Skip to main content

Admin RBAC Architecture — 2026-04-18

Status: Ready for implementation. Model C (per-person access_tokens + multi-group membership) with hybrid template-based custom groups.

Scope note: This document cannot be written to disk — the planning agent is read-only. The spec is delivered below for the parent agent to persist at C:/dev/knowledge/architecture/admin-rbac-2026-04-18.md.

Role Hierarchy


Section 1 — Current state audit

1.1 Roles today (hardcoded enum in src/lib/premium-shared.ts)

9 TeamRole values, single-select per team member (church_team_members.role TEXT NOT NULL):

RoleCurrent tabsSettings sectionsRequest typesTraining pathsFinancialPastoral
adminall 9all 19prayer, visitor, callback, safety10 agent paths
office_admin8 (no upgrade)17 (no team, no chatbot toggle)prayer, visitor, callback, safetysame as admin
prayer_teamoverview, requestsprayer onlyprayer
care_teamoverview, requests, careprayer, visitor, callbackpastoral_care, prayer
treasureroverviewgiving
volunteer_coordinatoroverview, requestsvisitor, callbackwelcome, volunteer
worship_leaderoverviewsermon_prep
spiritual_leaderoverview, trainingprayersmall_group, bible_study, prayer, new_member
care_leaderoverview, trainingprayer, visitor, callbackpastoral_care, prayer, welcome, new_member, youth

1.2 Enforcement sites today

LocationCheckAuthoritative?
AdminDashboard.tsx:198ROLE_TABS[role] filters tabsUI-only
RequestManager.tsx:188-189PASTORAL_ROLES.includes() + ROLE_REQUEST_TYPES[role]UI-only
SettingsTab.tsx:257,261ROLE_SETTINGS[role] filters sub-tabsUI-only
api/premium/requests/route.ts:31,69ROLE_REQUEST_TYPES[resolved.role]API (authoritative)
api/premium/update/route.ts:68,148ROLE_SETTINGS[role]API (authoritative)
api/care/members/route.ts:19ROLE_TABS[role]?.includes('care')API (authoritative)
api/care/broadcast/route.ts:49sameAPI (authoritative)
api/training/sessions/route.ts:125ROLE_TRAINING_PATHS[role]API (authoritative)
api/premium/team/route.ts:35resolved.role !== 'admin'API (authoritative)
voice-queries.ts:237,342PASTORAL_ROLES for prayer/callback redactionQuery-layer

1.3 Inconsistencies found

  1. Tab-order-vs-gating drift. ROLE_TABS.admin still lists 'social' but AdminDashboard.tsx:205 hides it for everyone (if (t.key === 'social') return false). Gate in data; UI shadows it. Dead code.
  2. treasurer has requests nowhere but training gives giving path. A treasurer has zero value in the Inbox today, yet the founder's locked list calls out financial visibility as a real concern.
  3. prayer_team can see Inbox → prayer, but ROLE_REQUEST_TYPES.prayer_team = ['prayer']. Consistent — but the Dashboard Overview recentActivity query in voice-queries.ts:246-270 filters per-role and returns {data: []} literal for unauthorized types. Good pattern to preserve.
  4. spiritual_leader / care_leader / worship_leader roles exist but the founder's locked list uses worship_team / has no spiritual_leader or care_leader. Mapping decision: keep 3 current roles as templates but rename per founder's list (see §4).
  5. Two overlapping identity systems coexist. church_team_members (legacy, token-based) and church_admin_identities + church_identity_roles (new, email/magic-link). Model C layers on top of church_team_members because that is where per-person access_token lives. church_identity_roles is orthogonal (owner/backup metadata for account recovery) and is not touched here.
  6. Financial visibility is declared but never enforced. FINANCIAL_ROLES is exported but not referenced anywhere outside of docs. Gap before launch — Treasurer can't actually see giving because there's no giving surface yet, but the moment a giving surface ships, the taxonomy must be in place.

1.4 The hard migration fact

Today's model has role as TEXT — single role per member. Must become group_ids UUID[] for Model C.

Keep role TEXT during rollout as a read-only fallback; new code reads group_ids + capabilities. Phase 4 backfills and drops role.


Section 2 — Target data model

2.1 New table: church_custom_groups

CREATE TABLE church_custom_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
church_id uuid NOT NULL REFERENCES premium_churches(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
capabilities text[] NOT NULL DEFAULT '{}', -- array of capability keys from Section 3
origin text NOT NULL DEFAULT 'custom' CHECK (origin IN ('template','custom')),
template_key text, -- e.g. 'worship_team'; null for origin='custom'
is_deletable boolean NOT NULL DEFAULT true, -- Admin template is false-locked
sort_order int NOT NULL DEFAULT 100,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (church_id, name)
);

CREATE INDEX idx_custom_groups_church ON church_custom_groups(church_id);
CREATE INDEX idx_custom_groups_template ON church_custom_groups(church_id, template_key) WHERE template_key IS NOT NULL;

-- Validate that every element of capabilities is a known capability key
-- (enforced at application layer; Postgres array-of-enum is awkward).

2.2 Modify church_team_members

ALTER TABLE church_team_members
ADD COLUMN group_ids uuid[] NOT NULL DEFAULT '{}',
ADD COLUMN capabilities text[] NOT NULL DEFAULT '{}';

-- Index for effective-permissions lookup
CREATE INDEX idx_team_members_group_ids ON church_team_members USING GIN (group_ids);

-- Keep role TEXT NOT NULL during Phase 1-3 rollout.
-- Phase 4 migration: backfill group_ids from role, then DROP COLUMN role.

2.3 Back-compat strategy

  • Phase 1 (data model + seed). Add columns nullable-effective via defaults. On each premium_churches INSERT (new church), trigger seeds all 11 templates into church_custom_groups. Existing churches get seeded via a one-shot UPDATE migration run in the same release.
  • Phase 2 (dual-read). resolveToken() returns both legacy role and new effectivePermissions. Every capability check falls back to the legacy ROLE_* maps when group_ids is empty. New code writes both.
  • Phase 3 (legacy write-only). role becomes computed-in-app from the primary group's template_key for UI display continuity. All authorization uses effectivePermissions.
  • Phase 4 (drop). Backfill group_ids from role for every extant row, verify, drop role column, remove legacy constants.

2.4 Seed rule for new churches

On premium_churches INSERT, a server-side function seedTemplateGroups(premiumId) creates 11 rows in church_custom_groups with origin='template', template_key set per §4, and is_deletable = false for the Admin template (only). Admin group membership auto-assigned to the primary owner church_team_members row that already exists.

2.5 RLS posture

  • church_custom_groups: RLS enabled. Service-role policy: full. Member-role policy (via auth.uid() mapped to premium_id): SELECT only within their church_id.
  • church_team_members.group_ids / capabilities: already covered by existing row-scoped RLS on that table.
  • Principle for this project: API is authoritative. RLS is defense-in-depth for the three-Stripe-customer launch window, not the primary gate.

Section 3 — Capability taxonomy

42 capabilities, namespaced surface:resource:action[:qualifier]. Key format is ^[a-z_]+:[a-z_]+(:[a-z_]+){1,2}$. These map to the 4-tab IA (Home / Inbox / Train AI / Website) + Settings slide-over + Billing.

Home

KeyLabelDescriptionCategoryAdmin-onlyDefault seed groups
home:overview:viewView Home dashboardSee the Home tab with welcome card, share-link card, and setup checklistHomeall 11
home:metrics:viewSee activity countsView this-week tiles: calls, prayer requests, visitor contacts (counts only, no detail)Homeall 11
home:metrics:financial:viewSee giving totalsView giving totals tile on Home (sums only, no donor names)HomeAdmin, Treasurer
home:checklist:editManage setup checklistMark items done, skip, hide the checklist railHomeAdmin, Office Admin, Pastor
home:share_link:viewSee share linkCopy church website / chatbot share linkHomeall 11
home:examples:removeRemove demo examplesRemove pre-loaded sample prayer, sample call from fresh-account stateHomeAdmin, Office Admin, Pastor

Inbox

KeyLabelDescriptionCategoryAdmin-onlyDefault seed groups
inbox:calls:readSee call historyView voice call log list with summariesInboxAdmin, Office Admin, Pastor
inbox:calls:transcriptRead call transcriptsOpen full verbatim transcript + listen to recordingInboxAdmin, Office Admin, Pastor
inbox:calls:deleteDelete call recordsPermanently delete a call log rowInboxAdmin
inbox:prayer:readSee prayer requestsView prayer request list (excluding confidential)InboxAdmin, Office Admin, Pastor, Prayer Team, Care Team
inbox:prayer:read:confidentialRead confidential prayer requestsView prayer requests marked is_confidential = true (un-redacted)InboxAdmin, Office Admin, Pastor
inbox:prayer:updateUpdate prayer status / notesMark prayed / acknowledged / archived; add internal notesInboxAdmin, Office Admin, Pastor, Prayer Team, Care Team
inbox:visitor:readSee visitor contactsView first-time visitor intake listInboxAdmin, Office Admin, Pastor, Care Team, Volunteer Coordinator
inbox:visitor:updateUpdate visitor status / notesMark contacted / converted; add notesInboxAdmin, Office Admin, Pastor, Care Team, Volunteer Coordinator
inbox:callback:readSee callback requestsView callback list with names and phoneInboxAdmin, Office Admin, Pastor, Care Team
inbox:callback:read:reasonRead callback reasonsRead the un-redacted reason field (redacts to "Pastoral inquiry" without this cap)InboxAdmin, Office Admin, Pastor
inbox:callback:updateUpdate callback statusMark scheduled / completed / failedInboxAdmin, Office Admin, Pastor, Care Team
inbox:safety:readSee safety flagsView moderation / safety-flag tabInboxAdmin, Office Admin, Pastor
inbox:safety:resolveResolve safety flagsDismiss, escalate, or mark reviewedInboxAdmin, Office Admin, Pastor

Train AI

KeyLabelDescriptionCategoryAdmin-onlyDefault seed groups
train:church_knowledge:editEdit church knowledgeAdd/edit staff, ministries, What-to-Expect, service info used by the AITrain AIAdmin, Office Admin, Pastor
train:theology:editEdit theology lensPick denominational lens, set doctrinal confidenceTrain AIAdmin, Pastor
train:agents:editEdit agent personalitiesModify AI agent tone, persona overrides, promptsTrain AIAdmin, Pastor
train:faqs:editEdit FAQsAdd, edit, delete FAQ Q&A pairsTrain AIAdmin, Office Admin, Pastor
train:safety:editEdit safety / crisis messageConfigure AI safety rules, crisis escalation messageTrain AIAdmin, Pastor
train:simulator:useUse AI simulatorRun AI simulator to test agent responsesTrain AIAdmin, Office Admin, Pastor
train:documents:uploadUpload training documentsUpload PDFs/docs for AI to ingestTrain AIAdmin, Pastor
train:pastor_pulse:editUpdate weekly pulseEdit sermon topic/series, weekly announcement, theme verseTrain AIAdmin, Office Admin, Pastor, Worship Team

Website

KeyLabelDescriptionCategoryAdmin-onlyDefault seed groups
website:sections:editEdit website sectionsEdit Hero, About, Services, Staff, Events, Give, Contact sectionsWebsiteAdmin, Office Admin, Pastor
website:design:editEdit website designChange template, colors, hero video, transition videosWebsiteAdmin, Pastor
website:publishPublish website changesPromote draft → liveWebsiteAdmin, Pastor
website:previewPreview websiteOpen public preview URLWebsiteall 11
website:media:uploadUpload website mediaUpload hero photo, logo, slideshow imagesWebsiteAdmin, Office Admin, Pastor

Settings (slide-over)

KeyLabelDescriptionCategoryAdmin-onlyDefault seed groups
settings:church_profile:editEdit church profileChurch name, description, contact email, phone, socialSettingsAdmin, Office Admin, Pastor
settings:hours:editEdit office hoursOffice hours grid, pastor availabilitySettingsAdmin, Office Admin, Pastor
settings:notifications:editConfigure notificationsNotification email/phone, alert routingSettingsAdmin, Office Admin, Pastor
settings:integrations:editManage integrationsConnect Planning Center, Cal.com, giving URLsSettingsAdmin, Pastor
settings:sharing:viewView sharing & embedShare links, embed code, QR codesSettingsAdmin, Office Admin, Pastor
settings:team:viewView team rosterSee who's on the team + what groups they're inSettingsAdmin, Pastor
settings:team:inviteInvite team membersAdd new members; assign to groupsSettingsAdmin
settings:team:removeRemove team membersDeactivate or delete membersSettingsAdmin

Admin-only / Billing (never grantable via group UI)

KeyLabelDescriptionCategoryAdmin-onlyDefault seed groups
billing:viewView billingSee subscription, invoicesBilling (admin-only)Admin
billing:manageManage billingUpgrade, downgrade, update cardBilling (admin-only)Admin
billing:cancelCancel subscriptionEnd the subscriptionBilling (admin-only)Admin
church:deleteDelete churchPermanently delete the church and all dataBilling (admin-only)Admin
church:transfer_ownershipTransfer primary ownershipMove primary-owner role to another identityBilling (admin-only)Admin
groups:manageManage groups & capabilitiesCreate/edit/delete custom groups; edit template group capabilitiesBilling (admin-only)Admin
api_keys:manageManage API keysCreate/rotate/revoke API keys for integrationsBilling (admin-only)Admin
audit:viewView audit logSee audit log of team actionsBilling (admin-only)Admin

Total: 42 capabilities. 8 are admin-only (never selectable in custom-group UI). 34 are group-grantable.

3.1 Redaction semantics

  • inbox:prayer:read (yes) + inbox:prayer:read:confidential (no) → prayer_text for is_confidential=true rows replaced with "Confidential — contact the pastor". Same row, masked field.
  • inbox:callback:read (yes) + inbox:callback:read:reason (no) → reason field replaced with "Pastoral inquiry".
  • home:metrics:view (yes) + home:metrics:financial:view (no) → giving tile hidden entirely (row-level, not field-level, because the tile is the data).

3.2 Why this shape

  • Namespacing by surface lets the admin UI auto-generate categories (all inbox:* under "Inbox").
  • Verb suffix (read/update/edit/resolve/view) is consistent and tight. read is list+detail; edit is save; update is status/notes transitions; view is analytical/reporting.
  • Qualifiers after a 3rd colon carve out redaction boundaries (:confidential, :reason) without inventing separate surfaces.

Section 4 — Template group definitions (11 templates)

Keys match what the admin UI will scaffold on every new church.

4.1 Admin

  • Key: admin
  • Name: "Admin"
  • Description: "Full access to everything. Can manage team, billing, and every setting."
  • Capabilities: all 42 capabilities (including the 8 admin-only). This is the only template that receives admin-only capabilities and is is_deletable = false.
  • Typical sample: 1–2 per church (pastor + office manager).

4.2 Office Admin

  • Key: office_admin
  • Name: "Office Admin"
  • Description: "Manages day-to-day content, requests, and church profile. Can't change billing or team."
  • Capabilities: all of Home; all of Inbox except inbox:calls:delete; train:church_knowledge:edit, train:faqs:edit, train:simulator:use, train:pastor_pulse:edit; website:sections:edit, website:preview, website:media:upload; settings:church_profile:edit, settings:hours:edit, settings:notifications:edit, settings:sharing:view.
  • Typical sample: 1–2 per church.

4.3 Pastor

  • Key: pastor
  • Name: "Pastor"
  • Description: "Sees all Inbox, edits theology and agent personality, publishes website changes."
  • Capabilities: all of Home; all of Inbox (including :confidential and :reason); all train:* except :safety (see note); all Website; settings:team:view, settings:church_profile:edit.
    • Note: train:safety:edit included for Pastor. Only the 8 admin-only caps are excluded.
  • Typical sample: 1–2 per church (lead pastor + associate).

4.4 Prayer Team

  • Key: prayer_team
  • Name: "Prayer Team"
  • Description: "Sees non-confidential prayer requests and can mark them prayed."
  • Capabilities: home:overview:view, home:metrics:view, home:share_link:view; inbox:prayer:read, inbox:prayer:update; website:preview.
  • Typical sample: 3–8 per church.

4.5 Care Team

  • Key: care_team
  • Name: "Care Team"
  • Description: "Sees prayer, visitor, and callback requests. Follows up with congregation."
  • Capabilities: home:overview:view, home:metrics:view, home:share_link:view; inbox:prayer:read, inbox:prayer:update, inbox:visitor:read, inbox:visitor:update, inbox:callback:read, inbox:callback:update; website:preview.
  • Typical sample: 3–6 per church.

4.6 Treasurer

  • Key: treasurer
  • Name: "Treasurer"
  • Description: "Sees giving totals and financial reports. No access to pastoral content."
  • Capabilities: home:overview:view, home:metrics:view, home:metrics:financial:view, home:share_link:view; website:preview.
  • Typical sample: 1–2 per church.

4.7 Volunteer Coordinator

  • Key: volunteer_coordinator
  • Name: "Volunteer Coordinator"
  • Description: "Manages visitor follow-up and volunteer callbacks. Does not see prayer requests."
  • Capabilities: home:overview:view, home:metrics:view, home:share_link:view; inbox:visitor:read, inbox:visitor:update, inbox:callback:read, inbox:callback:update; website:preview.
  • Typical sample: 1–2 per church.

4.8 Worship Team

  • Key: worship_team
  • Name: "Worship Team"
  • Description: "Edits weekly pulse (sermon topic, theme verse) so the AI speaks to this week's theme."
  • Capabilities: home:overview:view, home:metrics:view, home:share_link:view; train:pastor_pulse:edit; website:preview.
  • Typical sample: 3–6 per church.

4.9 Usher Team

  • Key: usher_team
  • Name: "Usher Team"
  • Description: "Receives first-time visitor contacts so they can welcome them Sunday."
  • Capabilities: home:overview:view, home:share_link:view; inbox:visitor:read; website:preview.
  • Typical sample: 4–10 per church.

4.10 Kids Ministry

  • Key: kids_ministry
  • Name: "Kids Ministry"
  • Description: "Sees visitor contacts flagged with children. Receives kids-specific callbacks."
  • Capabilities: home:overview:view, home:metrics:view, home:share_link:view; inbox:visitor:read, inbox:visitor:update, inbox:callback:read, inbox:callback:update; website:preview.
  • Typical sample: 2–4 per church.

4.11 Youth Ministry

  • Key: youth_ministry
  • Name: "Youth Ministry"
  • Description: "Sees visitor contacts and callbacks related to youth. Can update status."
  • Capabilities: same as Kids Ministry.
  • Typical sample: 2–4 per church.

4.12 Tech Team

  • Key: tech_team
  • Name: "Tech Team"
  • Description: "Edits website design, media uploads, integrations. Does not see pastoral content."
  • Capabilities: home:overview:view, home:share_link:view; train:simulator:use; website:sections:edit, website:design:edit, website:preview, website:media:upload; settings:integrations:edit, settings:sharing:view.
  • Typical sample: 1–3 per church.

Founder wrote "11 templates" but specified 12 (Admin through Tech Team). Shipping all 12. The Admin template is non-deletable; the other 11 can be renamed, customized, or deleted.


Section 5 — Permissions matrix

Legend: = full access · = redacted (visible but sensitive fields masked) · blank = hidden. Columns abbreviated: Admin (A), Office Admin (OA), Pastor (P), Prayer (Pr), Care (C), Treasurer (T), Volunteer Coord (V), Worship (W), Usher (U), Kids (K), Youth (Y), Tech (X). "AO" column flags admin-only capabilities.

Home tab

Surface / elementCapabilityAOAPPrCTVWUKYXAO
Welcome cardhome:overview:view
Share-link cardhome:share_link:view
Setup checklist railhome:checklist:edit
Metric tile: calls this weekhome:metrics:view
Metric tile: prayer counthome:metrics:view
Metric tile: visitor counthome:metrics:view
Metric tile: giving totalshome:metrics:financial:view
Example-data card (sample prayer/call)home:examples:remove
"View live preview" linkhome:share_link:view

Inbox tab (filter chips)

SurfaceCapabilityAOAPPrCTVWUKYXAO
Chip: Callsinbox:calls:read
Call detail — transcriptinbox:calls:transcript
Call detail — deleteinbox:calls:delete
Chip: Prayer requestsinbox:prayer:read
Prayer detail — confidential textinbox:prayer:read:confidential
Prayer row status dropdowninbox:prayer:update
Chip: Visitorsinbox:visitor:read
Visitor row updateinbox:visitor:update
Chip: Callbacksinbox:callback:read
Callback row — reason textinbox:callback:read:reason
Callback status dropdowninbox:callback:update
Chip: Safety flagsinbox:safety:read
Safety flag resolve actionsinbox:safety:resolve

Train AI tab (sub-tabs)

SurfaceCapabilityAOAPPrCTVWUKYXAO
Church Knowledge sub-tabtrain:church_knowledge:edit
Theology sub-tabtrain:theology:edit
Agent Personalities sub-tabtrain:agents:edit
FAQs sub-tabtrain:faqs:edit
Safety / Crisis sub-tabtrain:safety:edit
Simulator sub-tabtrain:simulator:use
Document Upload sub-tabtrain:documents:upload
Pastor Pulse cardtrain:pastor_pulse:edit

Website tab (editor sections)

SurfaceCapabilityAOAPPrCTVWUKYXAO
Section panel: Herowebsite:sections:edit
Section panel: Aboutwebsite:sections:edit
Section panel: Serviceswebsite:sections:edit
Section panel: Staffwebsite:sections:edit
Section panel: Eventswebsite:sections:edit
Section panel: Givewebsite:sections:edit
Section panel: Contactwebsite:sections:edit
Design: template pickerwebsite:design:edit
Design: colorswebsite:design:edit
Design: videoswebsite:design:edit
Publish buttonwebsite:publish
Media uploadwebsite:media:upload
Preview buttonwebsite:preview

Settings slide-over

SurfaceCapabilityAOAPPrCTVWUKYXAO
Church Profile sectionsettings:church_profile:edit
Hours sectionsettings:hours:edit
Notifications sectionsettings:notifications:edit
Integrations sectionsettings:integrations:edit
Sharing sectionsettings:sharing:view
Team rostersettings:team:view
Team — Invitesettings:team:invite
Team — Removesettings:team:remove
Billing sectionbilling:view, billing:manage, billing:cancel
Delete church (danger zone)church:delete
Transfer ownershipchurch:transfer_ownership
Manage Groups UIgroups:manage
API Keysapi_keys:manage
Audit logaudit:view

Adversarial check — snoop scenarios closed:

  • Prayer Team snoops on giving. Prayer Team has zero home:metrics:financial:view and zero inbox:callback:read:reason → giving tile absent from their Home; callback reasons redacted if they somehow added Care Team too. Confirmed.
  • Tech Team snoops on confidential prayer. Tech Team has no inbox:* caps at all. Chip-gated + API-gated. Confirmed.
  • Usher snoops on callback reasons. Usher only has inbox:visitor:read. Callback chip hidden, API returns 403. Confirmed.
  • Volunteer Coordinator reads confidential callback reason. Has inbox:callback:read but not :reason → reason masked to "Pastoral inquiry". Confirmed (matches today's redaction pattern in voice-queries.ts:288).
  • Kids Ministry pivots to prayer. No inbox:prayer:* caps. API returns 403 on /api/premium/requests?type=prayer. Confirmed.
  • Worship Team edits theology. Only has train:pastor_pulse:edit. Theology form returns 403 on PATCH. Confirmed.

Section 6 — Effective-permissions algorithm

6.1 Types (new file: src/lib/rbac.ts)

// src/lib/rbac.ts — client-safe; no DB imports.

export type Capability =
| 'home:overview:view' | 'home:metrics:view' | 'home:metrics:financial:view'
| 'home:checklist:edit' | 'home:share_link:view' | 'home:examples:remove'
| 'inbox:calls:read' | 'inbox:calls:transcript' | 'inbox:calls:delete'
| 'inbox:prayer:read' | 'inbox:prayer:read:confidential' | 'inbox:prayer:update'
| 'inbox:visitor:read' | 'inbox:visitor:update'
| 'inbox:callback:read' | 'inbox:callback:read:reason' | 'inbox:callback:update'
| 'inbox:safety:read' | 'inbox:safety:resolve'
| 'train:church_knowledge:edit' | 'train:theology:edit' | 'train:agents:edit'
| 'train:faqs:edit' | 'train:safety:edit' | 'train:simulator:use'
| 'train:documents:upload' | 'train:pastor_pulse:edit'
| 'website:sections:edit' | 'website:design:edit' | 'website:publish'
| 'website:preview' | 'website:media:upload'
| 'settings:church_profile:edit' | 'settings:hours:edit'
| 'settings:notifications:edit' | 'settings:integrations:edit'
| 'settings:sharing:view' | 'settings:team:view'
| 'settings:team:invite' | 'settings:team:remove'
| 'billing:view' | 'billing:manage' | 'billing:cancel'
| 'church:delete' | 'church:transfer_ownership'
| 'groups:manage' | 'api_keys:manage' | 'audit:view';

export const ADMIN_ONLY_CAPABILITIES: ReadonlySet<Capability> = new Set([
'billing:view', 'billing:manage', 'billing:cancel',
'church:delete', 'church:transfer_ownership',
'groups:manage', 'api_keys:manage', 'audit:view',
]);

export interface ResolvedMember {
id: string;
church_id: string;
premium_id: string;
name: string;
group_ids: string[];
capabilities: Capability[]; // direct per-person grants
is_active: boolean;
}

export interface GroupSpec {
id: string;
name: string;
capabilities: Capability[];
}

/** Pure union of group capabilities + direct grants. Deduped via Set. */
export function effectivePermissions(
member: Pick<ResolvedMember, 'group_ids' | 'capabilities'>,
groupsById: Map<string, GroupSpec>,
): Set<Capability> {
const out = new Set<Capability>(member.capabilities);
for (const gid of member.group_ids) {
const g = groupsById.get(gid);
if (!g) continue;
for (const c of g.capabilities) out.add(c);
}
return out;
}

export function can(
member: Pick<ResolvedMember, 'group_ids' | 'capabilities'>,
cap: Capability,
groupsById: Map<string, GroupSpec>,
): boolean {
return effectivePermissions(member, groupsById).has(cap);
}

export class ForbiddenError extends Error {
constructor(public readonly capability: Capability) {
super(`Forbidden: missing capability ${capability}`);
this.name = 'ForbiddenError';
}
}

export function assertCan(
member: Pick<ResolvedMember, 'group_ids' | 'capabilities'>,
cap: Capability,
groupsById: Map<string, GroupSpec>,
): void {
if (!can(member, cap, groupsById)) throw new ForbiddenError(cap);
}

6.2 Request-scoped cache

// src/lib/rbac-resolver.ts — server-only
import 'server-only';
import { cache } from 'react';
import { supabase } from './supabase';
import type { ResolvedMember, GroupSpec } from './rbac';

// React cache() dedupes per request — identical args return the same promise.
export const getGroupsForPremium = cache(async (premiumId: string): Promise<Map<string, GroupSpec>> => {
const { data } = await supabase
.from('church_custom_groups')
.select('id, name, capabilities')
.eq('church_id', premiumId);
const m = new Map<string, GroupSpec>();
for (const g of data ?? []) m.set(g.id, g as GroupSpec);
return m;
});

Performance note: one SELECT per request per premium, cached by React's cache() for the duration of the request. Typical result set ≤ 20 rows × ~20 capability strings → ~2 KB. Effective-permission computation is a single linear pass over group_ids (max ~5 per person) + direct grants — sub-millisecond.

6.3 Backwards compatibility helper during Phases 1–3

// src/lib/rbac-legacy.ts — compat bridge during rollout
import { LEGACY_ROLE_CAPABILITIES } from './rbac-legacy-map'; // derived from Section 4

export function legacyRoleCapabilities(role: string): Capability[] {
return LEGACY_ROLE_CAPABILITIES[role] ?? [];
}

export function effectiveWithFallback(
member: { role?: string; group_ids: string[]; capabilities: Capability[] },
groupsById: Map<string, GroupSpec>,
): Set<Capability> {
if (member.group_ids.length > 0 || member.capabilities.length > 0) {
return effectivePermissions(member, groupsById);
}
// Pre-migration: fall back to legacy role
return new Set(legacyRoleCapabilities(member.role ?? 'admin'));
}

Section 7 — Enforcement layers (UI, API, DB)

Principle: API always authoritative. UI hiding is UX, not security. DB RLS is defense-in-depth.

7.1 Per-surface enforcement table (key surfaces)

SurfaceUIAPIDB (RLS)
Home metric tile: giving totalsHidden if !can('home:metrics:financial:view')/api/admin/giving/summary → 403 without capRLS on church_giving (future) limited to members whose capabilities @> ARRAY['home:metrics:financial:view'] OR whose group_ids intersect a group that contains it
Inbox chip: PrayerHidden if !can('inbox:prayer:read')/api/premium/requests?type=prayer → 403voice_prayer_requests row-scoped RLS by church_id (existing); redaction applied in API layer
Prayer detail: confidential textShown redacted if !can('inbox:prayer:read:confidential')API returns prayer_text: 'Confidential — contact the pastor' when cap missing; pattern already used in voice-queries.ts:348-353
Callback detail: reason fieldShown redacted if !can('inbox:callback:read:reason')API returns reason: 'Pastoral inquiry' when cap missing; already in voice-queries.ts:288
Train AI → TheologySub-tab hidden if !can('train:theology:edit')/api/admin/theology POST → 403
Train AI → Agent personalitySub-tab hidden if !can('train:agents:edit')/api/admin/agent-config PATCH → 403
Website Publish buttonButton hidden if !can('website:publish')/api/admin/website/publish → 403
Settings → Team → InviteButton hidden if !can('settings:team:invite')/api/premium/team POST where action=invite → 403
Settings → BillingEntire section hidden if !can('billing:view')/api/billing/* → 403RLS on stripe_* tables (service-role only anyway)
Settings → Manage GroupsSection hidden if !can('groups:manage')/api/admin/groups → 403church_custom_groups RLS: SELECT is allowed to all members of church_id; INSERT/UPDATE/DELETE require service-role OR member with groups:manage (enforced in API)
Team removeButton hidden if !can('settings:team:remove')/api/premium/team POST action=remove → 403

7.2 API middleware pattern (new)

Replace the current ad-hoc resolved.role !== 'admin' checks with a single helper:

// src/lib/api-rbac.ts — new
import 'server-only';
import { NextResponse } from 'next/server';
import { resolveTokenOrHeaders } from './premium-queries';
import { getGroupsForPremium } from './rbac-resolver';
import { effectivePermissions, type Capability } from './rbac';

export async function requireCapability(
request: Request,
token: string | null,
cap: Capability,
): Promise<
| { ok: true; resolved: Awaited<ReturnType<typeof resolveTokenOrHeaders>> & {}; caps: Set<Capability> }
| { ok: false; response: Response }
> {
const resolved = await resolveTokenOrHeaders(token, request.headers);
if (!resolved) return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };

const groups = await getGroupsForPremium(resolved.premium.id);
// During rollout: hydrate member's group_ids + capabilities from DB (not on resolved).
// Phase 2+: have resolveToken also fetch these fields.
const caps = effectivePermissions(
{ group_ids: (resolved as any).group_ids ?? [], capabilities: (resolved as any).capabilities ?? [] },
groups,
);
if (!caps.has(cap)) {
return { ok: false, response: NextResponse.json({ error: `Forbidden: ${cap}` }, { status: 403 }) };
}
return { ok: true, resolved, caps };
}

7.3 UI hook pattern

// src/hooks/useCapabilities.ts — new
'use client';
import { createContext, useContext } from 'react';
import type { Capability } from '@/lib/rbac';

export const CapabilityContext = createContext<Set<Capability>>(new Set());
export const useCan = (cap: Capability) => useContext(CapabilityContext).has(cap);

The server page wraps the dashboard tree with <CapabilityContext.Provider value={caps}>. Every component reads via useCan(...).


Section 8 — Admin Group Management UI

All mockups live under Settings slide-over → Team sub-tab → "Manage Groups" link.

8.1 Team Members screen (updated)

┌──────────────────────────────────────────────────────────────────────────┐
│ Settings · Team [← back to dashboard] │
│ │
│ 12 members · 8 groups [+ Invite member] │
│ ────────────────────────────────────────────────────────────────────── │
│ │
│ Pastor Ruth Groups: Admin · Pastor │
│ ruth@buchananbaptist.org Last seen: 12 min ago │
│ [✎ edit groups] [copy link] [remove] │
│ ────────────────────────────────────────── │
│ Sarah Chen Groups: Prayer Team │
│ sarah.c@buchananbaptist.org Last seen: 2 days ago │
│ [✎ edit groups] [copy link] [remove] │
│ ────────────────────────────────────────── │
│ Tom Walker Groups: Treasurer │
│ tom@buchananbaptist.org Last seen: never │
│ [✎ edit groups] [copy link] [remove] │
│ │
│ ────────────────────────────────────────────────────────────────────── │
│ GROUPS [+ Create group] │
│ │
│ · Admin (2 members) [rename] [edit] · locked │
│ · Pastor (1 member) [rename] [edit] [delete] │
│ · Prayer Team (5 members) [rename] [edit] [delete] │
│ · Care Team (0 members) [rename] [edit] [delete] │
│ · Treasurer (1 member) [rename] [edit] [delete] │
│ · Worship Team (3 members) [rename] [edit] [delete] │
│ │
└──────────────────────────────────────────────────────────────────────────┘

8.2 Invite Member modal

┌─────────────────────────────────────────────────────────────┐
│ Invite a team member [✕] │
│ ───────────────────────────────────────────────────── │
│ Name [ Sarah Chen ] │
│ Email [ sarah.c@buchananbaptist.org ] │
│ │
│ Groups (pick one or more) │
│ [x] Prayer Team │
│ [ ] Care Team │
│ [ ] Worship Team │
│ [ ] Usher Team │
│ [ ] Kids Ministry │
│ [ ] Youth Ministry │
│ [ ] Volunteer Coordinator │
│ [ ] Tech Team │
│ [ ] Office Admin │
│ [ ] Pastor │
│ │
│ ▸ Advanced: extra capabilities (optional) │
│ Use when this person needs one-off access beyond │
│ their groups. │
│ [click to expand] │
│ │
│ We'll send Sarah a magic link to this email. │
│ │
│ [Cancel] [Send invite] │
└─────────────────────────────────────────────────────────────┘

8.3 Create / Edit Group modal

┌─────────────────────────────────────────────────────────────┐
│ Edit group: Prayer Team [✕] │
│ ───────────────────────────────────────────────────── │
│ Name [ Prayer Team ] │
│ Description [ Sees non-confidential prayer requests] │
│ │
│ Capabilities │
│ ───────────── │
│ │
│ HOME │
│ [x] See Home dashboard │
│ [x] See activity counts │
│ [ ] See giving totals │
│ [x] See share link │
│ │
│ INBOX │
│ [x] See prayer requests │
│ [ ] Read confidential prayer requests │
│ [x] Update prayer status / notes │
│ [ ] See visitor contacts │
│ [ ] See callback requests │
│ [ ] Read callback reasons │
│ [ ] Update callback status │
│ [ ] See safety flags │
│ [ ] Resolve safety flags │
│ [ ] See call history │
│ │
│ TRAIN AI │
│ [ ] Edit church knowledge │
│ [ ] Edit theology lens │
│ ... (collapsed by default — expand) │
│ │
│ WEBSITE │
│ [x] Preview website │
│ [ ] Edit website sections │
│ ... │
│ │
│ SETTINGS │
│ [ ] Edit church profile │
│ ... │
│ │
│ ───────────── │
│ Admin-only capabilities (billing, delete, manage groups) │
│ never appear here. Admins get those automatically. │
│ │
│ [Cancel] [Save group] │
└─────────────────────────────────────────────────────────────┘

8.4 Group deletion confirmation

┌───────────────────────────────────────────────────────────┐
│ Delete "Prayer Team"? [✕] │
│ ──────────────────────────────────────────────────── │
│ │
│ ⚠ 5 people will lose this access: │
│ · Sarah Chen │
│ · Mark Davis │
│ · Linda Park │
│ · Joe Patel │
│ · Amanda Ng │
│ │
│ They will remain team members but will lose all │
│ capabilities granted by this group. │
│ │
│ If any of them are only in this group, they will │
│ no longer see any content in the Inbox. │
│ │
│ This cannot be undone. (You can re-create the group │
│ from the Prayer Team template later.) │
│ │
│ [Cancel] [Yes, delete Prayer Team] │
└───────────────────────────────────────────────────────────┘

8.5 Empty states

  • Zero groups beyond Admin (someone deleted them all): prompt "Restore templates?" button that re-seeds from the template registry.
  • Zero members in a group: "No one is in this group yet. [Invite someone →]"
  • Member with zero groups and zero direct caps (possible if admin removes everything): dashboard renders a lockout page: "Your account has no permissions. Please contact your church admin."

8.6 Error states

  • Invite email already exists → inline: "A team member with this email already exists. [Edit their groups →]"
  • Save group with name collision → inline: "A group named 'Prayer Team' already exists. Pick a different name."
  • Save group with zero capabilities → warning toast: "This group grants no access. Add at least one capability to make it useful." (Allow save.)
  • Delete last Admin-group member → block with "Admin group must have at least one member. Add someone else before removing yourself."

Section 9 — Test surface

#ScenarioExpected
1Member in [Prayer Team] opens InboxOnly the Prayer chip is visible. /api/premium/requests?type=visitor returns 403.
2Member in [Prayer Team, Care Team] opens InboxPrayer + Visitor + Callback chips visible (union). inbox:callback:read:reason absent → reason field shows "Pastoral inquiry".
3Member in [Usher Team] + direct grant inbox:prayer:readVisitor + Prayer chips visible. Confidential prayer still masked (no :confidential cap).
4Member in [Office Admin] where Admin deletes the Office Admin groupMember loses all Office Admin caps on next request. /api/admin/theology GET → 403. Member still has a valid access_token and logs in to a lockout or reduced view.
5Admin opens Create Group modalBilling, delete-church, transfer-ownership, manage-groups, api-keys, audit:view checkboxes are absent from the capability list.
6Template rename (Admin renames "Prayer Team" to "Intercessors")Renamed in UI; existing members retain membership; template_key remains 'prayer_team'.
7Template → custom conversion (admin edits caps on a template group)origin stays 'template', template_key stays set, capabilities now diverge. "Restore template defaults" button offered.
8Admin creates brand-new custom group "Hospitality" with inbox:visitor:read onlyRow inserted with origin='custom', template_key=NULL. Members can be assigned.
9API 403 parity test: Treasurer hits every Inbox endpointAll 403. No endpoint returns 200 with redacted data (Treasurer has zero inbox caps).
10UI-hidden-but-API-open regression test: Prayer Team member calls /api/admin/theology directly403. Not 200-with-empty.
11Revocation immediacy: admin removes [Care Team] from Joe; Joe's next page loadJoe sees Inbox without Visitor/Callback chips; matching API calls return 403 within same session (no token rotation).
12Per-member access_token stability under role changesJoe's access_token is unchanged when groups change. Only group_ids array is updated.
13Admin-group minimum-one-member invariantRemoving the last Admin member blocked with "Admin group must have at least one member."
14Ownership transfer and Admin groupTransferring primary ownership also ensures the new owner is in the Admin group (auto-added).
15Group-delete un-assigns correctlyAfter delete, every previously-in-group member has that group_id removed from their group_ids array. Idempotent.
16Confidential prayer redactionPrayer Team member viewing a prayer with is_confidential=true sees the redacted placeholder. Office Admin viewing the same row sees full text.
17Multi-group union does NOT accidentally grant admin-only capEven if someone assembles 10 custom groups, none can grant billing:view because the create-group UI never exposes it (server validates on group save).
18Direct grant of admin-only cap attemptServer rejects with 400: "Admin-only capabilities cannot be granted directly."
19Phase 2 fallback: member with empty group_ids but legacy role='prayer_team'effectiveWithFallback() returns the template caps for prayer_team.
20Legacy endpoint compatibility/api/premium/requests continues to accept token query param and apply new capability checks with no change to API surface.

Section 10 — Rollout phases

Phase 1 — Data model + template seeding + hardcoded template-access (~2 days)

Goal: Ship new schema and seed. Every new church gets 12 templates. No admin UI yet. Capability checks shim-mapped from legacy role (no behavior change).

Files touched:

  • new supabase/migrations/20260419000000_rbac_model_c.sql — DDL from §2.
  • new src/lib/rbac.ts — types + effective-permissions helpers.
  • new src/lib/rbac-resolver.ts — cached group fetch.
  • new src/lib/rbac-templates.ts — 12-template definitions matching §4.
  • new src/lib/rbac-legacy-map.ts — legacy-role → capabilities map.
  • src/app/api/stripe/webhook/route.ts — seed templates on provisionNewChurch.
  • new src/app/api/admin/groups/seed-existing/route.ts — one-shot endpoint to seed all extant churches (internal only).

Migrations: additive only. No destructive changes to church_team_members.

Back-compat: resolveToken() unchanged; capability checks fall through to legacy map via effectiveWithFallback().

Phase 2 — Admin Group Management UI for template editing (~2 days)

Goal: Admins can rename, edit capabilities, delete templates (except Admin). No custom group creation yet.

Files touched:

  • new src/app/admin/[token]/components/settings/TeamTab.tsx — rewritten Team section.
  • new src/app/admin/[token]/components/settings/GroupEditorModal.tsx.
  • new src/app/admin/[token]/components/settings/GroupDeleteConfirmModal.tsx.
  • new src/app/api/admin/groups/route.ts — GET (list), PATCH (rename/edit caps).
  • new src/app/api/admin/groups/[id]/route.ts — DELETE.
  • new src/lib/api-rbac.tsrequireCapability() middleware from §7.2.
  • update src/app/admin/[token]/page.tsx — wrap in <CapabilityContext.Provider>.

Back-compat: member rows keep role; new group_ids membership preferred on read.

Phase 3 — Custom group creation + direct-grant UI (~1 day)

Goal: Admins can create brand-new groups and grant per-person capabilities.

Files touched:

  • new src/app/admin/[token]/components/settings/InviteMemberModal.tsx — with group-picker + advanced per-cap grants.
  • new src/app/api/admin/groups/route.ts POST — create custom group.
  • update src/app/api/premium/team/route.ts — accept group_ids[], capabilities[].
  • new src/app/api/admin/members/[id]/capabilities/route.ts — PATCH direct grants.
  • new src/hooks/useCapabilities.ts.

Back-compat: role column still written as the template_key of the first group for legacy display.

Phase 4 — Customer migration + drop legacy role (~half day)

Goal: Backfill + remove the legacy field.

Migration script:

-- For every extant church_team_members row, find the matching template
-- group_id (by role → template_key) and insert into group_ids.
UPDATE church_team_members m
SET group_ids = ARRAY[g.id]::uuid[]
FROM church_custom_groups g
WHERE g.church_id = m.premium_id
AND g.template_key = m.role
AND array_length(m.group_ids, 1) IS NULL;

Verify query: find rows where group_ids = '{}' AND capabilities = '{}' → those are orphans. Fix by adding to legacy role→template_key group. Then:

ALTER TABLE church_team_members DROP COLUMN role;

Code cleanup: remove TeamRole, ROLE_TABS, ROLE_SETTINGS, ROLE_REQUEST_TYPES, ROLE_TRAINING, ROLE_TRAINING_PATHS, PASTORAL_ROLES, FINANCIAL_ROLES, SIMULATOR_ONLY_ROLES, getRoleLabel. Roughly 25 call-sites from the grep in §1.2.

Back-compat: none needed post-Phase 4. Everything is capability-driven.


Section 11 — Open questions / founder decisions needed

  1. Should Treasurer see the prayer request count on Home (Option A), or should the counts tile be capability-gated per-data-type (Option B)?

    • A: Prayer count visible to Treasurer (they only skip the contents). Simpler.
    • B: Counts tile is sliced — Treasurer sees only Giving/visitor counts, not prayer count. More privacy-preserving.
    • My default recommendation: A, to keep tile implementation simple. The count alone is not sensitive.
  2. Pastor template caps: include train:safety:edit and website:publish, or reserve those strictly to Admin?

    • A: Include (current spec). A senior pastor realistically edits these.
    • B: Exclude. Only Admin can edit safety / publish.
    • Recommendation: A. A church typically has one pastor who IS the spiritual authority; withholding these makes the template useless.
  3. home:examples:remove — is this a real capability or just an admin action?

    • A: Keep as capability. Allows Office Admin to clear demo data.
    • B: Drop; fold into home:checklist:edit. Less noise in capability list.
    • Recommendation: B. It's a one-click one-time action, not worth a dedicated cap.
  4. Direct-grant ergonomics: flat checkbox list vs. "add a capability" searchable picker?

    • A: Flat checkbox list of all 34 grantable caps (expanded by category, collapsed by default).
    • B: "Add capability" button that opens a searchable picker; selected caps appear as chips.
    • Recommendation: A for parity with the group editor. Consistency > novelty.
  5. Worship Team access to inbox:prayer:read?

    • The founder's prior brief implied Worship needs pulse-editing only. But in practice worship leaders pray at the end of service and often need prayer awareness.
    • A: Exclude (current spec).
    • B: Include, on the argument that worship leaders pastorally engage.
    • Recommendation: A (keep tight by default); Admin can add as a direct grant case-by-case.
  6. Where does care:broadcast (email the congregation) live?

    • Not present in the 42 caps because the redesign may move Care out of Inbox. If it stays, it needs inbox:care:broadcast and inbox:care:subscribers:view caps. Flag for when the Care tab fate is decided.
  7. "Pastor" template and primary-owner auto-assignment.

    • When a church signs up, the primary owner is auto-added to Admin. Should they also auto-join the Pastor template? Or should the UI prompt "You're the admin — are you also the pastor? [yes/no]" at the checklist stage?
    • Recommendation: prompt once on Home during onboarding. Default is yes.

Critical Files for Implementation

  • C:/dev/churchwiseai-web/src/lib/premium-shared.ts — source of ROLE_* constants to be superseded; legacy fallback map lives here until Phase 4.
  • C:/dev/churchwiseai-web/src/lib/premium-queries.tsresolveToken must start returning group_ids and capabilities on the resolved member.
  • C:/dev/churchwiseai-web/src/app/admin/[token]/components/AdminDashboard.tsx — top-level gating consumer; must switch from ROLE_TABS[role] to can() calls under the new IA.
  • C:/dev/churchwiseai-web/src/app/api/premium/requests/route.ts — highest-traffic authoritative gate; prototype for the requireCapability() middleware rollout.
  • C:/dev/churchwiseai-web/src/lib/database.types.ts — must be regenerated after migration adds group_ids, capabilities, and the church_custom_groups table.