Three axes of authorization: from roles to relationships
Solving flexible permission queries in TypeScript using RBAC, ABAC, and Fine-Grained AuthorizationThe 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:
- Given a person and a resource, what can they do?
(subject, resource) => action[] - Given a resource and an action, who can do it?
(resource, action) => subject[] - 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:
- Human organization (grouping permissions into roles)
- 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:
-
what actions a person has for a resource
-
who can perform an action on a resource
-
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:
- Check if someone has permission to act on the resource at all (e.g. view reports)
- 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
viewpermissions for this resource? - Are you the owner/creator of this resource record?
- Are you on a team that has
viewpermissions 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
}