Skip to content

Auth and RBAC design ​

Audience: Platform team (internal only)

Status: Design complete — covers both Phase 1 (Platform) and Phase 4 (mobile) surfaces. Updated 2026-06-23 for ADR 0037 role expansion and ADR 0038 multi-role authorization.

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

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


Overview ​

Heritage Community Hub uses a two-plane security model (role-based access control, or RBAC — 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: a set of canonical roles enforced entirely server-side in the Node.js API. The client sends no trusted role claims. Roles are either ordinal (hierarchical privilege levels) or non-ordinal (feature-scoped, treated as member-level for hierarchy checks).

Authentication is split by account type. Adults authenticate through Clerk using Apple Sign-In or Google Sign-In. Children have no social-login account; they authenticate with a parent-assigned username and PIN stored in the platform's own Users table.

Every account — adult and child — requires minister approval before gaining active status. There is no self-registration path that grants community access.


Authentication ​

Adult authentication ​

Adults authenticate via Clerk, which acts as the OpenID Connect (OIDC) provider for the platform's social-login flow. Clerk handles the Apple/Google token exchange, session management, and JSON Web Token (JWT) issuance. The platform never sees or stores an Apple or Google refresh token.

Flow:

  1. The user initiates sign-in on the web app (apps/web) or the mobile app (apps/mobile).
  2. Clerk presents the Apple Sign-In or Google Sign-In prompt. On iOS, this uses the native expo-apple-authentication path — no browser redirect. The Android Google Sign-In native path requires verification before Phase 4 (see ADR 0003 risk log).
  3. Apple or Google returns an identity token to Clerk. Clerk validates it and issues a Clerk session JWT containing the provider sub claim.
  4. On first sign-in, the API creates a Users row: credential_type = 'social', status = 'pending_approval', role = 'visitor'. The external_user_id column stores the Clerk sub.

Spouse invite linking. When a spouse-add workflow is initiated, the platform pre-creates a Users row for the spouse (status = PENDING_APPROVAL, accountType = SPOUSE, externalUserId = null) and sends a Clerk invitation email. When the spouse completes Clerk sign-in, upsertByExternalUserId first checks for an existing row by Clerk subject — if not found, it checks for a PENDING_APPROVAL / SPOUSE row matching the sign-in email with no externalUserId. If found, it updates that row's externalUserId instead of creating a new row. The spouse remains PENDING_APPROVAL until minister approval.

  1. On every subsequent API call, the client sends the Clerk session JWT. The API verifies the JWT signature (using Clerk's JWKS endpoint), extracts the sub, looks up the corresponding Users row, and reads the application role and status from the database. The JWT sub is the only claim the server trusts from the client; the role is never read from the JWT.
  2. If Users.status is not 'active', the API returns 403 regardless of the role value.

Why Clerk over alternatives: Clerk's Expo SDK ships a native Sign in with Apple implementation that satisfies App Store Guideline 4.8 without requiring a browser-based OAuth redirect — which is the architecturally correct path for a React Native and Expo build (see ADR 0003 for the full alternatives comparison).

Closed community gate: Clerk is used only for token validation and session issuance. Clerk does not gate community membership; the Users.status check in the API does. An account that passes Clerk authentication but has not been minister-approved receives a 403 on every protected endpoint.

Child authentication ​

Children are parent-managed sub-accounts. They have no email address, no Apple or Google account, and no Clerk record. Clerk never touches child accounts.

Credential model:

FieldValue
credential_type'parent-managed'
usernameParent-assigned display name used as the login identifier
password_hashArgon2id hash of the PIN or password set by the parent
parent_user_idForeign key (FK) to the managing parent's Users row
emailNULL — children have no email address
external_user_idNULL — no Clerk record

Sign-in flow:

  1. The child enters their username and PIN on the sign-in screen.
  2. The request goes to a dedicated API endpoint (POST /auth/parent-managed/signin).
  3. The API looks up the Users row by username (filtered unique index on non-null values), reads password_hash, and verifies the PIN using Argon2id. No Clerk call is made.
  4. On success, the API issues a platform-signed JWT for the child's session.
  5. Subsequent requests carry this platform JWT. The API resolves the child's user_id, checks status = 'active', and enforces the parent's approval scope on every request.

A child account can only be created by an already-approved parent. Children inherit a restricted scope of the member role; the application layer enforces per-section access controls on top of the role check (see ADR 0006, risk: "child scope creep").


Authorization — role-based access control (RBAC) ​

Roles table ​

Plane 2 defines a set of application roles stored in the Roles table and assigned to users via UserRoles. Roles are either ordinal (carrying a numeric permission level used in hierarchical middleware checks) or non-ordinal (feature-scoped; treated as level 2 / member for any hierarchical check).

Hierarchy (ordinal roles only, highest to lowest):

infra_admin (7) > ministry_leader (6) > admin (5) > group_leader (3) > member (2) > visitor (1)

Level 4 is reserved for future use. Non-ordinal roles are not placed in this hierarchy.

Ordinal roles ​

RoleSlugLevelCapabilities
Infrastructure & Platform Administratorinfra_admin7God-mode access. Passes all hierarchical middleware checks. Full access to platform backend, infrastructure management, and all application capabilities. Intended for DevOps/platform operators only — not granted to community members.
Ministry leaderministry_leader6Manages their ministry area; announcement approval authority within their ministry; broader community authority than admin.
Community leader / adminadmin5Full app-level access; member approval; role assignment; all configuration; announcement approval authority.
Small group leadergroup_leader3Manages their assigned small group; no announcement approval authority.
Membermember2Standard community access; family management (add spouse and children, with approval); read access to all community content scoped to their role.
Visitor / prospectivevisitor1Approval-gated; read-only access to the minimum set of features needed to complete the approval process.

Non-ordinal (feature-scoped) roles ​

Non-ordinal roles carry no hierarchical privilege. For all requireMinRole() middleware checks they are evaluated as level 2 (member). Feature-specific endpoints use requireAnyRole() to grant access based on the specific role slug.

RoleSlugCapabilities
Sermon & Music Managermedia_stewardManages worship media uploads, music library, and sermon content. Member-level access to all general community features.
Communications authorcomms_authorDrafts and submits Announcements for assigned audiences only. Cannot approve, reject, or publish anything, including their own drafts.
Homeschool Program Administratorhomeschool_adminAdministrative access to the Homeschool portal. Capabilities TBD pending Homeschool portal epic.
Homeschool Teacherhomeschool_teacherTeacher access to the Homeschool portal. Capabilities TBD pending Homeschool portal epic.
Homeschool Advisor / Counselorhomeschool_advisorAdvisor/counselor access to the Homeschool portal. Capabilities TBD pending Homeschool portal epic.
High School Program Studenthighschool_studentStudent access to the once-weekly high school classes (science, writing, history, theology). Capabilities TBD pending Homeschool portal epic.
Homeschool Studenthomeschool_studentBase student access to the Homeschool portal. Capabilities TBD pending Homeschool portal epic.

Children are a sub-account type (ADR 0007), not a separate role. They operate within a parent-bounded scope of the member role.

Schema note: The database/schema.sql seed data currently inserts five ordinal roles (admin, ministry_leader, group_leader, member, visitor). The following roles must be added in migrations aligned with their respective portal epics: infra_admin (platform — ADR 0037), comms_author (Phase 2 — ADR 0023), media_steward (Sermons/Music epic — ADR 0037), and the four homeschool roles (Homeschool portal epic — ADR 0037). The ministry_leader level must be updated from 4 to 6 in the same platform migration.

Role storage — ADR 0038 update: As of ADR 0038, requireAuth reads role assignments from UserRole rows for the requesting user, not from the User.role scalar column. AuthContext.roles is AppRole[] (an array). The single User.role column is maintained for backward-compatible queries during the migration period but is not used by the authorization middleware. Every existing user must have a corresponding UserRole row inserted before the updated middleware is deployed. See ADR 0038 for the full migration requirements and updated endpoint contract.

infra_admin role ​

The infra_admin role is the highest-level ordinal role (level 7). It is intended exclusively for DevOps and platform operators — individuals who maintain the Azure infrastructure, CI/CD pipelines, and platform backend. It is not granted to community members.

infra_admin passes every hierarchical middleware check because level 7 exceeds all other levels. There is no endpoint or resource in the application layer that is inaccessible to an infra_admin user. The distinction between infrastructure administration and community administration is enforced at the policy and UI level (separate portal access, separate assignment process), not at the API level.

Granting infra_admin: This role must only be assigned by a human operator with direct database access or through a protected, separately audited admin endpoint. It must never appear in the standard community member role-assignment UI.

Audit requirement: Every assignment or removal of infra_admin must produce an AuditLog row with sufficient detail for a security review. See ADR 0005 (Observability model) for audit log schema requirements.

Feature-scoped (non-ordinal) roles ​

Non-ordinal roles follow the same middleware pipeline as ordinal roles (JWT verification → status check → role read) but resolve to level 2 for any requireMinRole() check. This means a media_steward user can reach all member-accessible endpoints without additional configuration. Feature-specific endpoints — such as media upload, sermon management, or homeschool course management — additionally call requireAnyRole(['media_steward', 'admin', 'infra_admin']) (or the equivalent set for that feature) to admit only the roles that have a legitimate reason to access the resource.

When to use requireAnyRole() vs requireMinRole():

  • Use requireMinRole('member') (or any ordinal level) for endpoints that are access-controlled by community standing alone.
  • Use requireAnyRole([...]) for endpoints where access is determined by a specific functional assignment (e.g., managing sermons, teaching a class) rather than by standing in the community hierarchy.
  • For homeschool portal endpoints, the specific capability model is TBD and will be specified during the Homeschool portal epic. Roles (homeschool_admin, homeschool_teacher, homeschool_advisor, highschool_student, homeschool_student) are created now as a foundation so that user assignments can be made before the portal is built.

comms_author role ​

The comms_author role exists to support a real delegation pattern in the ministry: a minister tasks a volunteer or coordinator to draft announcements for the community, a specific ministry, or a small group — without granting that person the authority to publish.

What comms_author can do:

  • Create Announcements drafts for any audience within their assigned scope.
  • Edit their own drafts.
  • Submit a draft to the approval queue (transitions Announcements.status from draft to pending_approval).
  • Revise and resubmit a rejected draft.

What comms_author cannot do:

  • Approve, reject, or publish any announcement — including their own.
  • Create announcements targeting an audience broader than their assigned scope.
  • Access any feature outside the Announcements authoring flow.

Approval authority is reserved for ministry_leader, admin, and infra_admin roles, enforced by the API. The endpoint that transitions pending_approval → approved rejects the request if the caller's user_id matches the announcement's author_user_id — the self-approval check is enforced server-side, not by convention.

Each comms_author user has one or more rows in the UserCommunicationsScope join table that record which audiences they are permitted to write for (community, ministry:<id>, or group:<id>). An admin configures these assignments through the Admin and Ministry portal (ADR 0021). The API rejects any draft creation that targets an audience outside the author's assigned scope.

Relationship to ADR 0023: This role was introduced by ADR 0023 (2026-06-18) to close the self-approval gap that existed when all writing roles also carried implicit approval authority. ADR 0006 was updated on the same date to add comms_author to the canonical role table. See ADR 0023 for the full decision record, alternatives considered, and risk log.

API middleware enforcement &ZeroWidthSpace;

Every request to a protected endpoint follows the same sequence:

text
  Client request (Authorization: Bearer <JWT>)


  1. JWT verification
     Clerk JWKS endpoint (social/adult) or platform public key (child)
     → invalid or expired JWT → 401


  2. Subject lookup
     SELECT * FROM Users WHERE external_user_id = <sub>   -- adult
     SELECT * FROM Users WHERE user_id = <jwt.sub>        -- child (platform JWT)
     → no matching row → 401


  3. Status check
     Users.status must equal 'active'
     → pending_approval | suspended | deactivated → 403


  4. Role read (ADR 0038: UserRole is source of truth)
     SELECT role_slug FROM UserRole JOIN Roles ...
     WHERE user_id = <Users.user_id> AND is_active = true
     → roles[] attached to request context as AuthContext.roles (AppRole[])
     → not re-queried per endpoint


  5. Endpoint authorization
     Each endpoint declares the minimum role(s) required.
     comms_author audience scope checked for announcement endpoints.
     child sub-account scope checked for member-scoped endpoints.
     → insufficient role → 403


  6. Handler executes

The role is resolved once per request from the database and placed in the request context. Individual handlers read from context; they do not re-query the database for the role. Caching the role in a short-lived server-side session is acceptable provided cache invalidation occurs immediately on any role or status change.


Member approval workflow &ZeroWidthSpace;

All new adult accounts — whether they sign in with Apple or Google — start with status = 'pending_approval' and role = 'visitor'. No path exists to self-elevate from visitor to member.

Approval sequence:

  1. A prospective member signs in with Apple or Google for the first time. The API creates the Users row (status = 'pending_approval') and an ApprovalWorkflow row (workflow_type = 'member-join', status = 'Pending').
  2. The platform notifies ministers via the notification transport (ADR 0013) that a new member request is in the queue.
  3. A minister or admin reviews the request in the Admin and Ministry portal. The ApprovalWorkflow.assigned_to field routes the request to a specific minister for dashboard visibility.
  4. On approval, the API atomically:
    • Sets Users.status = 'active'
    • Sets Users.role = 'member' (or a higher role if the minister assigns one) — kept for backward compat; see ADR 0038
    • Inserts a UserRole row for the assigned role (isActive = true) — this is the authoritative assignment (ADR 0038)
    • Sets ApprovalWorkflow.status = 'Approved' and records minister_id, minister_approval_date, and minister_comments
    • Creates the FamilyGroups row and links it to the new member (ADR 0007)
    • Writes an AuditLog row for the approval event
  5. The member receives notification that their account is active.

On rejection, ApprovalWorkflow.status = 'Rejected' and Users.status remains 'pending_approval'. The API continues to return 403 to the prospective member.

Spouse and child additions follow the same ApprovalWorkflow table with workflow_type = 'spouse-add' or 'child-add'. Child additions are typically auto-approved because the parent is already a vetted community member, but the workflow row is still created for the audit trail.

Content approval uses workflow_type = 'content-publish' and is the mechanism by which comms_author submissions are reviewed (see above).


Security notes &ZeroWidthSpace;

COPPA boundary &ZeroWidthSpace;

The Children's Online Privacy Protection Act (COPPA) applies to online services that collect personal information from children under 13. The platform draws a hard boundary: Clerk never touches child accounts. Children have no email address, no social-login credential, and no record in any third-party system. All child data — username, PIN hash, and parent FK — lives exclusively in the platform's own database, which is hosted on Azure Database for PostgreSQL within the platform's Azure subscription.

This boundary must be maintained throughout implementation:

  • No child user_id or username may be sent to Clerk, any analytics service, or any third-party SaaS.
  • Profile photos are not collected for children.
  • The Homeschool portal (Phase 7) will require a separate COPPA compliance review before implementation.

Argon2id for parent-managed credentials &ZeroWidthSpace;

The platform uses Argon2id to hash child PINs and passwords. Argon2id is the current Password Hashing Competition winner and is recommended by NIST Special Publication 800-63B for stored password verification. It is memory-hard, which makes GPU-based brute-force attacks computationally expensive.

bcrypt and scrypt are not used. MD5 and SHA-family hashes are prohibited for credential storage.

Minimum recommended parameters (to be tuned to the production instance's available memory):

  • Memory cost: 64 MiB
  • Iterations: 3
  • Parallelism: 4

The PasswordHash column in Users stores only the Argon2id encoded string (algorithm identifier, parameters, salt, and hash concatenated in the standard PHC format). The raw PIN is never stored or logged.

Role escalation protection &ZeroWidthSpace;

Role updates are admin-only API operations. No endpoint allows a member to read or modify their own role. The UserRoles table records assigned_by and assigned_date for every assignment. Any change to UserRoles writes an AuditLog row.

infra_admin assignment is subject to additional controls: it must not be reachable through the standard community member role-assignment UI. See the infra_admin role section above for the full granting and audit requirements.

If the Users or UserRoles table is directly accessible to any non-admin API path (including read-your-own-profile endpoints), that path must be audited to confirm it does not expose role data in a form the client could replay.


ADRTitleRelevance
ADR 0003Authentication: Clerk + Apple/Google social login, no Entra for end usersClerk selection rationale, Expo native sign-in, child credential model, Android risk
ADR 0006Two-plane RBAC with reconciled role modelCanonical six-role table, server-side enforcement rule, social sub → Users row mapping
ADR 0007Account and family-group identityParent-managed child sub-accounts, FamilyGroups as approval unit, one-way messaging
ADR 0012Announcements: one-way broadcastAnnouncements data model, status lifecycle, audience scoping
ADR 0023Communications authoring and approval workflowcomms_author role rationale, self-approval prohibition, UserCommunicationsScope table
ADR 0005Observability modelAudit log requirements — every auth and role event must produce an AuditLog row
ADR 0008Platform compositionAPI-first rule — no authorization logic lives in any client surface
ADR 0037RBAC role expansioninfra_admin (level 7), ministry_leader promotion to level 6, media_steward, and homeschool/highschool roles; non-ordinal role design pattern
ADR 0038Multi-role user authorizationUserRole join table as auth source of truth; AuthContext.roles: AppRole[]; requireRole max-ordinal semantics; deprecated User.role scalar

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