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}/tenantalready) - 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)¶
-
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. -
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 havetenant:manageoruser:managepermission, enforced by@require_permission. -
Soft-delete only on tenants. Per #50: deactivate/reactivate, never
DELETE.tenants.is_activeflag toggles. Existing data preserved. GDPR Article 17 cascades remain a separate, intentional flow (ADR-0019). -
Granular permissions:
user:manageandtenant:manage. Both currently rolled intoplatform_adminrole. Spec proposes splitting:user:managegrants are required for /admin/users/ writes;tenant:managefor /admin/tenants/. Read endpoints useuser:readandtenant:read(granted toplatform_adminand any futuresupport_agentrole). -
Audit log emit on every write. New
EventTypevalues: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. -
Clerk integration is read-mostly. Admin portal does NOT create Clerk orgs (those come from Clerk org-creation flow and trigger webhook →
tenant_org_mappingsrow). 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. -
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.
-
No bulk operations in v1. Per-row only. Bulk grant/revoke/deactivate is Phase 2.
-
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 anADMIN_SELF_MUTATIONevent in addition to the standard audit event. -
Last-admin guard. Revoking the last active
platform_admin(or lasttenant_adminfor any tenant) returns HTTP 409 with codeRBAC_LAST_ADMIN_GUARD. Override requires explicit?force=truequery param AND the caller must hold theadmin:forcepermission (granted only tosuper_adminrole; see decision 11). -
super_adminrole created in this spec. New rolesuper_adminis seeded alongside the new permissions. It carries every permissionplatform_adminhas plusadmin:force. SD's user gets the role on the seed migration. Used for: last-admin guard override,tenant-curaway-adminmutation override (display-only for everyone else), tenant deactivation with active cases override, future emergency operations. Distinct fromplatform_adminso day-to-day platform admins can't accidentally trigger force operations. -
Tenant-create form unified. Single "Create tenant" UI with a
typeselector (provider|coordinator|mso|admin|facilitator). Backend branches under the hood:type=provider→ existingPOST /api/v1/admin/providers/{provider_id}/tenant(creates Clerk org + tenant atomically perProviderOnboardingService)type=coordinator|mso|admin|facilitator→ newPOST /api/v1/admin/tenants(creates tenant row only; Clerk org separately if needed) Frontend shows the right fields per type. The currentPOST /providers/{id}/tenantis preserved as-is — additive change only.
-
tenant-curaway-adminmutation lock. Listed in Tenants page but all mutations (deactivate, edit slug, delete org mapping that targets it) return HTTP 409 with codeTENANT_PROTECTED_ADMIN.super_adminwith?force=truecan override. UI shows a 🔒 badge on the row. -
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 resolutionapp/services/feature_flags.py— flags consumed via existing helperapp/routers/admin_portal.py— keepPOST /providers/{id}/tenantandPOST /tenants/{id}/inviteas-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¶
- Land seed migration for new permissions (separate PR, low-risk DB-only change)
- Land backend (router + service + repository + tests) behind two flags:
admin_user_management_enabled(default false)admin_tenant_management_enabled(default false)- Land frontend (Users + Tenants pages, gated by Flagsmith flag check)
- Manual smoke on admin.curaway.ai with flags ON for SD only (Flagsmith identity override)
- Flip both flags to 100% globally
- 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)¶
super_adminrole created in this spec — see decisions 11, 13. Addsadmin:forcepermission. SD seeded assuper_adminontenant-curaway-admin.- Soft revoke on
user_roles—is_active=false, neverDELETE. Confirmed. tenant-curaway-admindisplayed but mutation-locked — see decision 13. 🔒 badge in UI; super_admin +?force=trueoverride.- Tenant-create form unified — see decision 12. Single UI with
typeselector branching toPOST /providers/{id}/tenantorPOST /tenants. - All admin endpoints under
/api/v1/admin/*— confirmed. See decision 14.
Resolved (2026-04-29 follow-up)¶
- Clerk JIT for super_admin —
admin:adminClerk org role JIT-grantssuper_admin(notplatform_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