Skip to content

RBAC and comms_author role implementation ​

Audience: Platform team (internal only)

ADO tracking: Platform Epic AB#3074; RBAC AB#3073; Communications authoring AB#3089.

Source of truth: ADRs 0006, 0012, 0023. Where anything in this document conflicts with those ADRs, the ADR wins.


Overview ​

Heritage Community Hub enforces a two-plane role-based access control (RBAC) model (ADR 0006).

  • Plane 1 — infrastructure access: Azure RBAC and Microsoft Entra govern operators, CI/CD service principals, and managed identities. No community member ever holds an Entra role.
  • Plane 2 — application authorization: six canonical roles enforced entirely server-side in the Node.js API. The client sends no trusted role claim.

This tutorial covers Plane 2 only: the six roles, how to enforce them in the API, the comms_author role and its intentionally narrow scope, the announcement approval workflow that role participates in, and family admin capabilities.

The comms_author role is the youngest of the six canonical roles, added by ADR 0023 to close a specific gap: the need to delegate announcement authoring to a volunteer or coordinator without granting that person the authority to publish unilaterally.


Role enforcement in the API ​

Role enum definition (TypeScript) ​

Define the six Plane-2 roles as a TypeScript const object and derive a union type from it. All role checks in the codebase use this single source of truth.

typescript
// UNVALIDATED — verify with: npm run build
// packages/api-client/src/types/roles.ts

export const AppRole = {
  admin:           'admin',
  ministry_leader: 'ministry_leader',
  group_leader:    'group_leader',
  member:          'member',
  visitor:         'visitor',
  comms_author:    'comms_author',
} as const;

export type AppRole = (typeof AppRole)[keyof typeof AppRole];

The role value is read from Users.role (or Users.RoleSlug via the UserRoles join) in the database on every authenticated request. The JWT from Clerk carries only the provider sub claim. The server looks up the user row using sub, reads the role from the database, and attaches it to the request context. The client never asserts a role.

requireRole() middleware ​

requireRole() is an Express middleware factory. It verifies the authenticated user holds the expected role, terminating the request with 403 Forbidden if the check fails.

typescript
// UNVALIDATED — verify with: npm run build
// apps/api/src/middleware/requireRole.ts

import { Request, Response, NextFunction } from 'express';
import { AppRole } from '@heritage/api-client';

/**
 * Require the authenticated user to hold exactly the specified role.
 * Attach this after the Clerk session middleware so req.userId is populated.
 */
export function requireRole(role: AppRole) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const user = await db.users.findByExternalId(req.userId);
    if (!user || user.role !== role) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    req.appUser = user;
    next();
  };
}

Apply it to a route:

typescript
// UNVALIDATED
router.post(
  '/members/:id/approve',
  requireRole(AppRole.admin),
  memberController.approveJoinRequest,
);

requireAnyRole() for routes accessible by multiple roles ​

Some routes are reachable by more than one role. requireAnyRole() accepts a list and passes if the user holds any one of them.

typescript
// UNVALIDATED
// apps/api/src/middleware/requireRole.ts

export function requireAnyRole(...roles: AppRole[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const user = await db.users.findByExternalId(req.userId);
    if (!user || !roles.includes(user.role as AppRole)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    req.appUser = user;
    next();
  };
}

Example — the announcement approval endpoint is open to both ministry_leader and admin:

typescript
// UNVALIDATED
router.patch(
  '/announcements/:id/approve',
  requireAnyRole(AppRole.ministry_leader, AppRole.admin),
  announcementController.approve,
);

The announcement draft creation endpoint is open to comms_author plus the two approver roles (who may also draft directly):

typescript
// UNVALIDATED
router.post(
  '/announcements',
  requireAnyRole(AppRole.comms_author, AppRole.ministry_leader, AppRole.admin),
  announcementController.create,
);

The comms_author role ​

What comms_author can do ​

A user holding the comms_author role may:

  • Create Announcements rows with status = 'draft'.
  • Edit their own drafts (any field: title, body, audience scope, priority, scheduled_at).
  • Submit a draft for approval, transitioning status from draft to pending_approval.
  • Revise and resubmit a rejected draft (transition: rejected → draft → pending_approval).

The audience scope a comms_author may target is bounded by their assignment in the UserCommunicationsScope join table. An admin sets one row per permitted audience per author:

Scope valueMay target
communityAll members (audience_role = NULL, audience_group_id = NULL)
ministry:<ministry_id>Members scoped to that ministry role
group:<group_id>Members in that specific group

The API rejects a draft creation or edit that targets an audience outside the author's assigned scope.

What comms_author cannot do &ZeroWidthSpace;

A comms_author user:

  • Cannot approve, reject, or publish any announcement — including one they authored.
  • Cannot view or act on the approver queue (GET /announcements?status=pending_approval returns 403 for this role).
  • Cannot access any other feature area. The comms_author role carries no permissions outside the Announcements feature.
  • Cannot assign or modify audience scope for other comms_author users (that is an admin-only operation).
  • Cannot create family-group relationships, approve membership requests, or access any admin portal surface other than their own drafts.

Why comms_author cannot self-approve &ZeroWidthSpace;

ADR 0023 exists specifically to close the self-approval gap that would otherwise exist in the five-role model. The problem is straightforward: any existing role that can write announcements (ministry_leader, group_leader) also carries implicit approval authority. There is no way within the original five roles to grant someone write access for community-wide announcements without simultaneously granting them the right to publish without review.

The ministry's actual workflow is two-actor: an author is tasked to draft; a minister reviews and approves before anything reaches members. comms_author models the first actor with no overlap into the second.

The server enforces this at the approval endpoint: the transition pending_approval → approved is rejected if the caller's user_id matches the announcement's author_user_id. This check applies regardless of which role the approver holds — it is not possible to approve your own draft, even if an admin were ever also assigned the comms_author scope.

Every state transition is written to the AuditLog table per ADR 0005 with distinct author_user_id and approved_by_id fields on the Announcements row.


Announcement approval workflow implementation &ZeroWidthSpace;

State machine &ZeroWidthSpace;

The announcement lifecycle is defined across ADR 0012 (data model) and ADR 0023 (workflow). The valid states and transitions are:

text
draft

  │  [comms_author | ministry_leader | admin submits]

pending_approval

  ├── [ministry_leader | admin approves]
  │     ▼
  │   approved ──[scheduled_at reached, or immediate]──► published
  │                                                           │
  │                                                           ▼
  │                                               notifications transport fans out
  │                                               AnnouncementReceipts written

  └── [ministry_leader | admin rejects]

      rejected

        │  [author revises]

      draft  (cycle repeats)

published ──[expires_at reached]──► expired  (terminal)

No client drives state transitions. The API evaluates the caller's role, validates the source state, and applies the transition. The client receives only the resulting state in the response body.

API endpoints for workflow transitions &ZeroWidthSpace;

Method and pathRole requiredTransition
POST /announcementscomms_author, ministry_leader, adminCreates row with status = 'draft'
PATCH /announcements/:idAuthor of draft onlyEdits a draft before submission
POST /announcements/:id/submitAuthor of draft onlydraft → pending_approval
PATCH /announcements/:id/approveministry_leader, adminpending_approval → approved
PATCH /announcements/:id/rejectministry_leader, adminpending_approval → rejected
PATCH /announcements/:id/withdrawadminpublished → withdrawn (removes from feed)

"Author of draft only" means the authenticated user's user_id must match Announcements.author_user_id and the announcement must be in draft or rejected status.

Who can trigger each transition &ZeroWidthSpace;

TransitionAllowed callersServer-side check
Create draftcomms_author, ministry_leader, adminRole check; audience scope check for comms_author
Edit draftThe author (any role)user_id = author_user_id; status must be draft or rejected
Submit for approvalThe authoruser_id = author_user_id; status must be draft
Approveministry_leader, adminRole check; user_id ≠ author_user_id (self-approve block)
Rejectministry_leader, adminRole check
WithdrawadminRole check; status must be published

The self-approve block is applied on the approve endpoint regardless of role. Even a ministry_leader who drafted an announcement directly cannot approve their own draft.

Notification on approval &ZeroWidthSpace;

When the approve endpoint transitions an announcement to approved, the API calls the platform notifications transport (ADR 0013) to fan out to all members in the audience scope. Delivery occurs on all active channels simultaneously: Short Message Service (SMS) via Twilio, mobile push via Expo, email via SendGrid, and in-app via the Notifications table. The comms_author who submitted the draft does not control or observe the fan-out; the transport is called by the server after the approver's action is persisted.

The transport also sends a system notification to approvers when a new submission enters pending_approval — this is a transport-level system event, not an announcement, so it does not go through the approval workflow itself.

For channel adapter configuration, per-member channel preferences, fan-out behavior, and retry handling, see architecture/notification-transport.md and ADR 0013.


Family admin capabilities &ZeroWidthSpace;

The family_admin designation in conversation refers to the family_admin capability available to the member role, not a separate RBAC role. A member who is the primary in a FamilyGroups row has authority to manage their own household in the following ways:

Add a spouse. The primary member submits a spouse-add request through the API (WorkflowType = 'spouse-add'). The request enters the ApprovalWorkflow table with status = 'Pending' and is routed to a minister for review. On approval, a new Users row is created with credential_type = 'social' and FamilyGroupID set to the family's group. The spouse authenticates via Clerk (Apple/Google) independently.

Add a child sub-account. The primary member submits a child-add request (WorkflowType = 'child-add'). Child-add is auto-approved for members who are already vetted and active — the minister-approval step that would gate a new adult does not apply. A Users row is created with credential_type = 'parent-managed', AccountType = 'Child', ParentUserID pointing to the primary member, Email = NULL, and the parent-assigned Username and PasswordHash (Argon2id — ADR 0003; never plaintext, and bcrypt is explicitly not used).

Set or reset a child's PIN. The parent calls the API to update Users.PasswordHash for a child row they own (ParentUserID = req.appUser.UserID). No child can update their own credential. No social-login endpoint is accessible for child accounts.

Manage child access. The parent's approval settings determine which sections of the platform a child account can see. This is an application-layer access control layer on top of the member role scope — children do not hold a distinct role; they inherit a bounded view of member access as defined by their parent's configuration.

Children have no email address and no phone number. They receive in-app notifications only, routed through the parent's session. The parent (adult) is the notification contact of record for their children.


DocumentWhat it covers
ADR 0006 — Two-plane RBACCanonical six-role Plane-2 model; infrastructure vs application planes; server-side enforcement requirement
ADR 0007 — Account and family-group identityUsers table discriminator; parent-managed child accounts; family-group structure; approval workflow table
ADR 0012 — Announcements (one-way broadcast)Announcements table schema; status values; AnnouncementReceipts; audience scoping
ADR 0023 — Communications authoring and approvalcomms_author role definition; two-actor separation; self-approve prohibition; UserCommunicationsScope table; audit requirements
ADR 0013 — Notification transportChannel adapters (SMS, push, email, in-app); fan-out; per-member preferences; provider decisions
design/auth-rbac-design.mdConceptual walkthrough of both auth planes; Clerk JWT mapping; child auth path
architecture/notification-transport.mdChannel adapter configuration; fan-out behavior; retry; member preference evaluation
database/schema.sqlUsers, Roles, UserRoles, ApprovalWorkflow, Announcements table definitions (design document — Phase 2 migration pending)

Heritage Community Hub — Internal. Access restricted via Cloudflare Access + Entra ID.