Skip to Content

← All archived runs

Run: user-permission-editor

run.md

Run: user-permission-editor

  • branch: claude/user-permission-editor-pipeline-tczg5z
  • pr: #665

01_define/output/spec.md

Spec: Per-user permission editor (user directory)

  • slug: user-permission-editor
  • personas: Admin
  • touches: apps/web/app/(app)/users/[id] (page, actions, components), apps/web/lib/route-policies.ts, packages/services/src/db/services (user-permission-override write service), packages/services/src/permissions
  • complexity: standard

Problem

Authorisation now resolves through the permission engine (permission-foundation): a user's effective set is template(role) ∪ grants − revocations, and the UserPermissionOverride model already stores per-user grants, revocations, and an append-only audit trail. But that per-user layer ships dark — there is no management surface. An admin cannot hand one specific expert invoice.view, or strip lead.export from one CSM, without editing the whole role template and affecting everyone in that role. This is the core promise of the platform-access-permissions epic (Build the Bridge / 2026-Q2 Objective 3 — validating the technical infrastructure): fine-grained, per-user control that the role-template editor deliberately cannot give. The data model and resolver exist; this run gives them a UI.

Proposed change

Add a per-user Permissions tab to the existing user directory detail page (/users/[id]), reusing the role-template editor's feature×action grid so the two surfaces read identically:

  • Effective-permission matrix (feature × action) for the user, each cell showing its provenancetemplate (inherited from the role), granted (added by a per-user override), or revoked (stripped by a per-user override) — plus the scope qualifier (own / full) on granted cells.
  • Toggling a cell writes the corresponding UserPermissionOverride delta through a new service/action: granting a template-absent permission adds a grant; revoking a template-granted permission adds a revocation; returning a cell to its template state clears the prior override. Every change appends an audit entry (permission, change, who, when) to the override document.
  • Audit view of the user's override history — who changed what, when — read from the override's audit trail.
  • Admin users are the hard superuser (decision §7.2): their matrix renders all-granted and is not editable; override writes against an admin user are rejected at the service layer.
  • The tab is admin-only — gated the same way as the sibling role-template editor (resolveActionContext({ allowedRoles: ["admin"] }) on the actions, an admin-only route policy on the tab surface), even though the user directory itself admits admin/csm/sdm. CSM/SDM viewing a user profile do not see the Permissions tab.

Acceptance criteria

  • An admin grants a permission to a single user via the Permissions tab; that user holds it on their next request (resolver reflects the grant), and other users of the same role are unaffected.
  • An admin revokes a template-granted permission from a single user; it is gone from that user's effective set on their next request, with the role template and other users unchanged.
  • Returning a toggled cell to its template state clears the prior override delta (no residual grant/revocation) and records a clear audit entry.
  • Each matrix cell shows correct provenance (template / granted / revoked) and the scope qualifier on granted cells.
  • Every override change (grant / revoke / clear) appears in the tab's audit view with permission, change type, actor, and timestamp.
  • Attempting to open or edit overrides on an admin user is impossible: the matrix is all-granted and read-only, and the service rejects any override write targeting an admin user.
  • The Permissions tab is visible and usable only to admin; csm/sdm on the same user-directory profile page do not see it, and the underlying actions reject non-admin callers.

Out of scope

  • "View as this user" entry point — deferred. It launches the admin-view-as emulation (epic stub 9), which is not built yet; the per-row entry point lands when that emulation does.
  • Role templates (role-template-editor, stub 10) and primary-role assignment (in-app-role-assignment, stub 12) — separate surfaces.
  • Live refresh of an affected user's open session — permission-refresh-hardening (stub 13); a change taking effect on the user's next request is acceptable here.
  • Cross-tenant management — own tenant only (epic-level scope; overrides are tenant-scoped via tenantPlugin).

Open questions

  • none — gating (admin-only) and the "View as" deferral were resolved during Define.

02_build/output/notes.md

Build notes: user-permission-editor

  • commits: feat: user-permission-editor — per-user override service + admin Permissions tab

What changed

  • packages/services/src/db/services/user-permission-override/{index,instance}.ts: new UserPermissionOverrideService. getUserPermissionMatrix(tenantId, userId) resolves one user's effective matrix over every registry permission with per-cell provenance (template/granted/revoked/none) + scope, and returns the override audit history with actor names resolved. setUserPermissions(...) derives override deltas from the desired-effective set vs the role template (a template-absent or scope-differing permission → grant; a dropped template permission → revocation), diffs against the stored override into grant/revoke/clear audit entries, and upserts (bounded audit $slice). Admin target is rejected in both methods (superuser).
  • packages/services/src/db/services/index.ts: export the new service + its types (flows to @sustentus/services/server via the db barrel).
  • apps/web/app/(app)/users/[id]/permissions-actions.ts: getUserPermissionMatrix and setUserPermissions server actions, both gated allowedRoles: ["admin"] (mirrors the role-template editor; the registry models workspace features, not this management surface, so there is no requiredPermission to derive from). Validates input, revalidates the profile path.
  • apps/web/components/users/user-profile/user-permissions.tsx: admin-only server component. Builds the feature×action groups from the registry (same humanise idiom as the role-templates page) and renders the panel; returns null when the gated action fails (non-admin) so the surface is hidden.
  • apps/web/components/users/user-profile/user-permissions-panel.tsx: client grid reusing the role-template editor's checkbox + scope-select cell pattern, with per-cell provenance badges (Granted / Revoked), a button tablist switching the matrix ↔ audit view, dirty-tracked save, and router.refresh() after save so provenance + audit reflect the new state. Admin target renders read-only all-granted with a note.
  • apps/web/components/users/user-profile/user-profile.tsx: render <UserPermissions> next to <ManageRoles> on the directory detail page.

Acceptance criteria status

  • Grant a permission to a single user — setUserPermissions writes a grant; the resolver reads the same override doc, so the user holds it next request; other users of the role are untouched (per-user override only).
  • Revoke a template-granted permission from a single user — dropping a template cell writes a revocation; gone from the effective set next request; template + other users unchanged.
  • Returning a cell to its template state clears the prior override delta — re-checking a revoked cell / unchecking a granted cell removes the delta and records a clear audit entry.
  • Each cell shows correct provenance (template/granted/revoked) and scope — computed in getUserPermissionMatrix, badges + scope select in the panel.
  • Every override change appears in the audit view (permission, change, actor, timestamp); overrides on an admin user are impossible — service + action reject an admin target.
  • Admin user shows all-granted, read-only; the service rejects any override write targeting them.
  • The Permissions tab is admin-only — the server component + both actions gate on admin; the surface is hidden for csm/sdm and the actions reject non-admin callers.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here.

Notes for review

  • Gating deliberately uses allowedRoles: ["admin"] (no registry requiredPermission) — the Define-stage decision: the registry models workspace features, not management surfaces, mirroring the sibling role-template editor.
  • "View as this user" is out of scope (deferred to admin-view-as, stub 9) per the spec.
  • The surface is a titled admin-only Card with an in-card button tablist (matrix ↔ audit), matching the existing profile-page idiom — no shared Tabs primitive was added.

03_release/output/changelog.md

Fine-tune permissions for a single user

Live entry: apps/help/app/changelog/2026-07-17-user-permission-editor/page.mdx · personas: admin · PR #665

You can now grant or revoke individual permissions for one person from the user directory, without changing what everyone else in their role can do.

  • Open a user's detail page and use the new Permissions tab to see everything they can do as a matrix.
  • Each cell shows where the permission comes from — inherited from their role, granted just for them, or revoked just for them.
  • Toggle a permission to hand it to that one user, or take it away, and set whether it applies to their own records or the whole tenant. The change takes effect on their next request; no one else in the role is affected.
  • Every change is recorded in an audit view — what changed, who changed it, and when.
  • The tab is admin-only, and admins always hold every permission, so an admin can never be locked out.

03_release/output/investor-update.md

Admins can now fine-tune permissions one user at a time

Who it's for: Platform admins What shipped: A per-user Permissions tab in the user directory to grant or revoke individual permissions, each cell showing where it comes from, with a full audit trail. Why it matters: Precise, exception-level access control without touching whole roles — advancing Build the Bridge's Objective 3, Validate Technical Infrastructure & Payout Flow.

Dig deeper: <merged-PR URL> · <changelog entry URL>

03_release/output/release.md

Release: user-permission-editor

  • pr: #665 — https://github.com/sustentus/sustentus/pull/665 · merged: pending (awaiting gate + CI)
  • CI: pending on merge commit
  • technical docs: no technical docs impact — the UserPermissionOverride model + effective-permissions resolver are already documented in apps/docs/app/technical/packages/services (permission-foundation); this run adds only an admin UI + a thin management service below that page's module-level granularity
  • business docs: apps/docs/app/business/roles/page.mdx — added a per-user-permissions capability bullet to the admin section, alongside role templates / extra roles / view-as
  • release notes: both — changelog entry (apps/help/app/changelog/2026-07-17-user-permission-editor/page.mdx) + investor draft in this PR
  • sent: pending

Review summary (self /code-review, medium — standard complexity)

  • No correctness bugs found; all seven acceptance criteria hold.
  • Reuse: humanise/featureGroups in user-permissions.tsx duplicate ~15 lines from the role-templates settings page — accepted (deliberate decoupling; the page helper can't import the service, and the two surfaces evolve independently).
  • Convention: UserPermissionOverrideService.resolveAudit queries User.find({_id:{$in}}) without an explicit tenantId — accepted (correct by globally-unique _id; actors are same-tenant admins).

Acceptance check (vs spec)

  • Grant to a single user — setUserPermissions writes a grant; resolver reads the same override doc, so the user holds it next request; other role members unaffected (per-user override only).
  • Revoke a template-granted permission — dropping a template cell writes a revocation; gone from the effective set next request; template + other users unchanged.
  • Return a cell to template state clears the delta and records a clear audit entry.
  • Each cell shows provenance (template/granted/revoked) + scope — computed in getUserPermissionMatrix, badges + scope select in the panel.
  • Every change appears in the audit view (permission, change, actor, timestamp); overrides on an admin are impossible — service + action reject an admin target.
  • Admin target renders all-granted, read-only; service rejects override writes against admins.
  • The Permissions tab is admin-only — server component + both actions gate on admin; hidden for csm/sdm and the actions reject non-admin callers.