Three axes of authorization: from roles to relationships

Solving flexible permission queries in TypeScript using RBAC, ABAC, and Fine-Grained Authorization

The 3 axes of authorization

A post on Hacker News caught my attention as a clean summary of the fundamental questions any authorization system tries to answer:

  1. Given a person and a resource, what can they do? (subject, resource) => action[]
  2. Given a resource and an action, who can do it? (resource, action) => subject[]
  3. Given a person and an action, which resources can they act on? (subject, action) => resource[]

Most systems solve the first two questions well, but the third often ends up as scattered, ad-hoc queries in application code.

This post walks through three approaches to authorization, each covering more of these axes: simple RBAC, RBAC with explicit permissions, and fine-grained authorization.

Current implementation: simple RBAC

Role-Based Access Control: Assigns permissions based on predefined roles (e.g., Pilot, Dispatcher). Users inherit all permissions of their assigned role(s).

We have three roles: user, admin, and sysadmin. Authorization is an optional requiredRole flag on loaders and actions, checked by numerical precedence. This covers axis 1 implicitly, but nothing else.

In a simple RBAC system, we have to update the logic for every added role:

if (user.role === "admin" || user.role === "sysadmin") {
  // Allow access to flight schedules
} else {
  // Deny access
}

The core issue with role-based checks is that they conflate two concerns:

  1. Human organization (grouping permissions into roles)
  2. Authorization logic (enforcing specific capabilities)

When we hardcode role checks, every organizational change requires code changes. Introduce a new role with overlapping permissions? Update every check. If customers want to create custom roles, the hard-coded system breaks down entirely.

Proposed iteration: RBAC/ABAC with ACL

Roles become labels: human-friendly keys for groupings of explicit permissions (Access Control Lists). The real security gates are the individual permissions.

const acl: Record<string, Permission[]> = {
  admin: [
    { target: "flight", action: "view" },
    { target: "flight", action: "edit" },
    { target: "report", action: "view" },
  ],
  dispatcher: [
    { target: "flight", action: "view" },
    { target: "flight", action: "edit" },
  ],
  pilot: [{ target: "flight", action: "view" }],
};

With a permission-based approach, adding a pilot role requires no code change. Custom org-defined roles work the same way.

// Check specific permissions instead of roles
if (hasPermission(user, { target: "flight", action: "view" })) {
  // Allow access to flight schedules
} else {
  // Deny access
}

We can combine this with ABAC for more complex conditions.

Attribute-Based Access Control: Uses policies that combine attributes of users, resources, and environment to determine access, e.g. whether you are the owner of a resource.

// Role-based permission check
const hasEditPermission = hasPermission(user, {
  target: "doc",
  action: "edit",
});
// Attribute-based permission check
const isOwner = isDocumentOwner(user, doc);

if (hasEditPermission || isOwner) {
  await editDoc(changes);
}

For a type-safe implementation of this RBAC/ABAC approach using Effect, see Effect-based authorization with Policy.

Tradeoffs

Even with explicit permissions, RBAC doesn’t cover all 3 axes. We can determine:

  1. what actions a person has for a resource

  2. who can perform an action on a resource

  3. but not which resources a subject can act on without re-implementing the check in application code

    if (hasPermission(user, { resource: "doc", action: "edit", scope: "all" })) {
      return allFlights;
    }
    
    if (hasPermission(user, { resource: "doc", action: "edit", scope: "own" })) {
      return allFlights.filter((f) => f.creatorId === user.id); // in prod: custom SQL
    }

    This requires two passes:

    1. Check if someone has permission to act on the resource at all (e.g. view reports)
    2. Resolve the scope of that action (e.g. view own reports vs. view all reports)

For coarse-grained permissions, this tradeoff is acceptable. By coarse-grained, I mean:

  • Do you have view permissions for this resource?
  • Are you the owner/creator of this resource record?
  • Are you on a team that has view permissions for this resource?

Fine-grained permissions get harder with RBAC alone:

  • Individual share permissions per resource record (a la Google Docs share modal)
  • Permissions that depend on the state or properties of the resource (e.g., only allowing maintenance staff to access aircraft records when set to a certain status)
  • Determining permission precedence if a user is on multiple teams

Each of these requires custom application logic (listRecordsByStatus, listFlightsByPilotAssignment, listDocumentsSharedWithUser) instead of leveraging the permission system directly. As requirements grow, so does the maintenance burden. Relationship-based models solve this by treating permissions as queryable graph edges.

FGA

Fine-Grained Authorization (FGA): Enables precise access control at a granular level. Allows for complex relationships between users and resources.

FGA is not a model itself but a way to implement different models like RBAC. The underlying model can change incrementally.

A DSL defines things like team inheritance without explicitly mapping every permutation (see appendix below). The FGA resolver determines at runtime what is possible across both explicit and implicit permissions (e.g. individual role assignments + team permissions).

const checkResult = await workos.fga.check({
  checks: [
    {
      resource: {
        resourceType: "report",
        resourceId: "7",
      },
      relation: "viewer",
      subject: {
        resourceType: "user",
        resourceId: "d6ed6474-784e-407e-a1ea-42a91d4c52b9",
      },
    },
  ],
});

if (checkResult.isAuthorized()) {
  console.log("User is authorized to view the report");
}

Beyond boolean checks, FGA supports full queries on both resources and subjects, satisfying all 3 axes.

1. (subject, resource) → actions

const relationsToCheck = ["owner", "editor", "viewer"];

const checks = relationsToCheck.map((relation) => ({
  resource,
  relation,
  subject,
}));

const { results } = await workos.checkBatch({ checks });

2. (resource, action) → subjects

const { data } = await workos.fga.query({
  q: "select viewer of type user for document:finance-report",
});

3. (subject, action) → resources

const { data } = await workos.fga.query({
  q: "select document where user:bob is owner",
});

Tradeoffs

  • Most FGA services require dual-write: once to your application database and once to the authz service as the source-of-truth. Every relevant operation must read/write to both.
    • The alternative is mapping the 3 axes to custom SQL filters (see Oso), which couples implementation knowledge with the policies.
  • I used WorkOS in my code examples, but as of this writing they were sunsetting their FGA implementation. There are several other implementations based on Google Zanzibar.
    • An in-house implementation on Postgres is possible but adds operational complexity.

Choosing an approach

Start with the simplest model that covers your requirements:

  • Simple RBAC works when you have a fixed set of roles and coarse-grained resource access. Most early-stage applications fall here.
  • RBAC with explicit permissions is the right move when you need custom roles, attribute-based checks (e.g. ownership), or your role set is growing. It covers axes 1 and 2 well.
  • FGA becomes worth the operational cost when you need per-record sharing, complex relationship hierarchies, or you find yourself writing custom query logic to answer “what can this user see?” repeatedly.

Each step up adds operational complexity. Only move when the previous model forces you to rewrite authorization logic in application code.

FGA Appendix

These WorkOS FGA schema examples show how the DSL handles progressively complex authorization models: static roles, custom (org-defined) roles, and conditional policies with runtime attributes.

Static roles

version 0.3

type user

type organization
    relation role_admin [user]
    relation role_read_only [user]
    // roles can inherit other roles
    inherit role_read_only if
        relation role_admin

    relation can_read_company_info []
    relation can_write_company_info []
    relation can_read_reports []
    relation can_write_reports []

    // Inheritance rules give users access to permissions through roles
    inherit can_read_company_info if
        any_of
            relation can_write_company_info // writers can also read
            relation role_read_only

    inherit can_write_company_info if
        relation role_admin

    inherit can_read_reports if
        any_of
            relation can_write_reports // writers can also read
            relation role_read_only

    inherit can_write_reports if
        relation role_admin

Custom roles

version 0.3

type user

// Per-organization roles are created with Warrant IDs
type role
    // Roles are assigned to users
    relation member [user]
    // Roles can compose other roles
    relation parent [role]

    // Users inherit child roles
    inherit member if
        relation member on parent [role]

type organization
    // These are permissions checked in-app
    relation can_read_company_info [role]
    relation can_write_company_info [role]
    relation can_read_reports [role]
    relation can_write_reports [role]

    // Inheritance rules give users access to permissions through roles
    inherit can_read_company_info if
        any_of
            relation can_write_company_info // Writers can also read
            relation member on can_read_company_info [role]

    inherit can_write_company_info if
        relation member on can_write_company_info [role]

    inherit can_read_reports if
        any_of
          relation can_write_reports
          relation member on can_read_reports [role]

    inherit can_write_reports if
        relation member on can_write_reports [role]

Conditional policies

version 0.3

type user

type team
    relation finance_admin [user]
    relation finance_manager [user]

    inherit finance_manager if
        relation finance_admin

type expense
    relation approval_team [team]
    relation submitter [user]

    relation approve []
    inherit approve if
        any_of
            all_of
                relation finance_manager on approval_team [team]
                policy can_approve_amount
            all_of
                relation finance_admin on approval_team [team]
                policy is_high_value_expense

policy can_approve_amount(expense_attributes map, user_attributes map) {
    let can_approve_cost_center = expense_attributes.cost_center in user_attributes.approved_cost_centers;
    let can_approve_amount = expense_attributes.amount <= 1000;

    can_approve_cost_center && can_approve_amount
}

policy is_high_value_expense(expense_attributes map) {
    expense_attributes.amount > 1000
}