0013. Authorization: scoped RBAC with delegation ceilings and a decision API#
Status: Accepted
Context#
The IdP must separate IdP administration from application permissions (AZ-005), prevent delegated administrators from granting more than they hold (AZ-006, TEN-008), deny by default (AZ-007), and offer resource servers a policy decision contract (§15.3) while applications keep enforcing object ownership.
Decision#
- Permissions are namespaced. Built-in namespaces:
realm.*(identity-domain administration),org.*(delegated organization administration),support.*(support operators). Applications register their own permissions (POST /api/v1/realms/{realm}/permissions); they can never imply IdP administration. - Roles have a boundary (
realm,organization,project), may includeallowand explicitdenypermissions (deny overrides allow), and may be composite (cycle-checked, expansion bounded bylimits.max_role_expansion). Built-in roles:realm-admin,user-admin,realm-auditor,support-operator,support-approver,resource-server, and organization rolesowner,admin,member(internal/authorization/catalog.go). - Assignments are scoped to a realm, organization or project. Users receive
organization roles only through their membership; groups can hold roles, with
group nesting bounded by
limits.max_group_depth. A revocation updates holder revisions and emits audit/outbox evidence only when its delete removes a row. Concurrent API losers receive404; expiry workers tolerate assignments another worker already removed, without counting or recording an additional transition. - Ceilings: creating or editing a role, and assigning one, requires the actor to effectively hold every permission involved, including through composites. Incompatible roles are rejected at assignment.
- Decision API:
POST /api/v1/realms/{realm}/authorization/checkand/check-batch(admin audience,realm.authorization.check). The subject is asubject_tokenthat the calling service could introspect, or a free-form subject only for callers holdingrealm.authorization.simulate. The decision denies on missing input, organization mismatch, unknown subject or missing permission; permissions markedprivilegedrequire authentication within 5 minutes with an MFA or phishing-resistant ACR, otherwise the response carriesreason_code: STEP_UP_REQUIREDand areauthenticateobligation. Support tokens are denied protected identity actions and, in read-only mode, any action not ending in.read,.list,.view,.getor.search.cache_ttl_secondswas always 0 at acceptance (see the update below). Denials and privileged or support decisions are audited.
Alternatives considered#
- Global user roles: leak across tenants (AZ-003).
- Embedding an external policy engine (OPA, Cedar, OpenFGA): not embedded. Since Update 2026-10 an operator-configured engine can be called by the decision API through an adapter (AZ-026, below), and a bounded relationship model is built in (AZ-021); neither engine runs inside the server.
Consequences#
- Applications still enforce ownership of their own objects (§15.2); the decision API answers "may this subject perform this action in this organization".
- At acceptance, ABAC policy authoring, versioning and simulation were not
implemented, and
requires_approvalrelied on a caller-assertedapprover_id. Both have since changed; see the update below. - Relationship-based authorization, which this record left out at
acceptance, now exists in a bounded form (see the update below). The
App.PolicyEvalextension point ininternal/httpapi/app.gois still never set; published policies are evaluated directly by the decision code instead.
Update (2026-10)#
The decision above stands. RBAC remains the source of grants for IdP
administration; for application permissions in the decision API, relationship
tuples and an external engine in decide mode can also grant (both described
below). These additions were implemented after acceptance; most of them only
restrict:
ABAC policies (AZ-013, AZ-015..AZ-017, AZ-019;
internal/authorization/policy.go,policystore.go, routes ininternal/httpapi/policies.go). Policies are typed, bounded documents (at most 50 rules, condition depth 8, 64 condition nodes) scoped to the realm or one organization, withdenyandrequirerules oversubject.*,resource.*,context.*andenvironment.*attributes. They are evaluated after RBAC by/authorization/checkand/check-batchand can only deny or add obligations, never grant. Evaluation is three-valued: a condition over a missing or mistyped attribute applies the rule, so missing input denies (POLICY_ATTRIBUTE_MISSING) rather than allows. Subject attributes come from the validated subject token and the IdP's records; resource and context attributes are asserted by the authenticated calling service. The decision'spolicy_versionnames every evaluated revision (name@version).Versioning and publishing (
/api/v1/realms/{realm}/policies): revisions are immutable and hashed; only a published revision is evaluated; staging,diff,rollback(a new revision with the old content) and disabling are audited. Other nodes see a newly published revision within 5 seconds (PolicyCacheTTL). Permissions:realm.policies.read,realm.policies.write,realm.policies.publish.Simulation (
POST /api/v1/realms/{realm}/authorization/simulate,realm.authorization.simulate): evaluates up tolimits.max_batch_sizechecks against the current policies and, optionally, a candidate revision or draft definition, returnscurrentandcandidatedecisions and achangedcount, and is not audited as real decisions.subject_attributes(assumedacr, authentication age, client, organization) are accepted only here; a real/authorization/checkwith them is denied (SUBJECT_ATTRIBUTES_SIMULATION_ONLY, AZ-016).Conditional grants (AZ-012,
conditions.go): role assignments may carryconditions(client_ids,resources,min_assurance,max_auth_age_seconds,active_hours_utc,weekdays, and for support operatorsorganization_ids,subject_categories,support_modes,support_scopes) plusvalid_from/expires_at. A condition that cannot be evaluated fails closed.Separation of duties (AZ-018): assigning a role with
requires_approvalthroughPOST /role-assignments(or the organization member-roles route) now returns202 pending_approvaland creates a request under/role-assignment-requests. The approver is the authenticated caller of.../approve, must differ from the requester and the subject, must be able to grant the role, and needs recent authentication; the requester's authority is re-checked at approval and requests expire after 7 days. A policy created withrequires_approvalpublishes only revisions approved by someone other than their author. The policy rule is covered byTestPoliciesAndConditionalGrants; the role-assignment request workflow has no end-to-end test yet.Incompatible roles (AZ-018,
rbac.goHeldRoles,checkSeparation,TestSeparationOfDuties): a role'sincompatible_role_ids(set when the role is created; the relation is symmetric) are checked against everything the subject would hold: direct and membership-bound assignments, the roles of the user's groups and of those groups' ancestors, all expanded through composites, and the incoming role with its own composites. The check runs on role assignment (users and service principals; for a group grant, every member of the group and of its descendant groups), on adding a member to a group, on moving a group under another parent, and for SCIM group members and mapped roles. A conflict is409 conflict("the subject would hold roles that are incompatible with each other (AZ-018)"). For service principals only direct assignments count. A dynamic group's recomputation leaves out a member whose roles would conflict with the group's (or its ancestors') roles and recordsgroup.member.skipped(categoryauthorization, metadatamembership_idandsource: "dynamic", once per new skip: a member skipped at the previous recomputation is not recorded again, as remembered by each worker process, so a restart or a new rule records a lasting skip once more; any error of the check counts as a conflict;member_countleaves skipped members out). A group move is checked against the roles of the old ancestors too, so moving a group away from a conflicting parent can be refused. Validation errors aboutincompatible_role_idsare reported under the fieldcomposite_role_ids.Group grants need the authority of the whole chain (TEN-008, TEN-013): adding a group member, or moving a group under another parent, requires that the administrator could assign every role of the group (or new parent) and its ancestors (ADR 0006).
Conditional role-permission edits (API-010):
POST /roles/{role}/permissionsandPOST /organizations/{org}/roles/{role}/permissionsaccept an optionalIf-Matchwith the role's version (W/"v<N>"; a stale or malformed value is412). The realm route returns the newETag; the organization route does not, so read the role again to get it. The version is compared before the caller's authority, so an unauthorized caller with a stale tag gets412rather than403.Authorization hooks (
authorization.decision,internal/hooks) run last and can deny or add obligations, never grant; an unreachable hook denies (TestPreActionHooks).Decision audit (AZ-020,
internal/httpapi/decision.go,TestDecisionAuditSampling):/authorization/checkand/check-batchrecord the audit eventauthorization.decision(categoryauthorization: decision ID, action, resource type and ID, reason code, subject, and for support tokens the support grant) for every denial, every privileged action and every decision about a support token. Once the subject token has been resolved, denials also carry the subject (subject), the support operator for support tokens (metadata.actor, fromact.sub; for any token with anactclaim also the whole delegation chain,metadata.actor_chain, AUD-005) andimpersonation_grant_id(IMP-006); denials before that (INVALID_REQUEST, an inactive or unevaluable subject token) have no subject. The event's organization is the one named in the request'sresource. Ordinary allowed decisions are not recorded unless realm policydata_access.sample_allowed_decisions_percent(0–100, default 0) samples them; sampled events carrymetadata.sampled: true. Simulations are never recorded. Every refused authenticated API request is recorded once asauthorization.denied(reasonPERMISSION_REQUIRED, metadatamethodandroutewith IDs replaced by{id},permissionwhen one permission was missing,concealedwhen an organization's existence was hidden with 404), never the request body or query. A support token (pa_it_) presented to an identity-provider API is refused with403 impersonation_restrictedand recorded assupport.token.rejected(categorysupport, reasonIMPERSONATION_RESTRICTED, metadataroute): when the token is known, the actor is the support operator (typeuser), the subject the customer, with the support grant and organization; an unknown token is recorded with actor typesupport_token, IDunknown.Decision cache contract (AZ-019,
internal/httpapi/decision.gocacheHint,TestDecisionCacheContract): a decision made by RBAC alone (aNOT_PERMITTEDdenial, or an allow of a non-privileged action with no applicable ABAC policy, no active pre-action hook and no obligation, never for support tokens) carriescache_keyandcache_ttl_seconds(security.decision_cache_ttl, 30 s by default, at most 10 minutes,0disables). Callers may reuse it for that long for requests with the same key; the server itself caches nothing. Update 2026-10: the key is a SHA-256 over realm, subject, actor, client, the token's kind, audience andacr, organization, resource type and ID, the resource's project (with the project's state, owner and the organization's project grant), action, the subject's authorization revision, a fingerprint of the applicable role assignments (direct, of the subject's groups and of their ancestor groups, expanded through composed roles with the version of every role involved), the organization's state and authorization revision, the membership's state and revision, the policy version and the asserted context. Decisions are not cacheable when an applicable assignment has conditions, a future start or an expiry within the TTL, or the subject has rule-derived group memberships or group memberships with a validity window, so conditional grants never reuse a decision made for another token; nor when relationships were consulted or the realm uses an external policy engine. Role edits and (un)assignments bump the holders' revisions, service principals included, and a change to a role's permissions also bumps the holders of every role composing it, at any depth, and the members of groups holding them. Granting or removing a role on a group raises the membership revision of every member of the group and of its descendant groups (AZ-019). The TTL still bounds any change the key does not see. The SDK middleware does not cache decisions.Stale group state (AZ-010,
rbac.gogroupClosure): rule-derived (dynamic) group memberships count only while the group was recomputed withinsecurity.dynamic_group_max_staleness(1 hour by default) and its rule has no error; nested-group depth and cycle limits apply as before.Relationship-based authorization, bounded (AZ-021, AZ-022, AZ-023, AZ-027; migration
0053,relations.go,shares.go, relationship-authorization.md). Services register resource types (relations,implied_by, and which registered application permission each relation or one-hoplink.relationgives) and write relationship tuples inside one organization; subjects are confined to that organization. When RBAC does not grant an action on an object of a registered type,/authorization/checkevaluates the tuples (direct, implied relations, groups with ancestors, the organization as every active member, links), bounded at depth 6, 64 object reads and 1000 tuples per object; a bound is a denial. Step-up, ABAC policies, an engine inrestrictmode and hooks still apply to a relationship allow, and such decisions are never cacheable.POST /authorization/list-objectsreturns only objects verified one by one (at most 5000 candidates and 1000 checks), orall: truewhen an organization-wide grant covers the action. Users can share objects with members of the same organization (offer, accept, revoke) for relations the type marks shareable. Built-in permissionsrealm.resource_types.*andrealm.relationships.*are held byresource-server(and the.readones byrealm-auditor). Relationship tuples can only grant registered application permissions, never IdP administration. Rejected alternative: a general Zanzibar-style service with arbitrary rewrites and consistency tokens; the bounds keep every decision's cost predictable.External policy engines (AZ-026,
policyengine.go, policy-engines.md): the operator configures engines (security.policy_engines: HTTPS URL, bearer token, timeout at most 5 s) and a realm names one in its policy (policy_engine). Inrestrictmode (default) the engine is asked after local rules allow and can only deny; indecidemode it is also asked after a local denial and may allow, with step-up, ABAC policies and hooks still applying after it. An unavailable engine denies (EXTERNAL_POLICY_UNAVAILABLE) unless the realm setson_error: local. Decisions in such realms are never cacheable, andpolicy_versionnames the engine's version.Just-in-time elevation (AZ-024, migration
0054,elevation.go, elevation-and-access-reviews.md): administrators who could assign a role make another active user eligible for it at a scope, for at most 7 days at a time; the user asks from a recent MFA session with a reason; an approver who could assign the role and is not the requester approves (always required for privileged roles); the grant is an ordinary role assignment (source: elevation) withexpires_at, removed by the expiry sweep. Separation of duties and delegation ceilings apply as for any assignment.Access reviews (AZ-025, migration
0055,reviews.go, same page): campaigns snapshot up to 10000 role assignments of the realm or one organization, named reviewers keep or revoke each item (never their own), and closing (by an administrator or at the due date) revokes what was decided; the evidence export records every decision and outcome and states that it is not a regulatory certification. Permissionrealm.access_reviews.manage(privileged;realm-adminthroughrealm.*).Privileged roles are recognized the same way everywhere (access reviews, elevation, migration reconciliation, exports, approvals;
rbac.goIsPrivilegedRole): a role is privileged when it, or any role it composes at any depth, is marked privileged or holds a privileged permission, including wildcard grants (*,x.*) and custom permissions registered as privileged; a composition too large to inspect (more than 64 roles, the role and the existing roles it reaches) counts as privileged. Migration reconciliation uses the equivalent SQL condition (PrivilegedRoleSQL), with the same 64-role cap (TestPrivilegedRoleCapConsistent).Agents (MCH-017, machine-clients.md): service principals marked
is_agentneed an accountable human owner, and assigning them a privileged role always goes through the two-party request (202 pending_approval; neither requester nor subject may approve), whether or not the role is markedrequires_approval. Their tokens carryagent: true(andact.agentafter token exchange). Otherwise agents are ordinary service principals: no exemption from any check.Inactive subjects hold nothing (
rbac.gosubjectActive,TestInactiveSubjectsHoldNoPermissions): users that are suspended, locked, pending verification or deletion, or deleted, and service principals that are disabled or deleted, have no effective permissions anywhere, in decisions and in the worker's job re-checks (recovery_restrictedaccounts keep theirs).Project-scoped assignments are evaluated (AZ-002,
rbac.goEffectiveInProject,ProjectContext,TestProjectScopedRoles). Before this fix, assignments withscope.type: projectwere stored but never evaluated by token issuance or the decision API. They now count, in addition to realm and organization assignments, wherever a resource belongs to a project: token scope policy (required_permissions) for a resource with aproject_id(authorization code, refresh, device, token exchange, client credentials, personal tokens and API keys); the decision API when the request namesresource.project_id; and the inspection viewsGET /me/permissionsandGET /subjects/{subject}/permissionswith?project_id=. They are the subject's own assignments (users and service principals; group-held project assignments are still not evaluated) and apply only to that project, only while it is active, and, in an organization's context, only when the project belongs to that organization: the organization owns it (owner_organization_id) or holds an active, unexpired project grant for it. A project role therefore grants nothing for another project, at organization or realm level, or in an unrelated tenant's context. The decision API validatesresource.project_id: an unknown project denies withUNKNOWN_PROJECT, one outside the decision's organization (resource.org_id, or the subject token's) withPROJECT_ORGANIZATION_MISMATCH; the inspection views answer422. Explicit denies of any held role (realm, organization or project) override project allows, as everywhere (AZ-004); delegation ceilings are unchanged (project roles never carry identity-provider permissions, so they confer no administration); ABAC policies and engines seeresource.project_id.Composite roles are bounded per project (AZ-011,
rbac.govalidateRoleRefs,checkCompositeGraph,TestCompositeRoleBounds,TestCompositeRoleGraphChecks): a project role may compose only roles of its own project (422, "cannot compose another project's role"). Creating a composite role checks, in one linear walk, that no cycle is formed and that the role with every role it reaches stays withinlimits.max_role_expansion(422, fieldcomposite_role_ids); before, the walk only bounded path depth, enumerated every path of a diamond-shaped composition, and a composite set larger than the bound failed with500. Composites are immutable after creation, so a cycle cannot be created through the API. Roles that are each within the bound can still together exceed it for a subject holding several of them; evaluation then fails closed:Checkdenies and the decision API answersEVALUATION_ERROR.Backend enforcement is a contract test (AZ-008,
tests/contract/authz_enforcement_test.goTestAuthenticatedRoutesCheckPermissions):a.admin,a.tenant,a.selfanda.protectedonly authenticate. Every route registered through them (directly or through a local wrapper) must reach a permission check (a.can,a.requirePerm,a.orgGuard,a.dirScope,Authz.Check,Require,CheckAssignable,CheckObject) or a service operation that authorizes the acting caller itself (role administration, approvals, elevation, support access), found by following App methods and local closures statically. The remaining routes are listed with their reason: self-service on the caller's own account, and items addressed to the caller (invitations, shares, approvals, reviews); a self-service exemption must be registered througha.self. A new route without a check, or a stale exemption, fails the test.
Tests: TestPoliciesAndConditionalGrants (tests/e2e/policies_test.go),
TestPolicyDecisionContract, TestDelegationCeilings,
TestDecisionCacheContract, TestDynamicGroups,
TestRelationshipAuthorization, TestExternalPolicyEngine,
TestJustInTimeElevation, TestAccessReviews, TestProjectScopedRoles,
TestCompositeRoleBounds (tests/e2e/project_roles_test.go),
TestCompositeRoleGraphChecks, TestAuthenticatedRoutesCheckPermissions.
Migration implications#
Built-in permission names are part of the API; renaming one requires a migration
of role_permissions and documentation of the change.
Related requirement IDs#
AZ-001..AZ-027, MCH-017, TEN-007, TEN-008, DOM-005, TEST-010, TEST-011, TEST-014, §15