Appearance
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:
- The user initiates sign-in on the web app (
apps/web) or the mobile app (apps/mobile). - Clerk presents the Apple Sign-In or Google Sign-In prompt. On iOS, this uses the native
expo-apple-authenticationpath — no browser redirect. The Android Google Sign-In native path requires verification before Phase 4 (see ADR 0003 risk log). - Apple or Google returns an identity token to Clerk. Clerk validates it and issues a Clerk session JWT containing the provider
subclaim. - On first sign-in, the API creates a
Usersrow:credential_type = 'social',status = 'pending_approval',role = 'visitor'. Theexternal_user_idcolumn stores the Clerksub.
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.
- 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 correspondingUsersrow, and reads the application role and status from the database. The JWTsubis the only claim the server trusts from the client; the role is never read from the JWT. - If
Users.statusis 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:
| Field | Value |
|---|---|
credential_type | 'parent-managed' |
username | Parent-assigned display name used as the login identifier |
password_hash | Argon2id hash of the PIN or password set by the parent |
parent_user_id | Foreign key (FK) to the managing parent's Users row |
email | NULL — children have no email address |
external_user_id | NULL — no Clerk record |
Sign-in flow:
- The child enters their username and PIN on the sign-in screen.
- The request goes to a dedicated API endpoint (
POST /auth/parent-managed/signin). - The API looks up the
Usersrow byusername(filtered unique index on non-null values), readspassword_hash, and verifies the PIN using Argon2id. No Clerk call is made. - On success, the API issues a platform-signed JWT for the child's session.
- Subsequent requests carry this platform JWT. The API resolves the child's
user_id, checksstatus = '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 ​
| Role | Slug | Level | Capabilities |
|---|---|---|---|
| Infrastructure & Platform Administrator | infra_admin | 7 | God-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 leader | ministry_leader | 6 | Manages their ministry area; announcement approval authority within their ministry; broader community authority than admin. |
| Community leader / admin | admin | 5 | Full app-level access; member approval; role assignment; all configuration; announcement approval authority. |
| Small group leader | group_leader | 3 | Manages their assigned small group; no announcement approval authority. |
| Member | member | 2 | Standard community access; family management (add spouse and children, with approval); read access to all community content scoped to their role. |
| Visitor / prospective | visitor | 1 | Approval-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.
| Role | Slug | Capabilities |
|---|---|---|
| Sermon & Music Manager | media_steward | Manages worship media uploads, music library, and sermon content. Member-level access to all general community features. |
| Communications author | comms_author | Drafts and submits Announcements for assigned audiences only. Cannot approve, reject, or publish anything, including their own drafts. |
| Homeschool Program Administrator | homeschool_admin | Administrative access to the Homeschool portal. Capabilities TBD pending Homeschool portal epic. |
| Homeschool Teacher | homeschool_teacher | Teacher access to the Homeschool portal. Capabilities TBD pending Homeschool portal epic. |
| Homeschool Advisor / Counselor | homeschool_advisor | Advisor/counselor access to the Homeschool portal. Capabilities TBD pending Homeschool portal epic. |
| High School Program Student | highschool_student | Student access to the once-weekly high school classes (science, writing, history, theology). Capabilities TBD pending Homeschool portal epic. |
| Homeschool Student | homeschool_student | Base 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
Announcementsdrafts for any audience within their assigned scope. - Edit their own drafts.
- Submit a draft to the approval queue (transitions
Announcements.statusfromdrafttopending_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 ​
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 executesThe 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 ​
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:
- A prospective member signs in with Apple or Google for the first time. The API creates the
Usersrow (status = 'pending_approval') and anApprovalWorkflowrow (workflow_type = 'member-join',status = 'Pending'). - The platform notifies ministers via the notification transport (ADR 0013) that a new member request is in the queue.
- A minister or admin reviews the request in the Admin and Ministry portal. The
ApprovalWorkflow.assigned_tofield routes the request to a specific minister for dashboard visibility. - 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
UserRolerow for the assigned role (isActive = true) — this is the authoritative assignment (ADR 0038) - Sets
ApprovalWorkflow.status = 'Approved'and recordsminister_id,minister_approval_date, andminister_comments - Creates the
FamilyGroupsrow and links it to the new member (ADR 0007) - Writes an
AuditLogrow for the approval event
- Sets
- 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 ​
COPPA boundary ​
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_idor 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 ​
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 ​
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.
Related ADRs ​
| ADR | Title | Relevance |
|---|---|---|
| ADR 0003 | Authentication: Clerk + Apple/Google social login, no Entra for end users | Clerk selection rationale, Expo native sign-in, child credential model, Android risk |
| ADR 0006 | Two-plane RBAC with reconciled role model | Canonical six-role table, server-side enforcement rule, social sub → Users row mapping |
| ADR 0007 | Account and family-group identity | Parent-managed child sub-accounts, FamilyGroups as approval unit, one-way messaging |
| ADR 0012 | Announcements: one-way broadcast | Announcements data model, status lifecycle, audience scoping |
| ADR 0023 | Communications authoring and approval workflow | comms_author role rationale, self-approval prohibition, UserCommunicationsScope table |
| ADR 0005 | Observability model | Audit log requirements — every auth and role event must produce an AuditLog row |
| ADR 0008 | Platform composition | API-first rule — no authorization logic lives in any client surface |
| ADR 0037 | RBAC role expansion | infra_admin (level 7), ministry_leader promotion to level 6, media_steward, and homeschool/highschool roles; non-ordinal role design pattern |
| ADR 0038 | Multi-role user authorization | UserRole join table as auth source of truth; AuthContext.roles: AppRole[]; requireRole max-ordinal semantics; deprecated User.role scalar |