> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Permission Registry and RBAC

> After this ships, "the call centre can read contacts but never their national IDs" is a configuration a tenant administrator makes in a minute, and it is enforced by the same code path for a person and for an API key.

## Context

DEC-D5 fixed the model: every endpoint declares **exactly one** permission
`{module}.{resource}.{action}`; permissions bundle into roles; roles attach to users **and to
machines**. This feature is that model.

The design decision that makes it trustworthy is that the registry is **generated, not maintained**.
Routes declare their permission in code; the registry is built from those declarations; CI fails if
the database and the generated union disagree. A hand-maintained list of permissions drifts from
what the code actually checks within weeks, and a drifted permission list is worse than none — it
tells an administrator something false about what they just granted.

Machines carrying roles is the second load-bearing choice. An API key is not a special case with
its own scope language; it is a subject with role assignments, checked identically. That is what
lets an OAuth2 consent screen (FS-IDN-0007) render real permissions instead of invented scopes.

## Scope *(normative)*

* `identity.permissions`: the generated registry, seeded from route declarations.
* `identity.roles`: system roles seeded per organization, plus tenant-defined roles.
* `identity.role_permissions` and `identity.role_assignments` with a polymorphic subject.
* Authorization resolution: subject → roles → permissions, cached.
* CI checks: every route declares exactly one permission; no drift between code and database.

## Non-scope *(normative)*

* **Per-record permissions.** Permissions are per action, never per row. "Can edit this campaign but
  not that one" is out, deliberately: it is where authorization models stop being explainable to the
  administrator who has to configure them.
* Permission checks inside modules. The API layer resolves the context and passes it down; a module
  reading these tables would eventually enforce them inconsistently (ADR-022).
* OAuth scopes — FS-IDN-0007 renders bundles of these permissions; it does not define a second model.

## Behaviour *(normative)*

1. A permission is `{module}.{resource}.{action}`, lowercase, dot-separated. It is created by a
   **route declaring it**, never by hand and never by a tenant.
2. Every endpoint in the platform declares **exactly one** permission. Zero is a defect; two is a
   defect. CI enforces both.
3. The registry is generated at build time and seeded. **Drift between the code declarations and the
   database fails CI** — the same discipline as the parametric catalogs.
4. System roles are seeded per organization: `owner`, `admin`, `member`, `analyst`, `support`. They
   are `is_system` and cannot be edited or deleted by the tenant, only copied as a starting point.
5. Tenant-defined roles are plan-gated. Lower plans get the system roles; custom roles are an
   entitlement.
6. A `role_assignment` has a **polymorphic subject**: a membership (a person in an organization) or
   a machine credential (an API key, an OAuth client). Resolution and checking are the same code
   path for both — there is no second authorization path to keep in sync.
7. Resolution is `subject → roles → permissions`, cached per subject with invalidation on
   assignment change. A permission check performs **zero database queries** on the hot path.
8. Permissions are **additive only**. There are no deny rules: the union of a subject's roles is what
   they may do. Deny rules make an effective-permission set impossible to reason about, and every
   administrator who has debugged one knows why.
9. The `owner` role always holds every permission, including future ones. A permission added by a
   new module is available to owners immediately.
10. Sensitive permissions — `core.contacts.read_national_id`, `identity.impersonation.start`,
    `identity.roles.update` — are **never in a seeded role below `admin`**, and granting one is
    audit-logged with the granting actor.

## Data *(normative)*

| Table                       | Key invariants                                                                                                                                                    |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity.permissions`      | `code` TEXT PK matching `^[a-z_]+\.[a-z_]+\.[a-z_]+$`; `description`; `is_sensitive`; seeded from generated declarations, never tenant-created                    |
| `identity.roles`            | `organization_id` nullable (null = system template); `code` unique per organization; `is_system`; `is_active`                                                     |
| `identity.role_permissions` | (`role_id`, `permission_code`); a system role's rows are immutable                                                                                                |
| `identity.role_assignments` | polymorphic `subject_type` membership\|api\_key\|oauth\_client + `subject_id`; `role_id`; `assigned_by`, `assigned_at`, `revoked_at`; append-only with revocation |

## API *(normative)*

| Endpoint                                    | Class      | Permission                              | Budget    |
| ------------------------------------------- | ---------- | --------------------------------------- | --------- |
| `GET /v1/identity/permissions`              | Management | `identity.permissions.read`             | p95 \<1 s |
| `GET/POST/PATCH /v1/identity/roles`         | Management | `identity.roles.{read\|create\|update}` | p95 \<1 s |
| `POST /v1/identity/role-assignments`        | Management | `identity.role_assignments.create`      | p95 \<1 s |
| `DELETE /v1/identity/role-assignments/{id}` | Management | `identity.role_assignments.revoke`      | p95 \<1 s |

## Events *(normative)*

`identity.role.assigned` and `identity.role.revoked`, carrying subject, role and actor. Not offered
as outgoing webhooks in F1.

## Acceptance criteria *(normative)*

1. A route declaring zero or two permissions fails CI, each proven by a failing fixture.
2. Removing a permission from the code declarations while it remains seeded fails the drift check
   with a message naming the code.
3. A permission check performs zero database queries once the subject's cache is warm.
4. An API key and a user holding the same role are authorized identically across the endpoint set.
5. Revoking an assignment invalidates the cache and takes effect on the next request, not on the
   next cache expiry.
6. A tenant cannot edit or delete a system role.
7. Granting a sensitive permission writes an audit row naming the granting actor.
8. **Negative:** no deny rule exists anywhere in the model, and no endpoint resolves permissions by
   querying `identity` tables from inside another module.

## Execution

Single slice, synchronous command. The generator and the resolution function live in
`packages/core`; the CI checks land with TS-005. Route declarations are the source — the registry
is downstream of the code, never upstream of it.

## Open questions

| # | Question                                                                                      | Decides | By                       |
| - | --------------------------------------------------------------------------------------------- | ------- | ------------------------ |
| 1 | Do the five seeded system roles cover the common cases, or is one missing (a "billing" role)? | Daniel  | before approval          |
| 2 | Are custom roles gated at plan level, or available to everyone?                               | Daniel  | before commercial launch |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
