$ cd /engineering && cat ./six-shapes-of-authorization.md
Identity
> Six Shapes of Authorization
Robert Chiniquy
||16 min read
share:
width:
Six Shapes of Authorization
Every permission system reduces to one of six shapes.
We axiomatized over 1,800 identity and authorization systems – SaaS products, databases, cloud providers, infrastructure tools, HR systems, developer platforms, anything with a permission model – and the structural variation is far narrower than anyone expected. Not a soft taxonomy. A mathematical partition: sharp boundaries, no overlap, every system in exactly one class.
Class
Name
Pattern
A
Flat RBAC
User to role to permission, no nesting
B
Nested Membership
Recursive group containment
C
Policy Evaluation
Allow/deny policy documents
D
Object Privileges
Grants cascade through object hierarchy
E
Context-Scoped Roles
Modifier: binds any class to a project/workspace/org
F
Other
Minimal, hybrid, or domain-specific structures
I’m Robert. I build identity infrastructure at ConductorOne. We maintain a fleet of connectors – currently north of two hundred – that reach into every identity system an organization uses and pull back a consistent picture of who has access to what. Okta, AWS, GitHub, PostgreSQL, Salesforce, Active Directory, and a couple hundred more.
Each connector translates a vendor-specific permission model into a common schema. The trouble is that “vendor-specific permission model” undersells the diversity. These systems don’t just have different APIs. They have fundamentally different ideas about what a permission is. Okta thinks a permission is a role assignment. AWS thinks it’s a policy evaluation. PostgreSQL thinks it’s a grant on an object. Jira thinks it’s a role binding scoped to a project. The classification explains why.
What Axiomatization Actually Means
An axiom, for our purposes, is a Datalog rule that defines how permissions are derived in a system. Datalog has been around since the eighties, and it turns out to be a near-perfect fit for expressing permission models: declarative, compact, mechanically checkable. We write axioms for every permission system we analyze. The axiom file is the single source of truth for “what does this system think a permission is?”
There are two kinds of statements in an axiom file. Predicate declarations describe the shape of observable facts – the things you can read from the system’s API. Derivation rules describe how the system computes new facts from those observables. The distinction matters: predicate declarations are like column definitions in a database schema. Derivation rules are like views that compute over those columns. A system’s class is determined by the structure of its derivation rules, not by how many predicates it declares.
Here’s Okta. Three predicate declarations, three derivation rules:
The declarations say: Okta has users assigned to roles, users in groups, and groups with roles. The rules say: a user has a role either directly or through group membership, and permissions flow from roles. That’s it. That’s Okta’s entire permission derivation.
The full axiom files are richer than this. Each one declares typed sorts for domain entities, annotates base predicates with their API source, defines invariant predicates that detect violations, and marks terminal predicates for verification output. A real axiom file might look like:
.type UserId <: symbol
.type Role <: symbol
% Source: GET /api/v1/users
.decl user(id: UserId)
% Source: GET /api/v1/users/{id}/roles
.decl user_role(user: UserId, role: Role)
% Derivation
has_permission(U, P) :- user_role(U, R), role_permission(R, P).
% Invariant: detect orphaned role assignments
invalid_role(U, R) :- user_role(U, R), !user(U).
.output has_permission
.output invalid_role
The invariant predicates are a pattern that recurs across all classes. If a role assignment references a user that doesn’t exist, that’s a data integrity violation. The axiom file detects it. These invariants turn the specification into more than a correctness check – they’re a data quality monitor.
Now compare Okta to Freshservice. Freshservice also has agents (users), roles, and group membership. The predicate declarations look similar. The derivation rules are almost identical. These two systems have different APIs, different UIs, different customers – but structurally, they make the same claims about what a permission is.
One more. Discord looks different on the surface – guilds, channels, permission overwrites – but strip away the vocabulary and the derivation follows the same pattern. Users are in guilds. Roles are assigned per guild. Permissions flow from roles. There’s an extra layer where channels can override guild-level permissions, but the core shape is the same.
The structural similarity isn’t a coincidence. It’s the basis for classification.
Five Questions That Partition the Entire Space
Once you have axioms for enough systems, patterns emerge. Systems that look completely different on the surface turn out to have the same underlying derivation structure. Okta and Freshservice and PagerDuty all compute permissions the same way: user gets role, role has permissions, groups are a shortcut for assigning roles. The APIs differ. The axioms don’t.
This is an isomorphism: a structural equivalence that preserves the relationships between elements even when the elements themselves are renamed. user_role(U, R) in Okta and agent_has_role(A, R) in Freshservice are the same predicate with different names.
When you find enough isomorphisms, you can define congruence classes: groups of systems where every member is isomorphic to every other member. The class is defined not by what the systems are called or what industry they serve, but by the structural features of their axioms.
Sandhu, Coyne, Feinstein, and Youman formalized role-based access control in 1996. Harrison, Ruzzo, and Ullman proved fundamental limits on what’s decidable about access control systems back in 1976. The theory has been there for decades. What we’re adding is the empirical observation: when you axiomatize 1,800 real systems, the structural variation is far narrower than you’d expect.
Five boolean questions determine the class:
Does the system use recursion? If computing effective membership requires chasing a self-referential rule – effective_member(U, G) :- effective_member(U, Child), parent(Child, G) – the system has recursive derivation. If every fact can be computed in a single pass over the base predicates, it doesn’t.
Do groups nest? Can a group contain another group, requiring transitive closure to resolve membership?
Are there policy documents with allow/deny semantics? Does the system evaluate statements with explicit Effects, where Deny takes precedence?
Are privileges granted on objects? Does the direction flow from object to principal rather than principal to role?
Are roles bound to a context? Is the same role meaningful only within a specific project, workspace, or organization?
Class A: Flat RBAC
A user gets a role. The role has permissions. Groups exist but don’t nest – they’re a single level of indirection, not a hierarchy.
User -----> user_role -----> Role -----> Permission
| ^
+---> group_member --> Group-+
Okta, Auth0, Zoom, Datadog, CrowdStrike, Discord, Freshservice, Greenhouse, PrismHR, and about a hundred and fifty others live here. If your system has users, roles, and maybe groups but the groups don’t contain other groups, it’s Class A.
The axioms are compact. Derivation rules are non-recursive because there’s no hierarchy to chase – every permission can be resolved in a single pass over the base facts. The average Class A system has 22 predicate declarations and 17 derivation rules.
The most common shape by a wide margin, and the simplest to reason about.
Class B: Nested Membership
Groups can contain other groups, and membership flows through the chain. If computing “who is effectively a member of this group” requires chasing a recursive relation, it’s Class B.
User --> member --> Group1 --> nested --> Group2 --> ... --> GroupN
| |
+---------- effective_member ---------------+
The defining feature is a recursive derivation rule. Here’s GitHub’s, from the actual axiom file:
.type UserId <: symbol
.type TeamId <: symbol
% Source: GET /orgs/{org}/teams/{slug}/members
.decl team_member(user: UserId, team: TeamId, role: TeamRole)
% Source: GET /orgs/{org}/teams/{slug} -> parent field
.decl team_parent(child: TeamId, parent: TeamId)
% Transitive team ancestry
.decl team_ancestor(descendant: TeamId, ancestor: TeamId)
team_ancestor(Child, Parent) :- team_parent(Child, Parent).
team_ancestor(Desc, Anc) :- team_ancestor(Desc, Mid), team_parent(Mid, Anc).
% Effective team-repo permission: direct grant or inherited from parent
% Permissions flow DOWN: child inherits parent's repo permissions
.decl effective_team_repo_permission(team: TeamId, repo: RepoId, perm: PermissionLevel)
effective_team_repo_permission(T, R, P) :- team_repo_permission(T, R, P).
effective_team_repo_permission(T, R, P) :-
team_parent(T, Parent),
effective_team_repo_permission(Parent, R, P).
team_ancestor and effective_team_repo_permission both appear in both the head and body of their second rules. That self-reference is the recursion – the structural marker that separates B from A. The system can’t resolve all memberships in a single pass because the depth of nesting isn’t known in advance.
GitHub’s axiom file also documents a structural constraint: the team hierarchy is a tree, not a DAG. At most one parent per team. Permissions are additive: the highest permission across all grant paths wins. A child team cannot reduce inherited permissions. These constraints are part of the axiom specification – they inform how the verification framework generates test inputs.
Azure AD security groups nesting inside security groups. Google Workspace org unit hierarchies. Active Directory’s OU structure. GitLab subgroups. HashiCorp Vault’s entity/group nesting. Keycloak group hierarchies. Wherever there’s transitive closure, that’s Class B.
58% of Class B connectors have recursive derivation rules, compared to 17% of Class A.
That 17% isn’t a contradiction. Class A systems don’t have group nesting, but some have recursion elsewhere: role inheritance chains where one role inherits capabilities from another (Splunk, Okta), reporting hierarchies where a manager’s access flows through the org chart (BambooHR, Paylocity), or permission set composition where sets include other sets (Outreach). The recursion isn’t in who belongs to what group; it’s in what does this role include or who reports to whom. The groups are still flat. The roles aren’t. Group nesting affects membership resolution for every user in the group. Role inheritance affects the permissions attached to a role. Different recursive structure, different operational impact.
Class C: Policy Evaluation
Here’s where it gets complicated.
Policy documents with statements, each statement having an Effect (Allow or Deny), evaluated with precedence rules. Deny beats Allow.
Principal --> attached_policy --> Policy --> Statement
|
+----------+----------+
v v v
Effect Action Resource
(Allow/Deny)
can_perform(P, A, R) :-
policy_allows(P, A, R),
!policy_denies(P, A, R).
That negation – !policy_denies – is where C gets complicated. Permissions aren’t just additive. They’re subtractive. You can’t compute the final answer by unioning grant sets; you have to evaluate deny precedence, permission boundaries, conditions, and wildcards.
AWS IAM is the canonical Class C system. GCP IAM is another. Cloudflare’s access policies land here too. Numerically rare – only three of our connectors are Class C – but among the most important to get right because they control access to cloud infrastructure.
Salesforce’s profile and permission set model also has Class C characteristics: permission sets can grant, but profiles can restrict, and the interaction between them requires evaluation rather than simple accumulation.
Class D: Object Privileges
The direction inverts.
Instead of granting roles to principals, privileges are granted on objects to principals. And the objects form a hierarchy: database contains schema, schema contains table, table contains column. Privileges cascade downward.
Database --> Schema --> Table --> Column
| | | |
v v v v
db_priv schema_priv table_priv col_priv
| | | |
+-----------+---------+--------+
|
v
Principal (grantee)
The PostgreSQL axiom file captures this with a privilege grant as the core predicate – a tuple of (role, object kind, object ID, privilege type, grant option):
.decl privilege_grant(
role_id: RoleId,
object_kind: ObjectKind, % table, view, sequence, schema, database
object_id: symbol,
privilege: PrivilegeType, % SELECT, INSERT, UPDATE, DELETE...
with_grant_option: symbol
)
% Effective privilege: direct grant
effective_privilege(R, OK, OID, P) :- privilege_grant(R, OK, OID, P, _).
% Effective privilege: inherited via role membership (respecting INHERIT)
effective_privilege(R, OK, OID, P) :-
effective_member(R, GR),
privilege_grant(GR, OK, OID, P, _).
% Owner has all privileges on their objects
effective_privilege(R, "table", TID, P) :-
table(TID, _, R, _),
valid_privilege("table", P).
% Superuser has all privileges on everything
effective_privilege(R, OK, OID, P) :-
role_superuser(R),
privilege_grant(_, OK, OID, P, _).
Four different paths to effective privilege: direct grant, inherited through role membership, implicit via ownership, implicit via superuser. The PostgreSQL axiom file is 406 lines. Instead of “what can this user do?” the natural question is “who can do things to this object?”
PostgreSQL, MySQL, Oracle, Snowflake, SQL Server, ClickHouse, Elasticsearch. The highest axiom complexity of any class. SQL Server has 78 predicate declarations and 81 derivation rules. The average across Class D is 44 declarations and 44 rules – double the average of Class A.
Class E: Context-Scoped Roles
Class E is not a shape on its own. It’s a modifier that binds another class’s permission machinery to a context – a project, a workspace, an organization, a team. Roles stop being global and become meaningful only within a specific scope.
User --> Context (project/workspace/org)
|
v
Role Binding --> Role --> Permission Scheme --> Permission
The Azure Infrastructure axiom captures this with scope as the binding element:
% Role assignments: the core binding of principal + role + scope
.decl role_assignment(id: symbol, principal: PrincipalId, role: RoleDefId, scope: ScopeId)
% Scope hierarchy: management group > subscription > resource group > resource
.decl scope_parent(child: ScopeId, parent: ScopeId)
% Effective role assignments inherit down the scope hierarchy
effective_role_assignment(P, R, S) :- role_assignment(_, P, R, S).
effective_role_assignment(P, R, S) :-
effective_role_assignment(P, R, Parent),
scope_parent(S, Parent).
The same “Contributor” role means different things at management group scope versus resource group scope. The role assignment carries no meaning without the context it’s scoped to.
What makes E interesting is what it wraps:
E alone (pure context scoping with flat RBAC inside): Jira project roles, Monday workspace access, Linear team membership, Asana project roles, LaunchDarkly project/environment roles. The permission machinery inside each context is Class A – flat role assignment. The E modifier just says “this role applies here, not everywhere.”
E wrapping B (context-scoped with nested membership): GitHub is the canonical example. Repository-scoped roles with nested team membership. Contentful spaces with team inheritance. GitLab project access with subgroup hierarchies. The context is E, the group structure inside it is B.
E wrapping D (context-scoped with object privileges): MongoDB Atlas. Organization roles at the org level, project roles per project, and database-level actions on specific collections. Discord too – guild-scoped roles with per-channel permission overwrites that function as object-level grants.
E wrapping B and D (the full stack): Azure infrastructure. Roles are scoped to management groups, subscriptions, resource groups, or individual resources (E). Group membership is transitive via Entra security groups (B). Resource-scoped assignments function as object-level access control (D).
The second most common pattern in the wild – 51 of our connectors – because most modern SaaS products scope their roles to some kind of workspace or project container.
Class F: Other
Systems that don’t fit the A-E criteria cleanly. Not a dumping ground for things we haven’t gotten to; these are real permission models with structures that either don’t recur across enough systems to justify a dedicated class, or combine patterns in ways that resist clean classification.
Kubernetes uses RBAC, but its ClusterRole/Role distinction with namespace binding and aggregation rules creates a structure that’s more than flat RBAC but not quite context-scoped in the E sense. It’s its own thing.
Grafana is a genuine hybrid. Organization-level roles (Class A), folder hierarchy with permission inheritance (Class B), and fine-grained action+scope permissions on dashboards (Class D). Three classes coexist in one system, weighted differently depending on how you’re using it.
Terraform Cloud has workspace-level permissions but the relationship between organizations, workspaces, teams, and sentinel policies creates a custom graph that doesn’t map to the standard patterns.
Redis ACL patterns, Argo CD application/project permissions, Cisco Meraki organization/network hierarchies – each of these has enough internal logic to be a real permission model, but the structure is domain-specific rather than recurring.
In practice, the boundary between classes is sharper than you’d expect but not absolute. Some systems straddle two classes – AWS is primarily Class C (policy evaluation) but has Class D characteristics (resource-based policies) and Class E overlays (Identity Center). The axiom files handle this with overlays: a base axiom captures the primary class, and delta files add the secondary patterns. The classification is structural, not a straitjacket.
Class F systems tend to have either very simple axioms (a handful of predicates, minimal derivation) or very unusual ones. They’re handled individually rather than by pattern.
What Happens When You Flatten a Higher Class
The class hierarchy has a direction. Higher classes can faithfully represent everything a lower class expresses. A Class B system (nested membership) can represent any Class A system (flat RBAC) – set the nesting depth to one. A Class C system (policy evaluation) can express RBAC by defining a policy that allows each role’s permissions. Lossless going up.
Going down is where it gets interesting. When you need to represent a higher class in the language of a lower one, you can’t preserve the derivation logic. What you can do is realize the derivation: compute all the concrete permissions the higher system would grant at this moment in time and store them as flat records. A Class A database of grants can capture “Alice can read this S3 bucket” even though the original Class C system derived that permission through a chain of policy evaluation, boundary checks, and deny overrides. The record is correct. But it’s a snapshot, not a rule. When the policy changes, the snapshot is stale.
This is what every identity aggregation platform does, including ours. We reduce the richness of each system’s permission model to point-in-time records in a database. For Class A and E systems, almost nothing is lost – the records are the model. For Class C and D systems, the records capture the current state but not the mechanism that produced it. “Can Alice access this bucket?” – accurate. “Under what conditions could Alice’s access change?” – you need the original system’s policy language.
When you look at your access data in an aggregation tool and see a flat list of who-has-what, you’re seeing the realized output of whatever class of permission system generated it. For simple systems, that’s the whole picture. For complex systems, it’s the right picture right now.
The Class Your System Is Actually Running
A system’s theoretical class – what its permission model is capable of expressing – may be higher than the class your organization is actually using.
Azure AD is Class B. It supports nested security groups with transitive membership resolution. But if your organization doesn’t nest groups – if every group membership is direct – your Azure AD deployment is effectively Class A. The recursive derivation rule exists in the axioms, but it never fires because the base facts that would trigger it aren’t there.
GitHub is Class E wrapping B. But if you don’t use nested teams – if every team is flat – it drops to E wrapping A. Many GitHub organizations never create child teams.
Okta supports group rules that can create dynamic, overlapping memberships. Most Okta deployments use static group assignment, keeping it firmly in Class A.
Jira is Class E with a permission scheme system that can express complex conditional access. In practice, most Jira projects use one of a handful of standard permission schemes, making the effective model much simpler than what Jira can theoretically express.
PostgreSQL is Class D with full column-level privilege cascades. Many Postgres deployments only grant at the schema or table level, collapsing the four-level hierarchy to two.
ServiceNow has ACL evaluation that places it in Class C – conditions, scripts, evaluation order. But because ACL evaluation requires runtime expression parsing, most integrations (including ours) extract only the base role assignments, treating it as effectively Class A.
Your organization’s effective class is determined by which features you’ve actually configured, not which features the software supports. Unused features don’t add complexity. The access model you need to reason about is usually simpler than the vendor’s documentation would suggest. That’s good news. Yay!
Series
This is part of a series on formally verifying identity connectors: