Declarative instance setting idpMapping.rules turns ID-token claims into pond roles and the site-admin flag on every OIDC login — configuration, not code. Mapped grants travel through the SAME GrantsService path as manual ones (permission cache invalidated, collab access notify fires so live sessions revalidate — asserted by test), never raw rows. Ownership makes precedence explicit: role_grants.origin marks mapped rows, users.is_site_admin_managed marks a mapping-set admin flag. The mapping only creates and revokes what it owns — manual wins: hand-made grants and hand-promoted admins are never revoked by a missing claim (a manual toggle clears the marker and takes ownership). Removal of a claim revokes the mapped grant and the managed flag on the next login. Every mapping-driven change is audited with origin idp_mapping. Failure containment: unknown pond slugs and the last-Pond-Admin protection log-and-skip — a mapping problem must never become a login lockout. Tests drive real OIDC logins against the fake IdP with group claims: grant + working access, revocation incl. notify, manual-wins, managed site-admin promote/demote/hands-off. Documented in permissions.md (own section), ADR 0021, data-model.md and the hardening guide (care rule: same PR). Refs #217. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
8.5 KiB
Permission model — "the most specific setting wins"
This document defines the authoritative semantics of roles and grants. The
resolution algorithm is implemented once in
packages/shared/src/permissions/ and used by the API (guards), the collab
server (token issuance), and the frontend (UI affordances only — the client
never enforces security).
Roles
| Role | Scope of existence | Capabilities |
|---|---|---|
| Site Admin | instance (user flag) | everything: instance settings, users, quotas, plugins (install + set disabled/optional/required), all ponds' administration, legal pages, backups |
| Pond Admin | per pond (grant) | manage the pond: settings/fonts, labels, members and their grants, enable/disable optional plugins, trash, quotas view; implies Editor everywhere in the pond |
| Editor | per pond/label/page (grant) | create/edit/delete pages within the granted scope; view history, restore versions, use trash for pages they may edit |
| Reader | per pond/label/page (grant) | read pages within the granted scope; no history access |
| Public | pseudo-subject | what non-authenticated visitors may read (never write) |
Additional structural rules:
- Personal ponds (self-signup) have exactly one Pond Admin — the owner;
additional
pond_admingrants are rejected there. Shared ponds may have several. pond_admingrants exist only at pond scope. Editor/Reader grants exist at pond, label, or page scope.- Site Admin bypasses resolution entirely.
Grants
A grant is (subject, role, scope, effect) inside one pond
(table role_grants, see data-model.md):
- subject: a specific user,
authenticated(any logged-in user), orpublic(everyone, including anonymous visitors). - scope: the whole pond, one label, or one page.
- effect:
allowordeny.denyexpresses the vision's "all pages except label X" (pond-scope allow + label-scope deny). - origin:
manual(admin-created) oridp(written by the claim mapping below). Resolution ignores the column — it only exists so the mapping can tell its own rows apart.
IdP claim mapping (issue #217, ADR 0021)
With external authentication (#214), the instance setting
idpMapping.rules maps ID-token claims declaratively onto this model —
configuration, not code:
[
{ "claim": "groups", "value": "wiki-editors", "role": "editor", "pondSlug": "team-wiki" },
{ "claim": "groups", "value": "wiki-admins", "role": "site_admin" }
]
Semantics, decided and tested:
- Applied on every OIDC login (fresh claims exist only there). Matching is string equality; array claims match by containment. Several rules for one pond: the strongest role wins.
- Same service path as manual grants (
GrantsService) — the permission cache is invalidated and the collab access notify fires, so live sessions revalidate exactly as on a manual change. Never raw rows. - Removal of a claim revokes the mapped grant on the next login. The
mapping only ever touches rows with
origin = 'idp'— manual wins: hand-made grants and hand-promoted site admins are never revoked by a missing claim (users.is_site_admin_managedmarks a mapping-set flag; a manual toggle clears the marker and takes ownership). - Every mapping-driven change is audited (
grant.created/grant.deleted/user.site_admin_setwithorigin: idp_mapping). - Configuration errors (unknown pond slug) and refusals (last-Pond-Admin protection) are logged and skipped — a mapping problem must never become a login lockout.
Resolution algorithm
Question: may user U perform action A (read / write) on page P?
- If U is Site Admin → allow.
- If the page or its pond is in trash → only roles that could edit it may see it in trash views; regular access is denied.
- Collect all grants in P's pond whose subject matches U
(their user id;
authenticatedif logged in;publicalways). - Keep grants whose role covers action A
(write needs
editor/pond_admin; read is covered by any role). - Evaluate by descending specificity; the first level that contains
any matching grant decides:
- Page scope — grants on P itself.
- Label scope — grants on any label assigned to P, including inherited labels: a grant on label L applies to L and all its descendants in the label hierarchy.
- Pond scope — grants on the pond.
- Within the deciding level: if any matching grant is
deny→ deny, else allow. (Deny wins ties at the same specificity; a more specificallowstill beats a less specificdeny— that is the point of "most specific wins".) - No matching grant at any level → deny (default-closed).
Worked examples
Pond "Handbook", user Uma has pond-scope editor (allow):
| Extra grants | Uma edits page "Salaries"? | Why |
|---|---|---|
| — | yes | pond-scope allow |
label confidential on the page + label-scope editor deny for Uma |
no | label level is more specific than pond level |
additionally page-scope editor allow for Uma on "Salaries" |
yes | page level beats label level |
page has labels confidential (deny Uma) and hr (allow Uma) |
no | same level → deny wins |
"Only pages with label Y" (vision) = no pond-scope editor grant + label-Y
editor allow. "All except label X" = pond-scope allow + label-X deny.
Non-page objects
- Attachments inherit the permissions of their page (or pond for pond-level files): read requires read on the page, upload/delete requires write.
- Comments (later milestone): reading follows page read; writing comments requires page read + the pond setting "who may comment" (readers-and-up or editors-only).
- History & trash: require write permission on the affected page (ADR 0013).
- Search results are filtered through the same resolution (ADR 0010).
- Plugin API calls execute with the viewing user's permissions (ADR 0008) — the API enforces this server-side.
- Classification lowering (issue #205, ADR 0022): raising a page's
VS-NfD classification is ordinary editorial work and needs only write
permission; lowering it is the sensitive direction (content escapes
marking through it) and requires the dedicated capability
canLowerClassification— pond-wide Pond Admin (the owner's standing grant; Site Admin bypasses). The capability lives in the shared model (permissions/pond.ts), never as an ad-hoc check; every raise and lower is audited (page.classification_raised/page.classification_lowered, seeaudit-events.md). The classification itself changes no read/write decision — it is a marking, not a protection mechanism.
Performance
Resolution needs the page's labels (+ label ancestors) and the pond's
grants — a handful of indexed queries, cacheable per (user, pond) with
event-based invalidation on grant/label changes. The collab server resolves
once at token issuance (token TTL ≤ 60 s) and re-checks on reconnect;
revoking write access closes live sessions via a pond-level notification
(LISTEN/NOTIFY).
UI obligations
- The frontend hides actions the user lacks (buttons, routes) but every denied server response renders a proper i18n-ed error — client checks are convenience, not security.
- Pond Admin UI must make effective permissions inspectable: "show effective access for user X / for Public" per page — this transparency feature is a story of its own and load-bearing for admin trust.