--- phase: 04-rbac verified: 2026-03-24T23:22:44Z status: passed score: 18/18 must-haves verified re_verification: false --- # Phase 4: RBAC Verification Report **Phase Goal:** Three-tier role-based access control — platform admins manage the SaaS, customer admins manage their tenant, customer operators get read-only access — with email invitation flow for onboarding tenant users **Verified:** 2026-03-24T23:22:44Z **Status:** PASSED **Re-verification:** No — initial verification --- ## Goal Achievement ### Observable Truths Plan 01 truths: | # | Truth | Status | Evidence | |---|-------|--------|----------| | 1 | Platform admin caller gets 200 on any tenant endpoint; non-admin gets 403 | VERIFIED | `rbac.py` raises `HTTP_403_FORBIDDEN` for non-`platform_admin`; 11 unit tests pass | | 2 | Customer admin gets 200 on their own tenant endpoints; gets 403 on other tenants | VERIFIED | `require_tenant_admin` checks `UserTenantRole` membership; `test_tenant_admin_own_tenant` / `test_tenant_admin_no_membership` pass | | 3 | Customer operator gets 403 on mutating endpoints; gets 200 on read-only endpoints | VERIFIED | `require_tenant_admin` always rejects operator; `require_tenant_member` allows operator on GET paths | | 4 | Invite token with valid HMAC and unexpired timestamp validates successfully | VERIFIED | `test_token_roundtrip` passes; `hmac.compare_digest` + 48h TTL enforced in `invite_token.py` | | 5 | Invite token with tampered signature or expired timestamp raises ValueError | VERIFIED | `test_token_tamper_rejected` and `test_token_expired_rejected` pass | | 6 | Auth verify response returns role + tenant_ids instead of is_admin | VERIFIED | `AuthVerifyResponse` has `role`, `tenant_ids`, `active_tenant_id`; `is_admin` absent from `PortalUser` model | Plan 02 truths: | # | Truth | Status | Evidence | |---|-------|--------|----------| | 7 | JWT token contains role, tenant_ids, and active_tenant_id after login | VERIFIED | `auth.ts` jwt callback sets `token.role`, `token.tenant_ids`, `token.active_tenant_id` (lines 72-74) | | 8 | Customer operator navigating to /billing is silently redirected to /agents | VERIFIED | `proxy.ts` line 57: `if (role === "customer_operator")` redirects restricted paths to `/agents` | | 9 | Customer operator does not see Billing, API Keys, or User Management in sidebar | VERIFIED | `nav.tsx` filters `navItems` by `allowedRoles` array via `useSession()` | | 10 | Customer admin sees tenant dashboard after login | VERIFIED | `proxy.ts` landing page logic: `customer_admin` routes to `/dashboard` | | 11 | Platform admin sees platform overview with tenant picker after login | VERIFIED | `proxy.ts` `case "platform_admin"` routes to `/dashboard`; `TenantSwitcher` rendered in layout | | 12 | Multi-tenant user can switch active tenant without logging out | VERIFIED | `tenant-switcher.tsx` calls `update({ active_tenant_id: newTenantId })` (line 71); `auth.ts` jwt callback handles `trigger === "update"` | | 13 | Impersonation shows a visible banner with exit button | VERIFIED | `impersonation-banner.tsx` 49 lines, amber banner with exit button; integrated in `layout.tsx` | | 14 | Invite acceptance page accepts token, lets user set password, creates account | VERIFIED | `app/invite/[token]/page.tsx` (172 lines) outside `(dashboard)` group; POSTs to `/api/portal/invitations/accept` | | 15 | User management page lists users for tenant with invite button | VERIFIED | `app/(dashboard)/users/page.tsx` (411 lines) with invite dialog and resend capability | | 16 | Platform admin global user page shows all users across all tenants | VERIFIED | `app/(dashboard)/admin/users/page.tsx` (462 lines) with tenant/role filters | Plan 03 truths: | # | Truth | Status | Evidence | |---|-------|--------|----------| | 17 | Every mutating portal API endpoint returns 403 for customer_operator | VERIFIED | 16 `Depends(require_tenant_admin)` / `Depends(require_platform_admin)` guards across `portal.py`, `billing.py`, `channels.py`, `llm_keys.py` | | 18 | Customer operator gets 200 on POST agent test-message endpoint | VERIFIED | `POST /tenants/{tid}/agents/{aid}/test` uses `Depends(require_tenant_member)` (line 622 `portal.py`) | **Score:** 18/18 truths verified --- ## Required Artifacts ### Plan 01 Artifacts | Artifact | Status | Details | |----------|--------|---------| | `migrations/versions/006_rbac_roles.py` | VERIFIED | 220 lines; adds `user_tenant_roles`, `portal_invitations`, drops `is_admin` | | `packages/shared/shared/api/rbac.py` | VERIFIED | 5473 bytes; exports `PortalCaller`, `get_portal_caller`, `require_platform_admin`, `require_tenant_admin`, `require_tenant_member` | | `packages/shared/shared/api/invitations.py` | VERIFIED | 11844 bytes; exports `invitations_router` with create/accept/resend/list endpoints | | `packages/shared/shared/invite_token.py` | VERIFIED | 2745 bytes; exports `generate_invite_token`, `validate_invite_token`, `token_to_hash` | | `packages/shared/shared/email.py` | VERIFIED | 3479 bytes; exports `send_invite_email` | | `tests/unit/test_rbac_guards.py` | VERIFIED | 188 lines (min 50); 11 tests all passing | | `tests/unit/test_invitations.py` | VERIFIED | 368 lines (min 40); 11 tests all passing | | `tests/unit/test_portal_auth.py` | VERIFIED | 279 lines (min 30); 7 tests all passing; **27 total unit tests pass** | ### Plan 02 Artifacts | Artifact | Status | Details | |----------|--------|---------| | `packages/portal/lib/auth-types.ts` | VERIFIED | 38 lines; `declare module "next-auth"` augmentation present | | `packages/portal/lib/auth.ts` | VERIFIED | 3473 bytes; `token.role` set in jwt callback | | `packages/portal/proxy.ts` | VERIFIED | 3532 bytes; `customer_operator` handling present | | `packages/portal/components/nav.tsx` | VERIFIED | 3656 bytes; `useSession` imported and used for role filtering | | `packages/portal/components/tenant-switcher.tsx` | VERIFIED | 96 lines (min 30); `update.*active_tenant_id` present | | `packages/portal/components/impersonation-banner.tsx` | VERIFIED | 49 lines (min 15); amber banner with exit | | `packages/portal/app/invite/[token]/page.tsx` | VERIFIED | 172 lines (min 40); outside `(dashboard)` group — no auth required | | `packages/portal/app/(dashboard)/users/page.tsx` | VERIFIED | 411 lines (min 40); invite dialog + resend | | `packages/portal/app/(dashboard)/admin/users/page.tsx` | VERIFIED | 462 lines (min 40); cross-tenant filters | ### Plan 03 Artifacts | Artifact | Status | Details | |----------|--------|---------| | `tests/integration/test_portal_rbac.py` | VERIFIED | 949 lines (min 80); `X-Portal-User-Role` headers used throughout | | `tests/integration/test_invite_flow.py` | VERIFIED | 484 lines (min 40) | --- ## Key Link Verification ### Plan 01 Key Links | From | To | Via | Status | Evidence | |------|----|-----|--------|---------| | `packages/shared/shared/api/rbac.py` | `packages/shared/shared/models/auth.py` | imports `UserTenantRole` | WIRED | Line 32: `from shared.models.auth import UserTenantRole` | | `packages/shared/shared/api/invitations.py` | `packages/shared/shared/invite_token.py` | generates and validates HMAC tokens | WIRED | Line 40: `from shared.invite_token import generate_invite_token, token_to_hash, validate_invite_token` | | `packages/shared/shared/api/portal.py` | `packages/shared/shared/models/auth.py` | auth/verify returns role + tenant_ids | WIRED | Lines 48, 284-303: `tenant_ids` resolved and returned in response | ### Plan 02 Key Links | From | To | Via | Status | Evidence | |------|----|-----|--------|---------| | `packages/portal/lib/auth.ts` | `/api/portal/auth/verify` | fetch in authorize(), receives role + tenant_ids | WIRED | Lines 55-56, 72-74: `token.role`, `token.tenant_ids` set from response | | `packages/portal/proxy.ts` | `packages/portal/lib/auth.ts` | reads session.user.role for redirect logic | WIRED | Line 47: `const role = (session.user as { role?: string }).role` | | `packages/portal/components/nav.tsx` | `next-auth/react` | useSession() to read role for nav filtering | WIRED | Line 13 import, line 90 use: `const { data: session } = useSession()` | | `packages/portal/components/tenant-switcher.tsx` | `next-auth/react` | update() to change active_tenant_id in JWT | WIRED | Line 71: `await update({ active_tenant_id: newTenantId })` | ### Plan 03 Key Links | From | To | Via | Status | Evidence | |------|----|-----|--------|---------| | `packages/shared/shared/api/portal.py` | `packages/shared/shared/api/rbac.py` | Depends(require_*) on all endpoints | WIRED | 16 `Depends(require_*)` declarations across portal endpoints | | `packages/shared/shared/api/billing.py` | `packages/shared/shared/api/rbac.py` | Depends(require_tenant_admin) on billing endpoints | WIRED | Lines 209, 259 | | `tests/integration/test_portal_rbac.py` | `packages/shared/shared/api/rbac.py` | Tests pass role headers and assert 403/200 | WIRED | Lines 68, 76, 85: `X-Portal-User-Role` header set per role | --- ## Requirements Coverage | Requirement | Description | Plans | Status | Evidence | |-------------|-------------|-------|--------|---------| | RBAC-01 | Platform admin role with full access to all tenants, agents, users, and platform settings | 01, 02, 03 | SATISFIED | `require_platform_admin` guards; platform_admin bypasses all tenant checks; 16 guarded endpoints | | RBAC-02 | Customer admin role scoped to a single tenant with full control over agents, channels, billing, API keys, and user management | 01, 03 | SATISFIED | `require_tenant_admin` on all mutating tenant endpoints; cross-tenant 403 enforced | | RBAC-03 | Customer operator role scoped to a single tenant with read-only access to agents, conversations, and usage dashboards | 01, 03 | SATISFIED | `require_tenant_member` on GET endpoints; `require_tenant_admin` blocks operator on mutations; operator CAN send test messages | | RBAC-04 | Customer admin can invite users by email — invitee receives activation link to set password | 01, 02, 03 | SATISFIED | Full invitation system: HMAC tokens, SMTP email, `invitations_router`, invite acceptance page at `/invite/[token]` | | RBAC-05 | Portal navigation, pages, and UI elements adapt based on user role | 02 | SATISFIED | Role-filtered nav, proxy redirects, impersonation banner, tenant switcher — all present and wired | | RBAC-06 | API endpoints enforce role-based authorization — unauthorized actions return 403 | 01, 03 | SATISFIED | FastAPI `Depends()` guards on all 17+ endpoints; integration tests cover full role matrix | All 6 requirements satisfied. No orphaned requirements. --- ## Anti-Patterns Found | File | Line | Pattern | Severity | Impact | |------|------|---------|----------|--------| | `packages/shared/shared/api/portal.py` | ~630 | Agent test-message returns stub echo response | Info | Documented decision: full orchestrator wiring deferred; endpoint has correct RBAC, stub response only | No blocker or warning-level anti-patterns found. The test-message stub is a documented, intentional deferral — RBAC enforcement (the goal of this phase) is correct. --- ## Human Verification Required The following items cannot be verified programmatically. All automated checks passed; these items require a running environment. ### 1. End-to-end Invitation Flow in Browser **Test:** Start dev environment, create invitation as customer_admin, open invite URL in incognito, set password, log in as new user **Expected:** Account created with correct role and tenant membership; JWT claims match; login succeeds **Why human:** Full HTTP + DB + email path requires live services ### 2. Operator Path Redirect in Browser **Test:** Log in as customer_operator, navigate to `/billing` directly **Expected:** Silently redirected to `/agents` with no error page shown **Why human:** Proxy behavior requires running Next.js server ### 3. Tenant Switcher Context Switch **Test:** Log in as user with multiple tenant memberships, use tenant switcher dropdown **Expected:** Active tenant changes instantly without page reload; TanStack Query refetches data for new tenant **Why human:** Requires live JWT update flow and visible UI state change ### 4. Impersonation Banner Display **Test:** Log in as platform_admin, impersonate a tenant via `/admin/impersonate` **Expected:** Amber banner appears at top of viewport showing tenant name with visible "Exit" button; banner disappears after exit **Why human:** Visual UI element, requires live session with `impersonating_tenant_id` JWT claim ### 5. Integration Tests Against Live DB **Test:** `uv run pytest tests/integration/test_portal_rbac.py tests/integration/test_invite_flow.py -x -v` **Expected:** All 56 integration tests pass (currently skipped in CI due to no DB) **Why human:** Requires PostgreSQL with migration 006 applied --- ## Commit Verification | Commit | Description | Verified | |--------|-------------|---------| | `f710c9c` | feat(04-rbac-01): DB migration 006 + RBAC ORM models + config fields | Present in main repo | | `d59f85c` | feat(04-rbac-01): RBAC guards + invite token + email + invitation API | Present in main repo | | `7b0594e` | test(04-rbac-01): unit tests for RBAC guards, invitation system, portal auth | Present in main repo | | `43b73aa` | feat(04-rbac-03): wire RBAC guards to all portal API endpoints + new endpoints | Present in main repo | | `9515c53` | test(04-rbac-03): add failing integration tests for RBAC enforcement and invite flow | Present in main repo | | `fcb1166` | feat(04-rbac-02): Auth.js JWT update, role-filtered nav, tenant switcher, impersonation banner | Present in portal submodule | | `744cf79` | feat(04-rbac-02): invite acceptance page, per-tenant users page, platform admin users page | Present in portal submodule | Note: Portal package (`packages/portal`) is a git submodule. Plan 02 commits exist in the submodule's history. The submodule has uncommitted working tree changes (portal listed as `modified` in parent repo status). --- ## Summary Phase 4 goal is achieved. All three tiers of role-based access control exist and are wired: - **DB layer:** Migration 006 adds `role` column to `portal_users` (TEXT+CHECK), `user_tenant_roles` table, and `portal_invitations` table. `is_admin` is gone. - **Backend guards:** `require_platform_admin`, `require_tenant_admin`, `require_tenant_member` implemented with real 403 enforcement and platform_admin bypass logic. Guards wired to all 17+ API endpoints across 5 routers. - **Invitation system:** HMAC-SHA256 tokens with 48h TTL, token hash stored (never raw token), SMTP email utility, full CRUD API (create/accept/resend/list), Celery task for async email dispatch. - **Portal JWT:** Auth.js carries `role`, `tenant_ids`, `active_tenant_id` replacing `is_admin`. Tenant switcher updates JWT mid-session via `trigger: "update"`. - **Portal routing:** Proxy silently redirects `customer_operator` from restricted paths. `/invite/[token]` is public (outside dashboard group). - **Portal UI:** Nav hides items by role. Impersonation banner is present. User management pages exist for both tenant and platform scope. - **Tests:** 27 unit tests pass. 56 integration tests written (require live DB to run — documented in summaries). The one notable deferred item (test-message endpoint returns echo stub pending orchestrator integration) is a documented decision and does not block the RBAC goal. --- _Verified: 2026-03-24T23:22:44Z_ _Verifier: Claude (gsd-verifier)_