Back to writing

Who can give power to whom

Using a capability and granting it are different authorities. Model both.

Almost every admin panel has a property that means, all at once, using the product, registering people, granting privileges and crossing boundaries. It's usually called admin. It's convenient until the day someone asks: can a customer's administrator create another administrator just like themselves?

Separation of duties starts from the idea that some authorities shouldn't come bundled just for convenience. In the catalog, that becomes distinct permissions:

users.write                -- register people
authz.roles.assign_tenant  -- assign tenant roles
authz.grants.direct        -- grant temporary exceptions
platform.tenant.bypass     -- cross tenants

Why “grant only what you have” fails

The most common rule is: a user can only grant permissions they hold themselves. It looks safe and has two flaws.

The first is self-replication. If the administrator holds every admin permission, the subset check also lets them grant admin to anyone. The policy “the tenant admin registers people and assigns operational roles, but can't create another admin” simply can't be expressed.

The second is the reverse: the rule forces anyone who only needs to grant a capability to hold it. A manager who hands out access to microdata ends up reading microdata, with no reason to.

The authority matrix

The way out is to model granting authority directly:

role_grantable(grantor_role, target_role)

tenant_admin   -> viewer
tenant_admin   -> analyst
platform_admin -> tenant_admin
platform_admin -> viewer
platform_admin -> analyst

Notice what isn't there: tenant_admin -> tenant_admin. The business rule stops being a side effect of comparing sets and becomes a line you can inspect. Direct grants follow the same reasoning, with another matrix:

permission_grantable(grantor_role, permission_code)

tenant_admin -> analysis.run
tenant_admin -> data.query
tenant_admin -> content.share

And above both matrices sits a hard deny: no tenant authority grants a platform permission, whatever the matrix says. If someone misconfigures it, the boundary still stands.

The predicate, with the parentheses in place

can_assign(actor, target, role) :=
  (
      tenant_branch(actor, target)
   OR platform_branch(actor)
  )
  AND role_is_grantable(actor, role)
  AND target_role_content_is_allowed(actor, role)

It looks pedantic, but here precedence is security. Without the parentheses, A OR B AND C AND D becomes A OR (B AND C AND D), and any actor who satisfies the tenant branch skips the matrix checks. It's a complete bypass hiding in an operator. The implementation should mirror the formula, or use equivalent early returns that leave no doubt.

The last term, target_role_content_is_allowed, matters when tenants can create their own roles: the check looks at what the role contains, not its name. A custom role called “Reader” that contains authz.roles.assign_tenant is not a reader.

Registering is not granting

POST /users              -- creates the identity
POST /users/{id}/roles   -- grants authority

Two endpoints, two services, two trails. Merging them into a “new user” form with a role dropdown makes every registration a grant, and the audit trail then has to separate what the architecture glued together.

Blocking self-assignment, actor == target, is a reasonable safeguard. But that's all it is: the real control is the matrix. If the matrix allows the escalation, stopping someone from doing it to themselves just means they ask a colleague.

Direct grants with a deadline

Temporary exceptions exist, and it's better to model them than to pretend they don't. But direct grants can't become a second, uncontrolled implementation of RBAC. The rules I follow:

  • expires_at is required. There's no eternal grant.
  • A justification is required, stored with it.
  • The grant is audited in the same transaction.
  • Its expiry counts towards session freshness.
  • Recurrence is monitored: the same grant renewed every month is a role nobody wanted to create.
  • Platform permissions can't be delegated by any tenant mechanism.

Provenance

For every effective permission, the system should be able to say where it came from: a system role, a custom role or a direct grant, who granted it, when and until when. That turns “why can this person do that?” into a query rather than a manual dig through logs.

This text is part of a series that starts at Authorization in four questions.