Communications ⇄ mailing-groups RBAC split¶
Amendment 2026-08-11 (post-implementation): the scope key is
communications.mailing_groups, notcommunications.groups. As designed and first shipped, the key wasgroups. That brokeGET /communications/:idwith a 500 on the first read: the route is@AggregateResponse(),CommunicationDetailDtohas carried a top-levelgroupskey since the module shipped, andFieldFilterInterceptor.assertAggregateShapetreats any top-level response key matching a registered scope name of the entity as a permission regression — throwing in dev/staging, logging in prod.communicationsbeing inFLAT_DTO_ENTITIESdoes not help: the aggregate branch runs first. The list route survived only because its top level is{data, meta}. Every normative reference below has been updated tomailing_groups; the reasoning, grants and behavior are unchanged. Two consequences worth carrying forward: (a) the catalogue seed only upsertsPermissionScope, so databases seeded with the old key keep a stale grantable row — cleaned bytools/drop-stale-communications-groups-scope.sql; (b) §11's verification plan was incomplete — the e2e suite only ever read the detail route expecting a 4xx, which short-circuits before the interceptor, so no test could have caught this. A happy-path 200 detail spec was added as the regression fence. The general lesson is now ch04 "Gate-only scopes" step 3b.
1. Problem distillation¶
communications.sendis currently the single gate over the whole module surface: compose, recipient picker, own archive, templates and all five/mailing-groupsroutes. Holding "may email people" therefore also means "may own mailing groups and browse the school's shared catalog" — one grant, two unrelated capabilities.- The referent preset holds
communications.send(so a parent can email their child's teachers). As a side effect a parent today can create private mailing groups and read the school's shared catalog — an internal-organisation surface that has no place in a family session. - The two capabilities want different grantees: sending is for teachers, management presets and admin and parents; mailing groups is staff-side only. They need separate RBAC keys.
- The gate must also cover the compose path, not just the routes: group visibility is
shared ∪ own, so shared groups are readable by every sender by query. 403'ing the five routes alone would still let a referent pass a shared group id inPOST /communications. - The referent's recipient reach was reviewed in the same conversation and is confirmed correct as implemented — no classifier change (see §8).
Success criteria (observable behavior that proves this works):
- A referent session gets 403 INSUFFICIENT_SCOPE on all five /mailing-groups routes.
- A referent session posting groupIds or ccGroupIds — including the id of a shared group that exists — gets 404 MAILING_GROUP_NOT_FOUND, and nothing is persisted.
- A referent session can still compose (POST /communications with inline recipients[]), page the recipient picker, read its own archive, and read its inbox — unchanged.
- A teacher session can still create/edit/delete its own private groups and still cannot create a shared one.
- An admin session is unchanged end-to-end (shared-catalog curation still gated by communications.management WRITE).
- GET /permissions for a referent shows the communications entity carrying the send action and no scopes.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Changing who a referent may contact. The tier rules in recipient-policy.ts are untouched (§8).
- Renaming communications.management (→ shared_groups would read better next to mailing_groups; a live scope-key rename churns /permissions for zero functional gain — §9).
- Any change to the sweeper, the delivery ledger, the webhook, templates, or the inbox.
- Giving the student preset anything (it holds no communications grant today and still won't).
- Splitting mailing_groups into its own RBAC entity (§7 divergence ledger records why not).
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/students/curriculum-selection.controller.ts + ch04 "Gate-only scopes" |
The gate-only scope pattern end to end: a scope with zero ScopeFieldMapping rows whose only job is driving the singular @RequireScope(entity, key, 'read'\|'write') on a sub-resource, with @AggregateResponse()/flat DTOs on the response side. students.curriculum_selection is the canonical precedent. |
Fits cleanly. |
src/communications/mailing-groups/mailing-groups.service.ts assertManagementWrite |
The service-side compiled-permission check for a gate whose applicability depends on the row (shared vs private) rather than the route, recompiled from ctx.roles so a management holder under a teacher view does not carry the gate into that view. The new compose-path check is the same shape, recompiled the same way. |
The new check is route-level-ish (it depends on the body, not on a loaded row), so it lands in CommunicationsService.create rather than as a decorator — same reason assertManagementWrite can't be a decorator. |
src/attendance/attendance.controller.ts + attendance-insights.controller.ts |
Singular @RequireScope(ATTENDANCE,'register','write') on routes whose body carries domain fields at top level — the documented FieldWriteGuard foot-gun avoidance (see the JSDoc on RequireScopes in require-scope.decorator.ts). The mailing-group create/update bodies (name, description, shared, members) are exactly that shape. |
Fits cleanly. |
prisma/seed/roles.ts + prisma/seed/helpers/expected-preset-grants.ts |
The exclusion-list vs inclusion-list preset split: the five management presets + teacher are exclusion-driven (a new catalogue scope lands on them automatically at READ, WRITE only if named in their write set), while referent and student are inclusion-driven (a new scope reaches them only if explicitly listed). Withholding from referent is therefore a no-op edit, which is exactly the property we want. | Fits cleanly — and the drift mirror derives from the same exported constants, so it needs no edit. |
docs/superpowers/specs/2026-08-10-referent-self-only-writes-design.md |
Precedent for narrowing an over-broad referent capability that was granted as a side effect of a coarser grant, and for the "double gate" habit (fence upstream, plus an explicit check at the privileged operation). Here: scope on the routes and the scope check on the compose path. | That spec's fence is a service-layer field carve-out inside a granted scope; ours is a clean new scope, so no carve-out is needed. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Unchanged. Every mailing-group and communication query already carries tenantId; both models are RLS-covered and registered in tenanted-models.ts. This iteration adds no model and no query. |
No data-model delta means no tenancy delta. |
| Academic-year scope | no | n/a — mailing groups and communications are not AY-scoped (they are correspondence records, not time-scoped academic data). The classifier's dated legs resolve on schoolToday, not on an AY override, and are untouched. |
Matches the module as shipped 2026-08-05. |
| RBAC entity key | existing communications |
No delta to src/common/constants/entity-keys.ts. |
A new mailing_groups entity was considered and rejected — see §7. |
| Scopes | new communications.mailing_groups; existing communications.management unchanged |
New gate-only scope, seeded in rbac-catalogue.ts SCOPES.communications at sortOrder: 1 (management shifts to 2). READ = see/use the catalog; WRITE = own private groups CRUD. Zero field mappings, and the mandatory runtime half — mailing_groups: [] as const on COMMUNICATION_SCOPES in scope-fields.ts — per the ch04 gate-only recipe (§6). |
The surface is a CRUD resource with a natural see-vs-own split, which is what a scope's READ/WRITE levels model; an action would collapse both into all-or-nothing. |
| Actions | none new; communications.send unchanged |
send keeps gating compose, recipient picker, templates and the own archive. It is removed from the five /mailing-groups routes (replaced, not stacked — §7). |
Stacking would let a role hold groups-WRITE it cannot exercise without an unrelated grant. |
| Service base | custom (MailingGroupsService, CommunicationsService) |
Neither extends BaseTenantedCrudService (bespoke aggregate services, flat DTOs). Unchanged by this spec; only the gate moves. |
Pre-existing shape. |
queries.ts shape |
unchanged | mailing-groups.queries.ts (findAccessibleGroups, findAccessibleGroup, findAccessibleGroupsWithMembersByIds, …) and communications.queries.ts are untouched — visibility stays shared ∪ own at the query layer, and the new permission check sits above it in the service. |
Permission compilation is not a queries.ts concern; queries.ts stays a pure data layer. |
| Error codes | existing only | MAILING_GROUP_NOT_FOUND (404, params { mailingGroupId }) is reused for the compose-path miss; INSUFFICIENT_SCOPE (403) comes from ScopeGuard for the routes. No new ErrorCode entries, no new params shape, no i18n additions. |
A group the caller may not see must be indistinguishable from a group that does not exist — the module's standing privacy rule. |
| DTO conventions | unchanged | No DTO added, removed or reshaped. CreateCommunicationDto still accepts groupIds/ccGroupIds for every caller; the rejection is authorization, not validation. |
Keeping it out of the DTO avoids a second place where the rule lives. |
| File-backed sub-resources | n/a — this iteration touches no file/attachment path. | ||
| Custom fields | no | n/a — communications is not in OTHERS_SCOPES; mailing groups carry no custom-field surface. |
Pre-existing. |
| Profile completeness | no | n/a — no person-record field is involved. | Pre-existing. |
4. Data model plan¶
Schema deltas¶
- None. No new table, column, FK or enum.
prisma/schema.prismais untouched.
Migration shape¶
- Additive / destructive / renaming: no migration at all. The change is a seed-catalogue row plus role-grant rows, both written by
npx prisma db seed. - Data backfill: none.
- Hazards from chapter 12 checklist: none apply (no DDL). The chapter-12 "new tenant-bearing model" checklist (RLS policy,
rls-coverage.ts,tenanted-models.ts) is not triggered — no new model. - Deploy note: this is a RESEED-REQUIRED change. The new
PermissionScoperow and the six role grants only exist after the seed runs; until then the five/mailing-groupsroutes 403 for everyone (nobody holds a scope that does not exist yet). Reseed must land with the deploy, not after it.
Indexes and uniqueness¶
- None added. The two partial uniques on
mailing_groups(shared namespace / private namespace) are unchanged.
5. API surface¶
No route is added or removed. Five routes change their gate; one route changes its behavior for a subset of callers.
| Verb | Path | Decorators (after) | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /mailing-groups |
@ProtectedResource(), @RequireScope(COMMUNICATIONS, 'groups', 'read'), @AggregateResponse() |
ListMailingGroupsQueryDto |
MailingGroupsResponseDto |
| POST | /mailing-groups |
@ProtectedResource(), @RequireScope(COMMUNICATIONS, 'groups', 'write'), @AggregateResponse() |
CreateMailingGroupDto |
MailingGroupDetailDto |
| GET | /mailing-groups/:id |
@ProtectedResource(), @RequireScope(COMMUNICATIONS, 'groups', 'read'), @AggregateResponse() |
— | MailingGroupDetailDto |
| PATCH | /mailing-groups/:id |
@ProtectedResource(), @RequireScope(COMMUNICATIONS, 'groups', 'write'), @AggregateResponse() |
UpdateMailingGroupDto |
MailingGroupDetailDto |
| DELETE | /mailing-groups/:id |
@ProtectedResource(), @RequireScope(COMMUNICATIONS, 'groups', 'write') |
— | 204 |
| POST | /communications |
unchanged: @ProtectedResource(), @RequireAction(COMMUNICATIONS, 'send') |
CreateCommunicationDto |
CreateCommunicationResponseDto (202) |
In every row above, @RequireAction(COMMUNICATIONS, 'send') is removed from the five /mailing-groups handlers and replaced by the scope decorator. The singular @RequireScope is required rather than the plural @RequireScopes(entity,'write'): the create/update bodies carry domain fields at top level, and the plural form would make FieldWriteGuard read name/members as scope names and 403 the caller (documented foot-gun in require-scope.decorator.ts).
Layered gates that stay exactly as they are:
- shared-group create/edit/delete → assertManagementWrite (communications.management WRITE, admin-only, ACTION_NOT_PERMITTED 403);
- private-group members → assertMembersContactable (classifier, MAILING_GROUP_MEMBER_NOT_CONTACTABLE 422).
Behavior delta on POST /communications¶
CommunicationsService.create step (2). Today it resolves groupIds ∪ ccGroupIds through findAccessibleGroupsWithMembersByIds (visibility shared ∪ own) and answers 404 MAILING_GROUP_NOT_FOUND on the first unresolvable id. After this change, when and only when at least one group id is supplied, the caller must additionally hold communications.mailing_groups READ; a caller without it takes the same 404 MAILING_GROUP_NOT_FOUND path on the first id in [...toGroupIds, ...ccGroupIds].
- A caller with no group ids pays zero extra queries — that is the referent's normal compose path.
- The check is a compiled-permission read, recompiled from
ctx.roles(session-narrowed) exactly likeassertManagementWrite, so a management holder composing under a teacher view is judged by the teacher view. - Mechanically: a
canReadMailingGroups(permissions, ctx): Promise<boolean>helper exported from the mailing-groups sub-module (sibling of the existing management check), consumed byCommunicationsService, which gainsPermissionsServicein its constructor. No module wiring is needed —PermissionsModuleis@Global(), which is also howMailingGroupsServicealready injects it withoutCommunicationsModuleimporting anything. - Ordering: the check runs before any group resolution, so an unauthorized caller never learns whether the id exists — the 404 is identical in body and timing-shape to a genuinely absent id.
Swagger considerations¶
- The five mailing-group route JSDoc blocks (FE-facing copy) need their permission sentences re-worded: they currently imply the send capability governs groups. Copy stays contract-only — no mention of
ScopeGuard, compiled permissions or the service check. POST /communicationsswagger gainsMAILING_GROUP_NOT_FOUNDas a documented 404 example if it is not already listed (it is the same code the groups CRUD answers).ApiMailingGroupsControllerresponse/error examples are otherwise unchanged; the 403 shape is the standardINSUFFICIENT_SCOPEenvelope already emitted across the app.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
1 new row under SCOPES.communications: { key: 'groups', label: 'Mailing Groups', description: 'Mailing-group catalog: browse and use groups (read); create and manage your own private groups (write)', sortOrder: 1 }. Existing management row shifts to sortOrder: 2; its label/description are unchanged. |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none — gate-only scope, zero field mappings (same as communications.management, students.curriculum_selection, curricula.selection_window). |
| Role grants (roles.ts) | Add 'communications.mailing_groups' to six exported WRITE sets: TEACHER_WRITE_SCOPE_KEYS, PRINCIPAL_WRITE_SCOPE_KEYS, HR_WRITE_SCOPE_KEYS, SECRETARY_WRITE_SCOPE_KEYS, DEPT_HEAD_WRITE_SCOPE_KEYS, CURRICULUM_COORDINATOR_WRITE_SCOPE_KEYS. admin gets WRITE from the all-scopes rule (no edit). referent and student are inclusion-list presets — not listing the key is the whole withholding mechanism (no edit, and deliberately not an entry in any *_EXCLUDED_SCOPE_KEYS, which those presets do not consult). communications.management stays in all five management-preset exclusion sets, unchanged. |
*_SCOPES runtime constant |
required: add mailing_groups: [] as const to COMMUNICATION_SCOPES in src/common/constants/scope-fields.ts (currently { management: [] as const }). This is not optional bookkeeping — rbac-catalogue.drift.spec.ts ("every seed SCOPES entry has a matching ENTITY_SCOPE_REGISTRY entry") iterates the seed catalogue and asserts the runtime registry knows each key, so a seed-only addition fails the guard. ENTITY_SCOPE_REGISTRY.communications picks the key up automatically via Object.keys(COMMUNICATION_SCOPES). No SCOPES_WITHOUT_NATIVE_FIELDS entry is needed — the empty-fields branch already exempts gate-only scopes from FIELD_MAPPINGS. This is step 2 of the documented gate-only recipe in ch04 "Gate-only scopes". |
Notes:
- Without the six explicit WRITE entries the exclusion-list presets would land on READ, i.e. browse-only groups. WRITE preserves today's behavior (every sender may own private groups), so this is a no-regression grant, not a widening.
- PRINCIPAL_WRITE_SCOPE_KEYS currently carries a comment declaring attendance.register "the one place the principal is not read-only" — that comment must be updated in the same edit, not silently invalidated.
- expected-preset-grants.ts derives every preset's expectation from these same exported constants, so the code-vs-DB drift guard (test/rbac-grants.db-sync.e2e-spec.ts) picks the delta up with no edit to the mirror. That is the intended property: a seed-only change must not need a hand-written expectation.
- RBAC drift-check habit (prefer-admit default) is deliberately overridden here for referent/student, same as communications.management: this scope authorizes an internal-organisation surface, so an ALL_READ admission would be misleading in /permissions and would leave the compose-path hole open.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
One RBAC entity per domain module surface (e.g. homerooms, subject_groups each own their entity) |
Keeping mailing groups inside the communications entity as a second scope, rather than promoting them to a mailing_groups entity of their own |
A separate entity would leave communications holding an action and zero scopes — a shape no existing entity has, and one the roles catalogue, entity-group rendering and ScopeGuard's "any scope of the entity" fallback would all need re-verifying. The split the user asked for is achieved fully by the scope; the entity split buys only a separate card in the roles editor. |
The roles editor shows one "Communications" card holding both the sending action and the groups scope, instead of two cards. Revisit if the module grows a third distinct surface. |
Gates are additive — a route usually accumulates @RequireScopes + @RequireAction |
Replacing @RequireAction(COMMUNICATIONS,'send') with the scope on the five group routes rather than stacking both |
Stacking would make groups-WRITE unexercisable without an unrelated grant, i.e. the two keys would not actually be independent — which is the entire point of the split. | A role granted communications.mailing_groups but not communications.send could curate groups it cannot send to. Harmless (a saved selection with no send capability), and not reachable in any seeded preset. |
| Authorization failures on a route answer 403 | The compose path answers 404 (MAILING_GROUP_NOT_FOUND) for a caller lacking communications.mailing_groups READ, not 403 |
The module's standing privacy rule: an unreachable group is indistinguishable from an absent one (already how a foreign private group answers, admin included). A 403 here would confirm to a parent that a given shared-group id exists. | Slightly "wrong" status code for what is technically an authorization failure. Consistency with the existing hidden-404 behavior wins; the FE already handles this code on this field. |
| Permission checks live in guards/decorators | A second compiled-permission check inside CommunicationsService.create |
The gate depends on the request body (were any group ids supplied?), which a decorator cannot see — same constraint that put assertManagementWrite in the service (attendance-access precedent). |
One more service-layer permission call site. Mitigated by extracting the shared canReadMailingGroups helper next to the existing one, so both group-related gates live in the mailing-groups sub-module. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "a referent can only send communications to: admin, teacher involved in classes/courses where there's one of the children, dept head and curriculum coordinators" | The list omits principal, HR and secretary, who are reachable today (the classifier admits every non-profile-coupled role that is tenant-wide). Dropping them is a live mid-year regression: the secretariat is normally a parent's first contact for administrative matters, and nothing replaces it. | Keep the office reachable — leave the REFERENT tier exactly as implemented: teachers of a linked child (four-leg dated set) ∪ admin ∪ principal / HR / secretary ∪ dept_head + curriculum_coordinator whose parameter covers a linked child's current department/curriculum. | Resolved — user chose "keep the office reachable" in chat 2026-08-11. |
| (implied by the above) the reachable set should be a closed, enumerated list | The classifier's rule is structural (role is not profile-coupled AND tenant-wide OR parameter covers a child), so a tenant-created custom management role with no parameter dimension is admitted automatically and is not in the product list. |
Option offered: tighten to !profileCoupled AND (isPreset OR (parametric AND covers a child)) using Role.isPreset. |
Resolved — user chose "leave as-is, no classifier change": a school that creates a tenant-wide management role accepts that parents can email its holders, same as principal/HR/secretary. recipient-policy.ts and recipient-policy.queries.ts are not touched by this iteration. |
| "A referent should not be granted mailing groups scopes nor actions like other roles" | Nothing — this is the change. But taken literally as "remove the grant" it would be incomplete: referents hold no mailing-group grant today either (there is no such key), and the capability leaks purely through communications.send. |
Create the key first (communications.mailing_groups), move the five routes onto it, grant it to the six staff-side presets, and close the compose-path hole so the withholding is real and not just route-deep. |
Resolved — this spec. |
9. Deferrals¶
- Renaming
communications.management→shared_mailing_groups— it reads poorly next tomailing_groups(both are about groups; only one says so). Deferred because a live scope-key rename churns/permissionsand the FE's permission keys for zero functional gain. Follow-up: fold into any future communications iteration that already breaks the permission shape. (The 2026-08-11groups→mailing_groupsrename was forced by the interceptor collision, not by this readability argument, and was cheap only because the FE contract was a day old.) - Promoting mailing groups to their own RBAC entity — §7. Follow-up: revisit if the module gains a third distinct surface, at which point the
communications-with-zero-scopes question has to be answered anyway. - Tightening the referent tier against tenant-wide custom management roles — §8 row 2, explicitly declined for now. Follow-up: revisit if a tenant reports parents reaching a custom role they did not intend to expose.
- Per-role control over receiving — reachability as a recipient is structural (who you are), not grant-driven (what you hold). Out of scope and not requested. Follow-up: none planned.
- Student preset — still holds no communications grant of any kind; it receives via the auth-only inbox. Unchanged, no follow-up.
10. Open questions¶
None — both product decisions (office reachability; custom non-parametric roles) were resolved in chat on 2026-08-11 and are recorded in §8.
11. Verification plan¶
- Unit specs
src/communications/communications.service.spec.ts— new cases: (a) caller withoutcommunications.mailing_groupsREAD supplyinggroupIds→MAILING_GROUP_NOT_FOUND404, nothing persisted, no audit row, no file rows; (b) same caller supplyingccGroupIdsonly → same 404; (c) same caller supplying no group ids → succeeds, and the permission service is not consulted (asserts the zero-cost path); (d) caller with the scope → existing behavior unchanged.src/communications/mailing-groups/mailing-groups.service.spec.ts— unchanged assertions must stay green (the service is untouched); add a case pinning thatcanReadMailingGroupsreads the narrowedctx.roles, mirroring the existingassertManagementWriteview-narrowing test.- E2E specs —
test/communications.e2e-spec.ts: - referent session → 403 on
GET /mailing-groups,POST /mailing-groups,GET /mailing-groups/:id,PATCH /mailing-groups/:id,DELETE /mailing-groups/:id; - referent session →
POST /communicationswith the id of an existing shared group → 404MAILING_GROUP_NOT_FOUND; - referent session →
POST /communicationswith inlinerecipients[]only → 202 (regression fence: the split must not break parent→teacher email); - teacher session → private-group create/patch/delete still 2xx; shared-group create still 403;
- admin session → shared-group CRUD still 2xx;
test/rbac-grants.db-sync.e2e-spec.ts→ green with no edit toexpected-preset-grants.ts(proves the mirror derives from the seed constants); it must showcommunications.mailing_groupsWRITE on admin + the six staff-side presets and absent from referent and student.- E2E discipline per
feedback_e2e_isolation_patterns.md(per-worker DB clones, re-cloned per run) — no hand-rolled tenant fixtures. - Manual verification: reseed a dev tenant, log in as a referent, confirm the mailing-groups section is gone from
/permissionsand that composing to a child's teacher still sends; log in as a teacher and confirm private-group CRUD still works. - Gates are the user's to run — build, unit, e2e and lint are not run by the implementer.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-08-11
- Chat reference: approved in chat 2026-08-11, after resolving the two product questions in §8 (office stays reachable; no classifier change for custom non-parametric roles) and choosing the new-scope split shape over a new action or a new
mailing_groupsentity.