Authoritative reconciliation of frozen preset roles into existing tenants¶
Deployment-scope supersession (2026-08-28): the frozen-only function remains as direct-seed safety, but hosted deployments now finish with the authoritative all-preset transaction defined by
2026-08-28-authoritative-preset-role-reconciliation-iteration-1-design.md. Its earlier editable-preset preservation rule no longer describes post-deploy state.
1. Problem distillation¶
- Preset roles are cloned per-tenant at provisioning; seeding thereafter is additive-only (
cloneRolesIntoTenant/backfillMissingPresetRolesnever delete aRolePermission, andseedGlobalRolePresetsonly adds/updates). So when a preset's grant set is narrowed in code (e.g. the teacher role losingteachers.employment/documents/healthper the self-service spec), tenants provisioned earlier retain the old, broader grants indefinitely. - The reverse gap exists too: a new scope/entity added to the catalogue never reaches an existing tenant's
admin(or other frozen) role, because backfill only creates missing roles, not missing grants on roles a tenant already has. - We want baseline changes to propagate to existing tenants without disturbing per-tenant
PATCH /roles/:keycustomizations. This iteration solves that for the roles where the constraint is trivially satisfiable: the frozen roles, which no one can customize. - The frozen set is exactly the roles
RoleGrantsService.assertEditablerejects:admin(ADMIN_FROZEN) +PROFILE_COUPLED_ROLE_KEYS=teacher,referent,student(PROFILE_COUPLED). Code is their sole source of truth, so the seed may add, update, and prune their grants freely.
Success criteria (observable behavior that proves this works):
- After a deploy/seed, a tenant provisioned before the teacher narrowing no longer has RolePermission rows for teachers.employment/documents/health on its teacher role.
- After a deploy/seed, a tenant provisioned before a new scope was added to the catalogue does have the corresponding grant on its admin (and any other baseline-granted frozen) role.
- Editable presets (principal, hr, secretary, department_head, curriculum_coordinator) — global and tenant-scoped — are byte-for-byte unchanged by the reconcile; a tenant's PATCH /roles/:key edits survive.
- The reconcile is idempotent: a second consecutive run performs zero writes.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Propagating baseline changes to editable presets while preserving customizations (needs a baseline snapshot / per-grant provenance — deferred, see §9).
- Any new endpoint, standalone CLI, or dry-run inspection mode (chose auto-only in the seed prologue).
- Any schema change to Role / RolePermission / RoleActionPermission.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/permissions/role-clone.ts — cloneRolesIntoTenant |
The "read the global preset row (tenantId=null) with its permissions/actionPermissions, upsert into a tenant" loop is the exact mechanism; the new function is this plus a scoped delete pass, gated to frozen keys. |
cloneRolesIntoTenant is additive-only and runs over all preset keys; we add pruning and restrict to frozen keys. |
src/permissions/role-clone.ts — backfillMissingPresetRoles |
Cross-tenant iteration (prisma.tenant.findMany) executed in the Tier-1 prologue in every env incl. prod; the new step slots in right after it and reuses the "runs in prod, touches real tenants" placement. |
Backfill only creates missing roles and explicitly never touches existing grants; we do the opposite (sync grants on existing roles). Complementary, non-overlapping. |
prisma/seed/helpers/seed-role.ts — seedRoleForTenant |
The authored grant set for a role is already computed here (visibleNative ∪ others-scopes ∪ actions). Adding an opt-in prune pass after the upserts makes the global frozen rows authoritative using the set already in hand — no re-derivation. |
The helper is shared by editable presets too; the prune must be opt-in (prune?: boolean) and passed only for frozen-role calls. |
src/permissions/role-grants.service.ts — assertEditable |
Defines the frozen set (admin + PROFILE_COUPLED_ROLE_KEYS) — the exact predicate that gates which roles the seed may prune. The reconcile's frozen-key list is this predicate, keeping ownership and editability in perfect symmetry. |
It's a per-request guard; we reuse only the key set, not the throw. |
prisma/seed/helpers/expected-preset-grants.ts + test/rbac-grants.db-sync.e2e-spec.ts |
The independently-derived expected grant map per role is the drift oracle; extend the db-sync spec to assert every tenant's frozen roles match it exactly (no missing, no stale). | Today the oracle is checked against the global presets; we extend coverage to tenant clones. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Iterates prisma.tenant.findMany() and writes tenant-scoped Role/RolePermission rows, exactly like backfillMissingPresetRoles. Runs in the seed context (DB owner), which is not RLS-constrained. |
Reconciliation is inherently cross-tenant platform maintenance; there is no single caller tenant. |
| Academic-year scope | no | n/a — roles and their grants are not academic-year-scoped. | Role/permission rows carry no academicYearId. |
| RBAC entity key | none | No new entity key. | This is seed/provisioning machinery, not a domain entity. |
| Scopes | none | No new scopes; reconcile mirrors the existing frozen-role grant rows (native and others-scopes uniformly — both are RolePermission rows keyed by scopeId). |
Behavior change only; no catalogue delta. |
| Actions | none | Mirrors existing RoleActionPermission rows; no new action keys (read/update remain implicit). |
" |
| Service base | custom (seed helper) | A plain exported function in src/permissions/role-clone.ts typed on PrismaClient / Prisma.TransactionClient, mirroring its two siblings. Not a NestJS service. |
Consistent with the existing provisioning-seam functions in the same file. |
queries.ts shape |
n/a — uses prisma directly |
The sibling functions in role-clone.ts already call prisma.role.findMany / rolePermission.upsert inline; follow that local convention rather than introducing a queries.ts. |
Provisioning seam predates the per-module queries.ts convention and is not a request-path module. |
| Error codes | none | Seed-time function; failures throw raw and abort the seed. | No user-facing surface. |
| DTO conventions | n/a — no HTTP surface | No endpoint. | |
| File-backed sub-resources | n/a | Not file-related. | |
| Custom fields | n/a (handled transparently) | Others-scope (custom-field) grants are ordinary RolePermission rows and are mirrored by the same loop; no special-casing. |
pickCustomFields is a read-path concern, unrelated. |
| Profile completeness | no | n/a — unrelated to person completeness. |
4. Data model plan¶
Schema deltas¶
- None. No table/column/FK/enum changes. The only code-level "delta" is an opt-in
prune?: booleanfield on the existingSeedRoleInputtype inseed-role.ts.
Migration shape¶
- Additive / destructive / renaming: none — no migration. Row deletions performed by the reconcile are runtime data operations on
RolePermission/RoleActionPermission, not schema changes. - Data backfill: the reconcile itself is the data reconciliation; it runs in the seed prologue, not as a Prisma migration.
- Hazards from chapter 12 checklist: n/a — no
migration.sqlgenerated.
Indexes and uniqueness¶
- None added. Relies on existing
@@uniquecompoundsroleId_scopeId(RolePermission) androleId_actionId(RoleActionPermission) for the upsertwhereclauses and scoped deletes.
5. API surface¶
n/a — no HTTP surface. The feature is a function invoked in the Tier-1 seed prologue (prisma/seed.ts).
Swagger considerations¶
- n/a — no controller, no OpenAPI delta.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | No new (entity, scope) or (entity, action) grants authored. Change is behavioral: the four frozen-role calls in seedGlobalRolePresets (admin, teacher, referent, student via seedProfileCoupledPresetsForTenant) pass prune: true, making their global rows authoritative. |
*_SCOPES runtime constant |
none — no scope-field surface changes. |
Wiring delta (code, not catalogue):
- prisma/seed/helpers/seed-role.ts: add prune?: boolean to SeedRoleInput; after the native + others upsert loops and the action loop, when prune is set, delete any RolePermission/RoleActionPermission row on the role whose scopeId/actionId is not in the authored set just written.
- prisma/seed/roles.ts: pass prune: true on the admin, teacher, referent, student global-preset authoring calls (the seedProfileCoupledPresetsForTenant teacher call, and inline for admin; referent/student are authored via inclusion-list loops there — extend those to prune as well).
- src/permissions/role-clone.ts: add reconcileFrozenPresetRolesIntoTenants(prisma) — the frozen list is ['admin', ...PROFILE_COUPLED_ROLE_KEYS]; for each tenant × frozen role, upsert every global-preset grant then delete tenant grants absent from it.
- prisma/seed.ts: call reconcileFrozenPresetRolesIntoTenants(prisma) immediately after backfillMissingPresetRoles(prisma) (line ~124). Update the stale "prod has no tenants" comment (backoffice onboarding now provisions real prod tenants).
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Seed/provisioning is additive-only; never deletes a RolePermission (documented invariant in role-clone.ts docstrings) |
Introducing deletion of grant rows during seeding | Only deletion propagates a narrowing to existing tenants; additive-only structurally cannot | Yes — deletion is gated strictly to the frozen set (roles no one can hand-edit), so no customization can be destroyed. The role-clone.ts docstrings are updated to state the new, narrower invariant: "additive-only for editable presets; authoritative (add/update/prune) for frozen presets." |
seedRoleForTenant is uniform across all roles |
Adding a per-call prune flag (frozen calls only) |
Editable global presets are hand-editable via backoffice PATCH at tenantId=null; pruning them would clobber platform-admin edits to the global baseline |
Yes — flag defaults to off; only the four frozen calls opt in. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| None — engineering-initiated, no US. | Resolved |
9. Deferrals¶
- Editable-preset propagation (
hr,secretary,department_head,curriculum_coordinator,principal) — this iteration does not touch them. Propagating a baseline change while preserving per-tenantPATCHedits requires distinguishing a deliberate customization from an untouched baseline grant, which we cannot do today (no baseline snapshot / provenance). Follow-up: a future spec adding either a per-RolePermissionprovenance flag (source: PRESET | TENANT) or a per-tenantbaselineVersionenabling a three-way merge. Revisit when a live editable-preset baseline change actually needs to ship. - Standalone / dry-run script — chose auto-only in the prologue. Follow-up: if manual inspection of pending deltas is ever wanted, extract the reconcile body behind a thin CLI wrapper (the function is already prod-safe and idempotent). Revisit on demand.
10. Open questions¶
- Scope — frozen roles only. (Resolved in chat, 2026-07-22.)
- Trigger — automatic in the Tier-1 seed prologue. (Resolved in chat, 2026-07-22.)
None open.
11. Verification plan¶
- Unit specs:
- New
src/permissions/role-clone.spec.ts(or extend if present):reconcileFrozenPresetRolesIntoTenants— (a) prunes a tenant grant absent from the global preset; (b) adds a preset grant missing from the tenant; (c) updates a differing access level; (d) leaves editable-preset roles untouched; (e) idempotent (second run issues no writes / no-op deltas); (f) mirrors others-scope (custom-field) grants like native ones. - New/extended
prisma/seed/helpers/seed-role.spec.ts:prune: truedeletes a pre-existing global grant not in the authored set;pruneabsent leaves extra grants in place (editable-preset safety). - E2E specs:
- Extend
test/rbac-grants.db-sync.e2e-spec.ts: after the full seed, assert every tenant's frozen roles (admin/teacher/referent/student) match theexpected-preset-grants.tsbaseline exactly — no missing, no stale grant. Add a regression case that seeds a tenant with an injected staleteachers.employmentgrant onteacher, runs the prologue, and asserts it is gone; and one that injects a hand grant on an editable preset and asserts it survives. - Manual verification: on a local DB,
PATCHa stale grant onto a tenant'steacherrole via raw SQL, run the prologue (npx prisma db seed), confirm the grant is removed and that a manually-editedhrrole is unchanged.
Patterns: chapter 09 (testing), and feedback_e2e_isolation_patterns.md for E2E discipline (E2E DB reset per run, deleteMany cleanup).
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-07-22
- Chat reference: design shape approved by Fabio in chat 2026-07-22 ("y"), then written spec approved ("yes") including the §7 additive-only → authoritative-for-frozen-roles invariant change.
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.