Skip to content

Admin Portal — Users + Tenants Pages — Design Spec

Status: Draft 2026-04-29 Owner: SD (CPO/CTO) References: ADR-0018 (multi-tenancy), ADR-0021 (Clerk org model), ADR-0016 (DAO repository pattern), issue #50 (frontend admin portal)

Goal

Replace the direct-DB-write toil that ops (currently SD) does for routine multi-tenancy operations. After this ships:

Today (manual SQL) After this spec
INSERT INTO user_roles ... to grant a role Click "Grant role" on Users page
DELETE FROM user_roles WHERE ... to revoke Click "Revoke" on Users page
INSERT INTO tenants ... to create a tenant "Create tenant" form on Tenants page
INSERT INTO tenant_org_mappings ... for new Clerk org "Map Clerk org → tenant" on Tenants page
Probing tenant_org_mappings to debug a portal 403 "View mappings" on Tenants page

Session 80 alone needed 5+ direct-SQL operations of these kinds. The pattern compounds as the platform scales.

Non-Goals

This spec covers only the Users + Tenants admin pages. Out of scope:

  • Procedures management page (#50 ✗ — defer; current procedures are seeded)
  • Providers onboarding page (#50 ✗ — separate scope; uses POST /providers/{id}/tenant already)
  • Audit log viewer (separate spec — Tier 2, single page, no permission surface)
  • Dashboard real-time widgets (separate, Tier 2)
  • Telegram alert dashboard (defer — Telegram itself is the dashboard for now)
  • Notification preferences UI (#124 — separate cross-portal feature)
  • Multi-row bulk operations (defer to Phase 2; v1 is single-record)
  • CSV import/export (defer)

Design Decisions (locked during this draft)

  1. One spec, two pages. Users and Tenants share the (user, tenant, role) triple as their core domain object. Splitting into two specs would duplicate edge cases.

  2. Cross-tenant queries via BaseUnscopedRepository. The admin pages need to query across all tenants. RLS still applies row-level; the repository class explicitly opts out per ADR-0016. Caller (route handler) must have tenant:manage or user:manage permission, enforced by @require_permission.

  3. Soft-delete only on tenants. Per #50: deactivate/reactivate, never DELETE. tenants.is_active flag toggles. Existing data preserved. GDPR Article 17 cascades remain a separate, intentional flow (ADR-0019).

  4. Granular permissions: user:manage and tenant:manage. Both currently rolled into platform_admin role. Spec proposes splitting: user:manage grants are required for /admin/users/ writes; tenant:manage for /admin/tenants/. Read endpoints use user:read and tenant:read (granted to platform_admin and any future support_agent role).

  5. Audit log emit on every write. New EventType values: ADMIN_USER_ROLE_GRANTED, ADMIN_USER_ROLE_REVOKED, ADMIN_TENANT_CREATED, ADMIN_TENANT_UPDATED, ADMIN_TENANT_DEACTIVATED, ADMIN_ORG_MAPPING_CREATED, ADMIN_ORG_MAPPING_DELETED. Payload includes actor, target user/tenant/org, before/after values where applicable.

  6. Clerk integration is read-mostly. Admin portal does NOT create Clerk orgs (those come from Clerk org-creation flow and trigger webhook → tenant_org_mappings row). Admin portal can: (a) view existing Clerk orgs and their tenant mapping, (b) edit a mapping (e.g. flip portal_type), (c) delete a mapping. It cannot create new Clerk orgs from the admin UI in v1.

  7. Pagination + search required from v1. Users could grow to thousands. Tenants stays small (<50 expected) but list endpoint still paginates for consistency. Default page size 25, max 100.

  8. No bulk operations in v1. Per-row only. Bulk grant/revoke/deactivate is Phase 2.

  9. Self-write guard. Granting a role to yourself, revoking your own role, or deactivating your own tenant is allowed but must include a confirm-modal in the UI ("You are modifying your own access. Continue?") and emit an ADMIN_SELF_MUTATION event in addition to the standard audit event.

  10. Last-admin guard. Revoking the last active platform_admin (or last tenant_admin for any tenant) returns HTTP 409 with code RBAC_LAST_ADMIN_GUARD. Override requires explicit ?force=true query param AND the caller must hold the admin:force permission (granted only to super_admin role; see decision 11).

  11. super_admin role created in this spec. New role super_admin is seeded alongside the new permissions. It carries every permission platform_admin has plus admin:force. SD's user gets the role on the seed migration. Used for: last-admin guard override, tenant-curaway-admin mutation override (display-only for everyone else), tenant deactivation with active cases override, future emergency operations. Distinct from platform_admin so day-to-day platform admins can't accidentally trigger force operations.

  12. Tenant-create form unified. Single "Create tenant" UI with a type selector (provider | coordinator | mso | admin | facilitator). Backend branches under the hood:

    • type=provider → existing POST /api/v1/admin/providers/{provider_id}/tenant (creates Clerk org + tenant atomically per ProviderOnboardingService)
    • type=coordinator|mso|admin|facilitator → new POST /api/v1/admin/tenants (creates tenant row only; Clerk org separately if needed) Frontend shows the right fields per type. The current POST /providers/{id}/tenant is preserved as-is — additive change only.
  13. tenant-curaway-admin mutation lock. Listed in Tenants page but all mutations (deactivate, edit slug, delete org mapping that targets it) return HTTP 409 with code TENANT_PROTECTED_ADMIN. super_admin with ?force=true can override. UI shows a 🔒 badge on the row.

  14. All admin endpoints under /api/v1/admin/*. Existing endpoints (POST /api/v1/admin/providers/{id}/tenant, POST /api/v1/admin/tenants/{id}/invite) keep their paths. New endpoints follow same prefix.

Architecture & Data Flow

graph TD
  AdminUI[Admin Portal UI<br/>apps/admin-app/]

  subgraph Backend
    UserRouter["app/routers/admin_users.py<br/>(NEW)"]
    TenantRouter["app/routers/admin_tenants.py<br/>(NEW; extends admin_portal.py)"]
    UserSvc["app/services/admin_user_service.py<br/>(NEW)"]
    TenantSvc["app/services/admin_tenant_service.py<br/>(NEW)"]
    UserRepo["app/repositories/user_role_admin_repository.py<br/>BaseUnscopedRepository (NEW)"]
    TenantRepo["app/repositories/tenant_repository.py<br/>BaseUnscopedRepository (NEW)"]
    Audit[(audit_logs<br/>+ events)]
    PG[(Postgres<br/>tenants, user_roles, roles,<br/>tenant_org_mappings)]
    Clerk[Clerk Backend API<br/>(read-mostly)]
  end

  AdminUI -->|GET /admin/users/...| UserRouter
  AdminUI -->|GET/POST /admin/tenants/...| TenantRouter
  AdminUI -->|GET /admin/orgs/...| TenantRouter

  UserRouter --> UserSvc --> UserRepo --> PG
  TenantRouter --> TenantSvc --> TenantRepo --> PG
  TenantSvc -.->|optional lookup| Clerk

  UserSvc -.->|emit Event| Audit
  TenantSvc -.->|emit Event| Audit

  style AdminUI fill:#008B8B,color:#fff
  style UserSvc fill:#FF7F50,color:#fff
  style TenantSvc fill:#FF7F50,color:#fff
  style Clerk fill:#FF7F50,color:#fff

Files

Created

app/routers/admin_users.py                    # GET/POST/DELETE /admin/users/*
app/routers/admin_tenants.py                  # GET/POST/PATCH /admin/tenants/*
                                              # GET/POST/DELETE /admin/orgs/*
app/services/admin_user_service.py            # business logic for user/role admin
app/services/admin_tenant_service.py          # business logic for tenant + org-map admin
app/repositories/user_role_admin_repository.py  # BaseUnscopedRepository for cross-tenant user queries
app/repositories/tenant_repository.py         # BaseUnscopedRepository for tenants + tenant_org_mappings
tests/test_admin_users.py                     # router + service tests
tests/test_admin_tenants.py                   # router + service tests

apps/admin-app/src/pages/Users.tsx            # frontend Users page
apps/admin-app/src/pages/Tenants.tsx          # frontend Tenants page
apps/admin-app/src/services/adminUsersApi.ts  # @tanstack/react-query hooks
apps/admin-app/src/services/adminTenantsApi.ts # @tanstack/react-query hooks
apps/admin-app/src/pages/Users.test.tsx       # vitest
apps/admin-app/src/pages/Tenants.test.tsx     # vitest

Modified

app/models/event.py                # add 7 new EventType values + ADMIN_SUPER_ADMIN_FORCE_USED
app/models/role.py                 # add 5 new permissions: user:read, user:manage, tenant:read,
                                   #   org_mapping:manage, admin:force; new super_admin RoleCode
alembic/versions/<new>_admin_super_admin_role_and_perms.py
                                   # 1. CREATE super_admin role row
                                   # 2. extend platform_admin.permissions JSONB (4 new perms, NOT admin:force)
                                   # 3. seed user_roles row: SD's user → super_admin on tenant-curaway-admin
config/feature_flags.yaml          # add admin_user_management_enabled, admin_tenant_management_enabled
config/clerk_role_mapping.yaml     # change "admin:admin": "platform_admin" → "super_admin"
                                   # (per resolved Q6; "admin:member" stays null)
docs/api/error-codes.md            # add RBAC_LAST_ADMIN_GUARD, TENANT_NOT_FOUND, TENANT_INACTIVE,
                                   # TENANT_PROTECTED_ADMIN, TENANT_HAS_ACTIVE_CASES
apps/admin-app/src/components/AdminLayout.tsx  # add /users + /tenants nav items
apps/admin-app/src/App.tsx         # routes
mkdocs.yml                         # nav entry for this spec

Deliberately untouched

  • app/middleware/rbac_middleware.py — already handles permission resolution
  • app/services/feature_flags.py — flags consumed via existing helper
  • app/routers/admin_portal.py — keep POST /providers/{id}/tenant and POST /tenants/{id}/invite as-is; new admin_tenants.py is sibling, not replacement

API Endpoints

Users page

Method Path Permission Notes
GET /api/v1/admin/users user:read Search by Clerk user_id, email, name. Filter by tenant, role, active. Paginated.
GET /api/v1/admin/users/{user_id} user:read Detail: identity (resolved from Clerk), all roles across tenants, last sign-in, audit trail (last 10 actions).
GET /api/v1/admin/users/{user_id}/roles user:read List all user_roles rows for the user (active + inactive).
POST /api/v1/admin/users/{user_id}/roles user:manage Body: { tenant_id, role_code, granted_by_note? }. Idempotent (ON CONFLICT DO NOTHING + reactivate inactive row). Returns 200 with the row.
DELETE /api/v1/admin/users/{user_id}/roles/{role_id} user:manage Soft revoke: is_active=false. Triggers last-admin guard.
POST /api/v1/admin/users/by-email/lookup user:read Body: { email }. Calls Clerk to resolve email → user_id. Used by "Grant role" form.

Tenants page

Method Path Permission Notes
GET /api/v1/admin/tenants tenant:read Paginated. Includes member count, active case count (computed).
GET /api/v1/admin/tenants/{tenant_id} tenant:read Detail: settings, members, recent audit.
POST /api/v1/admin/tenants tenant:manage Body: { id, name, slug, contact_email, country_code, type? }. type{coordinator, mso, admin, facilitator} for non-provider tenants. Provider tenants use POST /api/v1/admin/providers/{provider_id}/tenant (existing) instead — that endpoint creates a Clerk org atomically. Idempotent on id. Returns 201.
PATCH /api/v1/admin/tenants/{tenant_id} tenant:manage Body: any of { name, slug, contact_email, country_code, is_active }.
GET /api/v1/admin/tenants/{tenant_id}/members tenant:read All user_roles rows for the tenant + resolved Clerk identities.

Org-mapping management (sibling under Tenants page)

Method Path Permission Notes
GET /api/v1/admin/org-mappings tenant:read All tenant_org_mappings rows. Filter by tenant_id, environment, org_role.
POST /api/v1/admin/org-mappings org_mapping:manage Body: { clerk_org_id, tenant_id, org_role, environment }. Returns 201.
DELETE /api/v1/admin/org-mappings/{id} org_mapping:manage Hard delete (mapping is config, not patient data; safe).

Permission Model

Add 5 new permissions:

user:read           # search + view users + their roles
user:manage         # grant/revoke roles
tenant:read         # search + view tenants + members
org_mapping:manage  # CRUD tenant_org_mappings rows
admin:force         # bypass last-admin guard, tenant-curaway-admin lock,
                    # active-cases tenant-deactivate guard
                    # super_admin only — never granted to platform_admin

tenant:manage already exists from admin_portal.py.

Add new super_admin role (per decision 11) with the full superset:

super_admin role permissions:
  user:read, user:manage,
  tenant:read, tenant:manage,
  org_mapping:manage,
  admin:force,
  feature_flag:manage,
  rbac:manage,
  audit:read
  (every permission in platform_admin + admin:force)

platform_admin role permissions are extended with: user:read, user:manage, tenant:read, org_mapping:manage. NOT extended with admin:force — that stays exclusive to super_admin.

Seed migration: SD's user (user_3BQwUurqvqB2IHj6pXUuvbeIDPl) gets the new super_admin role on tenant-curaway-admin with granted_by='system:manual_grant'. This bootstraps the role before JIT can apply it (avoids chicken-and-egg where JIT looks up super_admin in the roles table on the first request). SD already has platform_admin on tenant-curaway-admin from Session 80; the seed adds super_admin alongside (both active, additive — RBAC unions permissions across roles).

After this migration deploys + #468 JIT path is reached on next admin.curaway.ai sign-in, the JIT cycle sees the super_admin row already exists with granted_by='system:manual_grant' and no-ops per spec Q4 ("manual rows preserved"). Subsequent admins added to Curaway Admin Clerk org will get a system:clerk_jwt super_admin row on first sign-in (per resolved Q6).

UX (key screens)

Users page

┌── /users ────────────────────────────────────────────────────────────┐
│ Search [_____________________________]  Filter: tenant ▼  role ▼ □active │
│                                                                       │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Avatar  Name              Email                    Roles    Last │ │
│ │  [SD]   Srikanth Donthi   srikanth.donthi@gm…     3 roles  2m   │ │
│ │  [P1]   Provider 1        curawaydemo+prov…       1 role   3d   │ │
│ │  [C1]   Coordinator 1     curawaydemo+coor…       1 role   1d   │ │
│ │  ...                                                              │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│                                              [< prev] [next >]        │
└──────────────────────────────────────────────────────────────────────┘

Click a row → drawer with: identity card, roles table (per tenant), grant button, revoke button (per row), audit history.

Grant Role modal

┌── Grant Role ────────────────────────────┐
│ User:   Srikanth Donthi                  │
│ Tenant: [tenant-curaway-ops    ▼]        │
│ Role:   [coordinator           ▼]        │
│ Note:   [_____________________]          │
│                                          │
│         [Cancel]   [Grant Role]          │
└──────────────────────────────────────────┘

If user-tenant-role triple already exists active → 200 (idempotent). If inactive → reactivate. If new → insert.

Tenants page

┌── /tenants ──────────────────────────────────────────────────────────┐
│ [+ Create Tenant]   Search [____________]  Filter: ☑ active           │
│                                                                       │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ID                       Name             Members  Cases  Active  │ │
│ │ tenant-apollo-001        Apollo Hospital  2        12     ●       │ │
│ │ tenant-curaway-ops       Curaway Ops      2        —      ●       │ │
│ │ tenant-mso-panel         MSO Panel        2        —      ●       │ │
│ │ tenant-curaway-admin     Curaway Admin    2        —      ●       │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘

Click a row → tabs: Settings (edit form), Members (table from /admin/tenants/{id}/members), Org Mappings (table from /admin/org-mappings?tenant_id=... + add/delete form), Activity (recent audit).

Edge Cases & Error Handling

(Non-negotiable per feedback_edge_cases_in_specs.md)

Users — grant/revoke

Edge case Behaviour
Grant a role that doesn't exist (typo in role_code) 422 RBAC_INVALID_ROLE
Grant a role to a non-existent user (Clerk user_id has no membership in any Curaway org) 200 (we still create the row; user gets perms on next sign-in if they ever join the tenant). Log warning.
Tenant doesn't exist or is inactive 422 TENANT_NOT_FOUND / TENANT_INACTIVE
Granting yourself a role Allowed; emit ADMIN_SELF_MUTATION. UI shows confirm modal.
Revoking your own last platform_admin 409 RBAC_LAST_ADMIN_GUARD. Override via ?force=true requires super_admin.
Revoking the last <role>_admin for any tenant Same: 409, super_admin override.
Race: two admins grant the same triple simultaneously DB unique constraint catches; second one returns 200 with the existing row (no duplicate).
Race: admin grants role while target user is mid-request New permission applies on next request after set_active JIT cycle (5-min Redis cache + JIT delta).
Clerk user deleted but user_roles rows exist Roles persist (tenant data integrity). Webhook user.deleted should soft-deactivate (separate work). For now: list shows (deleted in Clerk) badge; revoke remains usable.
Revoke a row that's already inactive 200 idempotent (no-op).

Tenants — create/update/deactivate

Edge case Behaviour
Create with duplicate id or slug 409 TENANT_DUPLICATE (unique constraint).
Deactivate a tenant with active cases 409 TENANT_HAS_ACTIVE_CASES (count > 0). Override via ?force=true requires super_admin + emits CRITICAL alert.
Deactivate a tenant referenced in tenant_org_mappings Allowed; mappings stay (orgs become orphan; user can't sign in to portal). UI shows warning before submit.
Edit slug (URL-relevant) Allowed; emits warning event because frontend URLs may break. UI shows confirmation.
Deactivate tenant-curaway-admin (the admin tenant) 409 unconditionally — would lock all admins out. No override.

Org mappings

Edge case Behaviour
Create mapping for a Clerk org_id that doesn't exist in Clerk Allowed (forward-compat for new orgs being staged). Surfaces 401 from JIT later if user attempts sign-in before org exists.
Create duplicate mapping (same clerk_org_id) 409 unique constraint.
Delete a mapping that has active user_roles rows from system:clerk_jwt Allowed; rows persist. Next sign-in falls through to fallback dict, then unknown-org alert. UI shows warning.
org_role value mismatches Clerk's publicMetadata.portal_type Mapping write succeeds (DB is source of truth); UI surfaces a "Clerk metadata drift" warning. Separate "fix Clerk" button calls Clerk Backend API.

Cross-cutting failures

Edge case Behaviour
Clerk Backend API down (email lookup, identity hydration) List endpoints degrade to "user_id only" (no email/name). Detail endpoint shows partial data + warning banner. Grant/revoke unaffected (no Clerk dependency).
audit_logs write fails after a successful state change Caller-level transaction: if audit emit fails, the whole operation rolls back (audit is mandatory per ADR-0018 governance). Returns 500 AUDIT_WRITE_FAILED.
Admin user revoked mid-session (their own user:manage removed) Next request returns 403; UI redirects to landing with "Your access changed" toast.
Pagination cursor expired or invalid 400 PAGINATION_INVALID_CURSOR. UI resets to page 1.
Search query returns >10K matches (DoS via prefix search) Backend caps total at 10,000 (set via MAX_SEARCH_RESULTS constant) + flips truncated: true in the response body and sets X-Result-Truncated: true header. UI hints to refine search. (Implementation chose 10K over the original draft of 1K — 1K was too aggressive at admin scale; 10K covers all expected workloads while still stopping accidental DoS via empty-substring prefix.)

Testing Strategy

Layer 1 — Unit tests (services + repositories)

tests/test_admin_users.py: - service: grant_role with valid input → row inserted + Event emitted - service: grant_role with invalid role_code → ValueError - service: grant_role with last-admin guard → raises LastAdminError - service: revoke_role idempotent (already inactive) → no DB write - service: lookup_by_email → calls Clerk + caches result

tests/test_admin_tenants.py: - service: create_tenant idempotent - service: deactivate guards (active cases, admin tenant) - service: org_mapping CRUD

Target: ≥90% line coverage on the two new services.

Layer 2 — Router integration (HTTP-level)

For each new endpoint: - 401 without JWT - 403 with JWT but missing permission (per @require_permission) - 200 happy path - 4xx for each documented error code - Audit event emitted (assert via events table query)

Layer 3 — Frontend component tests (Vitest)

For Users.tsx + Tenants.tsx: - Renders list from mocked API - Search debounce works - Pagination next/prev works - Grant role flow: select user → modal → submit → list refreshes - Confirm modals appear for self-mutation and tenant deactivation - Last-admin guard 409 surfaces error inline

Layer 4 — Manual smoke (post-deploy)

  • [ ] Sign in to admin.curaway.ai as SD; navigate to Users; search "donthi"; verify your own row + roles
  • [ ] Grant a fresh role to a test user; verify row in DB + audit event
  • [ ] Revoke that role; verify is_active=false + audit event
  • [ ] Try to revoke your own platform_admin → 409 surfaces correctly
  • [ ] Create a test tenant tenant-spec-test-001; deactivate; verify soft-only
  • [ ] Add an org mapping; delete it; verify hard delete

Rollout

  1. Land seed migration for new permissions (separate PR, low-risk DB-only change)
  2. Land backend (router + service + repository + tests) behind two flags:
  3. admin_user_management_enabled (default false)
  4. admin_tenant_management_enabled (default false)
  5. Land frontend (Users + Tenants pages, gated by Flagsmith flag check)
  6. Manual smoke on admin.curaway.ai with flags ON for SD only (Flagsmith identity override)
  7. Flip both flags to 100% globally
  8. Schedule follow-up to delete the flags in 1 week (per Curaway flag hygiene)

Definition of Done (Tier 3 — applicable sections)

Always

  • [ ] Build passes (ruff + mypy + frontend tsc + vite build)
  • [ ] All tests pass (backend + frontend)
  • [ ] Code review (subagent or peer)
  • [ ] GitHub issue linked (#50)
  • [ ] Feature branch (no direct main)
  • [ ] Verified on Vercel preview + Railway staging deploy
  • [ ] Audit log emit verified post-deploy (manual smoke step)

Code quality

  • [ ] No file > 500 lines
  • [ ] No PII in logs
  • [ ] No hardcoded secrets

Testing

  • [ ] ≥90% line coverage on admin_user_service.py + admin_tenant_service.py
  • [ ] All edge cases above have a test
  • [ ] Manual smoke checklist completed on staging

Database

  • [ ] Alembic migration for permission seeds (reversible)
  • [ ] No new tables; uses existing tenants, user_roles, tenant_org_mappings, events, audit_logs

Feature flags

  • [ ] Two new flags in config/feature_flags.yaml + Flagsmith
  • [ ] Graceful 503 when off (frontend hides nav items)

Security / Compliance

  • [ ] No JWT in URL strings
  • [ ] All cross-tenant queries via BaseUnscopedRepository
  • [ ] Every write emits audit event (mandatory)

Documentation

  • [ ] API changelog (docs/CHANGELOG.md)
  • [ ] Swagger docstrings on all new routes
  • [ ] Error codes added to docs/api/error-codes.md
  • [ ] mkdocs nav updated to include this spec

Deployment

  • [ ] Two new feature flags registered in Flagsmith
  • [ ] Rollback plan: flip flags off → frontend hides + backend returns 503

Resolved questions (2026-04-29)

  1. super_admin role created in this spec — see decisions 11, 13. Adds admin:force permission. SD seeded as super_admin on tenant-curaway-admin.
  2. Soft revoke on user_rolesis_active=false, never DELETE. Confirmed.
  3. tenant-curaway-admin displayed but mutation-locked — see decision 13. 🔒 badge in UI; super_admin + ?force=true override.
  4. Tenant-create form unified — see decision 12. Single UI with type selector branching to POST /providers/{id}/tenant or POST /tenants.
  5. All admin endpoints under /api/v1/admin/* — confirmed. See decision 14.

Resolved (2026-04-29 follow-up)

  1. Clerk JIT for super_adminadmin:admin Clerk org role JIT-grants super_admin (not platform_admin). Rationale: SD overrode the conservative recommendation. Curaway Admin Clerk org membership is tightly controlled (currently 1 user — SD); convenience of automatic emergency-ops access outweighs the marginal security gain of manual-grant-only. Trust boundary: whoever is added to Curaway Admin org IS effectively super_admin.

Implementation impact: - config/clerk_role_mapping.yaml updates: "admin:admin": "super_admin" (was "platform_admin") - "admin:member": null stays null (no auto-grant for org members; they need a manual grant) - Seed migration still grants SD super_admin explicitly so the role exists before next sign-in (avoids a chicken-and-egg where JIT can't fire because the role row doesn't exist in roles table yet) - When SD next signs in to admin.curaway.ai, JIT confirms the existing super_admin row (no-op per #468 reconcile-already-in-sync path) - Future Curaway Admin org members get super_admin automatically on first sign-in