dorfteich/docs/architecture/permissions.md
Claude Fable 5 6aac785841
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m55s
CI / Build container images (pull_request) Successful in 3m0s
CI / Auth e2e pack (pull_request) Successful in 8m49s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m28s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m15s
CI / Import/export fidelity gate (push) Successful in 59s
#217: map IdP groups and roles onto the permission model
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
2026-07-31 13:09:11 +02:00

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_admin grants are rejected there. Shared ponds may have several.
  • pond_admin grants 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), or public (everyone, including anonymous visitors).
  • scope: the whole pond, one label, or one page.
  • effect: allow or deny. deny expresses the vision's "all pages except label X" (pond-scope allow + label-scope deny).
  • origin: manual (admin-created) or idp (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_managed marks 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_set with origin: 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?

  1. If U is Site Admin → allow.
  2. 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.
  3. Collect all grants in P's pond whose subject matches U (their user id; authenticated if logged in; public always).
  4. Keep grants whose role covers action A (write needs editor/pond_admin; read is covered by any role).
  5. Evaluate by descending specificity; the first level that contains any matching grant decides:
    1. Page scope — grants on P itself.
    2. 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.
    3. Pond scope — grants on the pond.
  6. Within the deciding level: if any matching grant is denydeny, else allow. (Deny wins ties at the same specificity; a more specific allow still beats a less specific deny — that is the point of "most specific wins".)
  7. 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, see audit-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.