Skip to content

Communications ⇄ mailing-groups RBAC split

Amendment 2026-08-11 (post-implementation): the scope key is communications.mailing_groups, not communications.groups. As designed and first shipped, the key was groups. That broke GET /communications/:id with a 500 on the first read: the route is @AggregateResponse(), CommunicationDetailDto has carried a top-level groups key since the module shipped, and FieldFilterInterceptor.assertAggregateShape treats any top-level response key matching a registered scope name of the entity as a permission regression — throwing in dev/staging, logging in prod. communications being in FLAT_DTO_ENTITIES does 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 to mailing_groups; the reasoning, grants and behavior are unchanged. Two consequences worth carrying forward: (a) the catalogue seed only upserts PermissionScope, so databases seeded with the old key keep a stale grantable row — cleaned by tools/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.send is currently the single gate over the whole module surface: compose, recipient picker, own archive, templates and all five /mailing-groups routes. 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 in POST /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.prisma is 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 PermissionScope row and the six role grants only exist after the seed runs; until then the five /mailing-groups routes 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 like assertManagementWrite, 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 by CommunicationsService, which gains PermissionsService in its constructor. No module wiring is needed — PermissionsModule is @Global(), which is also how MailingGroupsService already injects it without CommunicationsModule importing 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 /communications swagger gains MAILING_GROUP_NOT_FOUND as a documented 404 example if it is not already listed (it is the same code the groups CRUD answers).
  • ApiMailingGroupsController response/error examples are otherwise unchanged; the 403 shape is the standard INSUFFICIENT_SCOPE envelope 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.managementshared_mailing_groups — it reads poorly next to mailing_groups (both are about groups; only one says so). Deferred because a live scope-key rename churns /permissions and 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-11 groupsmailing_groups rename 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 without communications.mailing_groups READ supplying groupIdsMAILING_GROUP_NOT_FOUND 404, nothing persisted, no audit row, no file rows; (b) same caller supplying ccGroupIds only → 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 that canReadMailingGroups reads the narrowed ctx.roles, mirroring the existing assertManagementWrite view-narrowing test.
  • E2E specstest/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 /communications with the id of an existing shared group → 404 MAILING_GROUP_NOT_FOUND;
  • referent session → POST /communications with inline recipients[] 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 to expected-preset-grants.ts (proves the mirror derives from the seed constants); it must show communications.mailing_groups WRITE 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 /permissions and 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_groups entity.