diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index a53345e..739b887 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -15,6 +15,7 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 1: Foundation** - Secure multi-tenant pipeline with Slack end-to-end and basic agent response (completed 2026-03-23)
- [x] **Phase 2: Agent Features** - Persistent memory, tool framework, WhatsApp integration, and human escalation (gap closure in progress) (completed 2026-03-24)
- [x] **Phase 3: Operator Experience** - Admin portal, tenant onboarding, and Stripe billing (gap closure in progress)
+- [ ] **Phase 4: RBAC** - Three-tier role-based access control with email invitation flow
## Phase Details
@@ -75,23 +76,6 @@ Plans:
- [ ] 03-04-PLAN.md — Cost tracking dashboard with Recharts charts, budget alert badges, time range filtering
- [x] 03-05-PLAN.md — Gap closure: mount Phase 3 API routers on gateway, fix Slack OAuth and budget alert field name mismatches (completed 2026-03-24)
-## Progress
-
-**Execution Order:**
-Phases execute in numeric order: 1 -> 2 -> 3
-
-| Phase | Plans Complete | Status | Completed |
-|-------|----------------|--------|-----------|
-| 1. Foundation | 4/4 | Complete | 2026-03-23 |
-| 2. Agent Features | 6/6 | Complete | 2026-03-24 |
-| 3. Operator Experience | 5/5 | Complete | 2026-03-24 |
-
----
-
-## Coverage Notes
-
-**LLM-03 conflict resolved:** BYO API keys confirmed in v1 scope per user decision during Phase 3 context gathering. Implemented via Fernet encryption in Phase 3.
-
### Phase 4: RBAC
**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
**Depends on**: Phase 3
@@ -102,11 +86,31 @@ Phases execute in numeric order: 1 -> 2 -> 3
3. A customer operator can view agents and usage dashboards but cannot create, edit, or delete anything
4. A customer admin can invite a new user (admin or operator) by email — the invitee receives a link, clicks to activate, and sets their password
5. Portal navigation and API endpoints enforce role-based access — unauthorized actions return 403, not just hidden UI elements
-**Plans**: 0 plans
+**Plans**: 3 plans
Plans:
-- [ ] TBD (run /gsd:plan-phase 4 to break down)
+- [ ] 04-01-PLAN.md — Backend RBAC foundation: DB migration (is_admin -> role enum), ORM models (UserTenantRole, PortalInvitation), RBAC guard dependencies, invitation API + SMTP email, unit tests
+- [ ] 04-02-PLAN.md — Portal RBAC integration: Auth.js JWT role claims, proxy role redirects, role-filtered nav, tenant switcher, impersonation banner, invite acceptance page, user management pages
+- [ ] 04-03-PLAN.md — Wire RBAC guards to all existing API endpoints, impersonation audit logging, integration tests, human verification checkpoint
+
+## Progress
+
+**Execution Order:**
+Phases execute in numeric order: 1 -> 2 -> 3 -> 4
+
+| Phase | Plans Complete | Status | Completed |
+|-------|----------------|--------|-----------|
+| 1. Foundation | 4/4 | Complete | 2026-03-23 |
+| 2. Agent Features | 6/6 | Complete | 2026-03-24 |
+| 3. Operator Experience | 5/5 | Complete | 2026-03-24 |
+| 4. RBAC | 0/3 | Planned | — |
+
+---
+
+## Coverage Notes
+
+**LLM-03 conflict resolved:** BYO API keys confirmed in v1 scope per user decision during Phase 3 context gathering. Implemented via Fernet encryption in Phase 3.
---
*Roadmap created: 2026-03-23*
-*Coverage: 25/25 v1 requirements mapped*
+*Coverage: 25/25 v1 requirements + 6 RBAC requirements mapped*
diff --git a/.planning/phases/04-rbac/04-01-PLAN.md b/.planning/phases/04-rbac/04-01-PLAN.md
new file mode 100644
index 0000000..1fc4980
--- /dev/null
+++ b/.planning/phases/04-rbac/04-01-PLAN.md
@@ -0,0 +1,343 @@
+---
+phase: 04-rbac
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - packages/shared/shared/models/auth.py
+ - packages/shared/shared/api/rbac.py
+ - packages/shared/shared/api/invitations.py
+ - packages/shared/shared/api/portal.py
+ - packages/shared/shared/config.py
+ - packages/shared/shared/invite_token.py
+ - packages/shared/shared/email.py
+ - packages/orchestrator/orchestrator/tasks.py
+ - migrations/versions/006_rbac_roles.py
+ - tests/unit/test_rbac_guards.py
+ - tests/unit/test_invitations.py
+autonomous: true
+requirements:
+ - RBAC-01
+ - RBAC-02
+ - RBAC-03
+ - RBAC-04
+ - RBAC-06
+
+must_haves:
+ truths:
+ - "Platform admin caller gets 200 on any tenant endpoint; non-admin gets 403"
+ - "Customer admin gets 200 on their own tenant endpoints; gets 403 on other tenants"
+ - "Customer operator gets 403 on mutating endpoints; gets 200 on read-only endpoints"
+ - "Invite token with valid HMAC and unexpired timestamp validates successfully"
+ - "Invite token with tampered signature or expired timestamp raises ValueError"
+ - "Auth verify response returns role + tenant_ids instead of is_admin"
+ artifacts:
+ - path: "packages/shared/shared/api/rbac.py"
+ provides: "FastAPI RBAC guard dependencies"
+ exports: ["PortalCaller", "get_portal_caller", "require_platform_admin", "require_tenant_admin", "require_tenant_member"]
+ - path: "packages/shared/shared/api/invitations.py"
+ provides: "Invitation CRUD API router"
+ exports: ["invitations_router"]
+ - path: "packages/shared/shared/invite_token.py"
+ provides: "HMAC token generation and validation"
+ exports: ["generate_invite_token", "validate_invite_token"]
+ - path: "packages/shared/shared/email.py"
+ provides: "SMTP email sender for invitations"
+ exports: ["send_invite_email"]
+ - path: "migrations/versions/006_rbac_roles.py"
+ provides: "Alembic migration adding role enum, user_tenant_roles, portal_invitations"
+ contains: "user_tenant_roles"
+ - path: "tests/unit/test_rbac_guards.py"
+ provides: "Unit tests for RBAC guard dependencies"
+ min_lines: 50
+ - path: "tests/unit/test_invitations.py"
+ provides: "Unit tests for HMAC token and invitation flow"
+ min_lines: 40
+ key_links:
+ - from: "packages/shared/shared/api/rbac.py"
+ to: "packages/shared/shared/models/auth.py"
+ via: "imports UserTenantRole for membership check"
+ pattern: "from shared\\.models\\.auth import.*UserTenantRole"
+ - from: "packages/shared/shared/api/invitations.py"
+ to: "packages/shared/shared/invite_token.py"
+ via: "generates and validates HMAC tokens"
+ pattern: "from shared\\.invite_token import"
+ - from: "packages/shared/shared/api/portal.py"
+ to: "packages/shared/shared/models/auth.py"
+ via: "auth/verify returns role + tenant_ids"
+ pattern: "role.*tenant_ids"
+---
+
+
+Backend RBAC foundation: DB schema migration, ORM models, FastAPI guard dependencies, invitation system (API + HMAC tokens + SMTP email), and auth/verify endpoint update.
+
+Purpose: All backend authorization primitives must exist before the portal can implement role-based UI or endpoint guards can be wired to existing routes.
+Output: Migration 006, RBAC models, guard dependencies, invitation API, SMTP email utility, updated auth/verify response, unit tests.
+
+
+
+@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
+@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/04-rbac/04-CONTEXT.md
+@.planning/phases/04-rbac/04-RESEARCH.md
+
+
+
+
+From packages/shared/shared/models/auth.py:
+```python
+class PortalUser(Base):
+ __tablename__ = "portal_users"
+ id: Mapped[uuid.UUID]
+ email: Mapped[str] # unique, indexed
+ hashed_password: Mapped[str]
+ name: Mapped[str]
+ is_admin: Mapped[bool] # TO BE REPLACED by role enum
+ created_at: Mapped[datetime]
+ updated_at: Mapped[datetime]
+```
+
+From packages/shared/shared/api/portal.py:
+```python
+class AuthVerifyResponse(BaseModel):
+ id: str
+ email: str
+ name: str
+ is_admin: bool # TO BE REPLACED by role + tenant_ids + active_tenant_id
+
+portal_router = APIRouter(prefix="/api/portal", tags=["portal"])
+```
+
+From packages/shared/shared/config.py:
+```python
+class Settings(BaseSettings):
+ # Auth / Security section — add invite_secret and SMTP settings here
+ auth_secret: str = Field(default="insecure-dev-secret-change-in-production")
+```
+
+From packages/shared/shared/db.py:
+```python
+async def get_session() -> AsyncGenerator[AsyncSession, None]: ...
+```
+
+From packages/shared/shared/models/tenant.py:
+```python
+class Base(DeclarativeBase): ... # All ORM models inherit from this
+class Tenant(Base):
+ __tablename__ = "tenants"
+ id: Mapped[uuid.UUID]
+ name: Mapped[str]
+```
+
+From packages/shared/shared/models/audit.py:
+```python
+class AuditEvent(Base): # Reuse for impersonation logging in Plan 03
+```
+
+
+
+
+
+
+ Task 1: DB migration + ORM models for RBAC
+
+ migrations/versions/006_rbac_roles.py,
+ packages/shared/shared/models/auth.py,
+ packages/shared/shared/config.py
+
+
+ - Migration adds role column to portal_users with CHECK constraint (platform_admin, customer_admin, customer_operator)
+ - Migration backfills: is_admin=True -> platform_admin, is_admin=False -> customer_admin
+ - Migration drops is_admin column after backfill
+ - Migration creates user_tenant_roles table with user_id FK, tenant_id FK, role TEXT, unique(user_id, tenant_id)
+ - Migration creates portal_invitations table with email, name, tenant_id FK, role, invited_by FK, token_hash (unique), status, expires_at
+ - UserTenantRole ORM model exists with proper ForeignKeys
+ - PortalInvitation ORM model exists with proper ForeignKeys
+ - UserRole str enum has PLATFORM_ADMIN, CUSTOMER_ADMIN, CUSTOMER_OPERATOR
+ - PortalUser.is_admin replaced by PortalUser.role
+ - Settings has invite_secret, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from_email fields
+
+
+ Create Alembic migration `006_rbac_roles.py` (revision 006, depends on 005):
+ 1. Add `role` column to `portal_users` as TEXT, nullable initially
+ 2. Execute UPDATE: `is_admin = TRUE -> 'platform_admin'`, else `'customer_admin'`
+ 3. ALTER to NOT NULL
+ 4. Add CHECK constraint `ck_portal_users_role CHECK (role IN ('platform_admin', 'customer_admin', 'customer_operator'))` — use TEXT+CHECK pattern per Phase 1 decision (not sa.Enum to avoid DDL issues)
+ 5. Drop `is_admin` column
+ 6. CREATE `user_tenant_roles` table: id UUID PK default gen_random_uuid(), user_id UUID FK portal_users(id) ON DELETE CASCADE, tenant_id UUID FK tenants(id) ON DELETE CASCADE, role TEXT NOT NULL, created_at TIMESTAMPTZ default now(), UNIQUE(user_id, tenant_id)
+ 7. CREATE `portal_invitations` table: id UUID PK default gen_random_uuid(), email VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, tenant_id UUID FK tenants(id) ON DELETE CASCADE, role TEXT NOT NULL, invited_by UUID FK portal_users(id), token_hash VARCHAR(255) NOT NULL UNIQUE, status VARCHAR(20) NOT NULL DEFAULT 'pending', expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ default now()
+
+ Update `packages/shared/shared/models/auth.py`:
+ - Add `UserRole(str, enum.Enum)` with PLATFORM_ADMIN, CUSTOMER_ADMIN, CUSTOMER_OPERATOR
+ - Replace `is_admin: Mapped[bool]` with `role: Mapped[str] = mapped_column(String(50), nullable=False, default="customer_admin")`
+ - Add `UserTenantRole(Base)` model with __tablename__ = "user_tenant_roles", UniqueConstraint("user_id", "tenant_id"), ForeignKey refs
+ - Add `PortalInvitation(Base)` model with __tablename__ = "portal_invitations", all fields matching migration
+
+ Update `packages/shared/shared/config.py` — add to Settings:
+ - `invite_secret: str = Field(default="insecure-invite-secret-change-in-production")`
+ - `smtp_host: str = Field(default="localhost")`
+ - `smtp_port: int = Field(default=587)`
+ - `smtp_username: str = Field(default="")`
+ - `smtp_password: str = Field(default="")`
+ - `smtp_from_email: str = Field(default="noreply@konstruct.dev")`
+ - `portal_base_url: str = Field(default="http://localhost:3000")` (for invite link URL construction, only add if portal_url doesn't already exist — check existing field name)
+
+ Note: `portal_url` already exists in Settings (used for Stripe redirects). Reuse it for invite links — do NOT add a duplicate field.
+
+
+ cd /home/adelorenzo/repos/konstruct && python -c "from shared.models.auth import PortalUser, UserTenantRole, PortalInvitation, UserRole; print('Models OK'); assert hasattr(PortalUser, 'role'); assert not hasattr(PortalUser, 'is_admin')"
+
+ Migration 006 exists with upgrade/downgrade. PortalUser has role (not is_admin). UserTenantRole and PortalInvitation models exist. Settings has invite_secret and SMTP fields.
+
+
+
+ Task 2: RBAC guard dependencies + invite token + email utility + invitation API
+
+ packages/shared/shared/api/rbac.py,
+ packages/shared/shared/invite_token.py,
+ packages/shared/shared/email.py,
+ packages/shared/shared/api/invitations.py,
+ packages/shared/shared/api/portal.py,
+ packages/gateway/gateway/main.py
+
+
+ - require_platform_admin raises 403 for non-platform_admin callers
+ - require_platform_admin returns PortalCaller for platform_admin
+ - require_tenant_admin raises 403 for operators and non-members
+ - require_tenant_admin allows platform_admin to bypass tenant membership check
+ - require_tenant_member allows all three roles if they have tenant membership (or are platform_admin)
+ - require_tenant_member raises 403 for users with no membership in the target tenant
+ - generate_invite_token produces a base64url-encoded HMAC-signed token
+ - validate_invite_token rejects tampered signatures (ValueError)
+ - validate_invite_token rejects expired tokens (ValueError)
+ - validate_invite_token returns invitation_id for valid tokens
+ - POST /api/portal/invitations creates invitation, returns 201
+ - POST /api/portal/invitations/accept accepts invitation, creates user, returns 200
+ - POST /api/portal/invitations/{id}/resend resets expiry and returns new token
+ - AuthVerifyResponse returns role, tenant_ids, active_tenant_id instead of is_admin
+
+
+ Create `packages/shared/shared/api/rbac.py`:
+ - `PortalCaller` dataclass with user_id (UUID), role (str), tenant_id (UUID | None)
+ - `get_portal_caller()` — reads X-Portal-User-Id, X-Portal-User-Role, X-Portal-Tenant-Id headers. Returns PortalCaller. Raises 401 on invalid user_id format.
+ - `require_platform_admin(caller: PortalCaller)` — raises 403 if role != "platform_admin"
+ - `require_tenant_admin(tenant_id: UUID, caller: PortalCaller, session: AsyncSession)` — platform_admin bypasses (return immediately). customer_admin checks UserTenantRole membership. Others get 403.
+ - `require_tenant_member(tenant_id: UUID, caller: PortalCaller, session: AsyncSession)` — platform_admin bypasses. customer_admin or customer_operator checks UserTenantRole membership. Returns PortalCaller.
+
+ Create `packages/shared/shared/invite_token.py`:
+ - `generate_invite_token(invitation_id: str) -> str` — HMAC-SHA256 with settings.invite_secret, embeds invitation_id:timestamp, base64url-encodes result
+ - `validate_invite_token(token: str) -> str` — decodes, verifies HMAC with hmac.compare_digest (timing-safe), checks 48h TTL, returns invitation_id. Raises ValueError on tamper or expiry.
+ - Uses `from shared.config import settings` for invite_secret
+
+ Create `packages/shared/shared/email.py`:
+ - `send_invite_email(to_email, invitee_name, tenant_name, invite_url)` — sync function using smtplib + email.mime. Subject: "You've been invited to join {tenant_name} on Konstruct". Reads SMTP config from settings. Called from Celery task.
+
+ Create `packages/shared/shared/api/invitations.py`:
+ - `invitations_router = APIRouter(prefix="/api/portal/invitations", tags=["invitations"])`
+ - POST `/` — accepts {email, name, role, tenant_id}. Requires tenant admin (use require_tenant_admin). Creates PortalInvitation row, generates token, stores SHA-256 hash of token in token_hash column, dispatches Celery email task (fire-and-forget), returns invitation details + raw token in response (for display/copy in UI). Returns 201.
+ - POST `/accept` — accepts {token, password}. Validates token via validate_invite_token. Looks up invitation by id, checks status='pending' and not expired. Creates PortalUser with bcrypt password + role from invitation. Creates UserTenantRole row. Updates invitation status='accepted'. All in one transaction. Returns 200 with user details.
+ - POST `/{invitation_id}/resend` — requires tenant admin. Generates new token, updates token_hash and expires_at (+48h from now), re-dispatches email Celery task. Returns 200.
+ - GET `/` — requires tenant admin. Lists pending invitations for the caller's active tenant. Returns list.
+
+ Add Celery task in orchestrator or define inline:
+ - Add `send_invite_email_task` to an appropriate location. Per codebase pattern, Celery tasks are sync def. The task calls `send_invite_email()` directly. If SMTP is not configured (empty smtp_host), log a warning and skip — do not crash.
+
+ Update `packages/shared/shared/api/portal.py`:
+ - Change `AuthVerifyResponse` to return `role: str`, `tenant_ids: list[str]`, `active_tenant_id: str | None` instead of `is_admin: bool`
+ - Update `verify_credentials` endpoint: after finding user, query `user_tenant_roles` for all tenant_ids where user has membership. For platform_admin, return all tenant IDs (query tenants table). Return role + tenant_ids + active_tenant_id (first tenant or None).
+ - Update `AuthRegisterResponse` similarly (replace is_admin with role)
+ - Gate `/auth/register` behind platform_admin only (add Depends(require_platform_admin)) with a deprecation comment noting invite-only is the standard flow.
+
+ Mount invitations_router in `packages/gateway/gateway/main.py`:
+ - `from shared.api.invitations import invitations_router`
+ - `app.include_router(invitations_router)`
+
+
+ cd /home/adelorenzo/repos/konstruct && python -c "from shared.api.rbac import PortalCaller, require_platform_admin, require_tenant_admin, require_tenant_member; from shared.invite_token import generate_invite_token, validate_invite_token; from shared.api.invitations import invitations_router; print('All imports OK')"
+
+ RBAC guards raise 403 for unauthorized callers. Invite tokens are HMAC-signed with 48h TTL. Invitation API supports create/accept/resend/list. Auth verify returns role+tenant_ids. Invitations router mounted on gateway.
+
+
+
+ Task 3: Unit tests for RBAC guards and invitation system
+
+ tests/unit/test_rbac_guards.py,
+ tests/unit/test_invitations.py
+
+
+ - test_platform_admin_passes: platform_admin caller gets through require_platform_admin
+ - test_non_admin_rejected: customer_admin and customer_operator get 403 from require_platform_admin
+ - test_tenant_admin_own_tenant: customer_admin with membership passes require_tenant_admin
+ - test_tenant_admin_other_tenant: customer_admin without membership gets 403
+ - test_platform_admin_bypasses_tenant_check: platform_admin passes require_tenant_admin without membership
+ - test_operator_rejected_from_admin: customer_operator gets 403 from require_tenant_admin
+ - test_tenant_member_all_roles: all three roles with membership pass require_tenant_member
+ - test_token_roundtrip: generate then validate returns same invitation_id
+ - test_token_tamper_rejected: modified token raises ValueError
+ - test_token_expired_rejected: token older than 48h raises ValueError
+ - test_invite_accept_creates_user: accepting invite creates PortalUser + UserTenantRole
+ - test_invite_accept_rejects_expired: expired invitation returns error
+ - test_invite_resend_updates_token: resend generates new token_hash and extends expires_at
+
+
+ Create `tests/unit/test_rbac_guards.py`:
+ - Test require_platform_admin with PortalCaller(role="platform_admin") — should return caller
+ - Test require_platform_admin with PortalCaller(role="customer_admin") — should raise HTTPException 403
+ - Test require_platform_admin with PortalCaller(role="customer_operator") — should raise HTTPException 403
+ - Test require_tenant_admin: mock session with UserTenantRole row for customer_admin — should pass
+ - Test require_tenant_admin: mock session with no membership — should raise 403
+ - Test require_tenant_admin: platform_admin caller — should bypass membership check entirely
+ - Test require_tenant_member: customer_operator with membership — should pass
+ - Test require_tenant_member: customer_admin with membership — should pass
+ - Test require_tenant_member: no membership — should raise 403
+
+ For tenant membership tests, mock the AsyncSession.execute() to return appropriate results.
+ Use `pytest.raises(HTTPException)` and assert `.status_code == 403`.
+
+ Create `tests/unit/test_invitations.py`:
+ - Test generate_invite_token + validate_invite_token roundtrip with a UUID string
+ - Test validate_invite_token with a manually tampered signature — should raise ValueError
+ - Test validate_invite_token with an artificially old timestamp (patch time.time to simulate expiry) — should raise ValueError
+ - Test invitation creation via httpx TestClient hitting POST /api/portal/invitations (mock DB session, mock Celery task)
+ - Test invitation acceptance via POST /api/portal/invitations/accept (mock DB session with pending invitation)
+ - Test resend updates token_hash and extends expires_at
+
+ Follow existing test patterns: use `make_app(session)` factory from tests/ or direct httpx.AsyncClient with app.
+
+
+ cd /home/adelorenzo/repos/konstruct && pytest tests/unit/test_rbac_guards.py tests/unit/test_invitations.py -x -v
+
+ All RBAC guard unit tests pass. All invitation token and API unit tests pass. Coverage includes platform_admin bypass, tenant membership checks, token tampering, and expiry validation.
+
+
+
+
+
+- `pytest tests/unit/test_rbac_guards.py tests/unit/test_invitations.py -x -v` — all pass
+- `python -c "from shared.models.auth import UserRole; assert len(UserRole) == 3"` — enum has 3 values
+- `python -c "from shared.api.rbac import require_platform_admin"` — guard imports clean
+- `python -c "from shared.invite_token import generate_invite_token, validate_invite_token"` — token utils import clean
+
+
+
+- Migration 006 exists and handles is_admin -> role backfill
+- PortalUser model has role field, no is_admin
+- UserTenantRole and PortalInvitation models exist
+- RBAC guards (require_platform_admin, require_tenant_admin, require_tenant_member) implemented
+- Invitation API (create, accept, resend, list) endpoints exist
+- HMAC invite tokens generate and validate with 48h TTL
+- SMTP email utility exists (sync, for Celery)
+- Auth/verify returns role + tenant_ids
+- All unit tests pass
+
+
+
diff --git a/.planning/phases/04-rbac/04-02-PLAN.md b/.planning/phases/04-rbac/04-02-PLAN.md
new file mode 100644
index 0000000..782454a
--- /dev/null
+++ b/.planning/phases/04-rbac/04-02-PLAN.md
@@ -0,0 +1,317 @@
+---
+phase: 04-rbac
+plan: 02
+type: execute
+wave: 2
+depends_on: ["04-01"]
+files_modified:
+ - packages/portal/lib/auth.ts
+ - packages/portal/lib/auth-types.ts
+ - packages/portal/proxy.ts
+ - packages/portal/components/nav.tsx
+ - packages/portal/components/tenant-switcher.tsx
+ - packages/portal/components/impersonation-banner.tsx
+ - packages/portal/app/(dashboard)/invite/[token]/page.tsx
+ - packages/portal/app/(dashboard)/users/page.tsx
+ - packages/portal/app/(dashboard)/admin/users/page.tsx
+ - packages/portal/app/(dashboard)/layout.tsx
+autonomous: true
+requirements:
+ - RBAC-05
+ - RBAC-04
+ - RBAC-01
+
+must_haves:
+ truths:
+ - "JWT token contains role, tenant_ids, and active_tenant_id after login"
+ - "Customer operator navigating to /billing is silently redirected to /agents"
+ - "Customer operator does not see Billing, API Keys, or User Management in sidebar"
+ - "Customer admin sees tenant dashboard after login"
+ - "Platform admin sees platform overview with tenant picker after login"
+ - "Multi-tenant user can switch active tenant without logging out"
+ - "Impersonation shows a visible banner with exit button"
+ - "Invite acceptance page accepts token, lets user set password, creates account"
+ - "User management page lists users for tenant with invite button"
+ - "Platform admin global user page shows all users across all tenants"
+ artifacts:
+ - path: "packages/portal/lib/auth-types.ts"
+ provides: "TypeScript module augmentation for Auth.js types with role + tenant fields"
+ contains: "declare module"
+ - path: "packages/portal/lib/auth.ts"
+ provides: "Updated Auth.js config with role + tenant_ids in JWT"
+ contains: "token.role"
+ - path: "packages/portal/proxy.ts"
+ provides: "Role-based redirects for unauthorized paths"
+ contains: "customer_operator"
+ - path: "packages/portal/components/nav.tsx"
+ provides: "Role-filtered sidebar navigation"
+ contains: "useSession"
+ - path: "packages/portal/components/tenant-switcher.tsx"
+ provides: "Tenant switcher dropdown component"
+ min_lines: 30
+ - path: "packages/portal/components/impersonation-banner.tsx"
+ provides: "Impersonation indicator banner"
+ min_lines: 15
+ - path: "packages/portal/app/(dashboard)/invite/[token]/page.tsx"
+ provides: "Invite acceptance page with password form"
+ min_lines: 40
+ - path: "packages/portal/app/(dashboard)/users/page.tsx"
+ provides: "Per-tenant user management page"
+ min_lines: 40
+ - path: "packages/portal/app/(dashboard)/admin/users/page.tsx"
+ provides: "Platform admin global user management page"
+ min_lines: 40
+ key_links:
+ - from: "packages/portal/lib/auth.ts"
+ to: "/api/portal/auth/verify"
+ via: "fetch in authorize(), receives role + tenant_ids"
+ pattern: "role.*tenant_ids"
+ - from: "packages/portal/proxy.ts"
+ to: "packages/portal/lib/auth.ts"
+ via: "reads session.user.role for redirect logic"
+ pattern: "session.*user.*role"
+ - from: "packages/portal/components/nav.tsx"
+ to: "next-auth/react"
+ via: "useSession() to read role for nav filtering"
+ pattern: "useSession"
+ - from: "packages/portal/components/tenant-switcher.tsx"
+ to: "next-auth/react"
+ via: "update() to change active_tenant_id in JWT"
+ pattern: "update.*active_tenant_id"
+---
+
+
+Portal RBAC integration: Auth.js JWT updates, role-based proxy redirects, role-filtered navigation, tenant switcher, impersonation banner, invite acceptance page, and user management pages.
+
+Purpose: The portal must adapt its UI and routing based on user role — hiding restricted items for operators, redirecting unauthorized URL access, and providing user management for admins.
+Output: Updated auth config, proxy, nav, plus new components (tenant switcher, impersonation banner) and pages (invite acceptance, user management).
+
+
+
+@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
+@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/04-rbac/04-CONTEXT.md
+@.planning/phases/04-rbac/04-RESEARCH.md
+@.planning/phases/04-rbac/04-01-SUMMARY.md
+
+
+
+
+From packages/shared/shared/api/portal.py (updated in Plan 01):
+```python
+class AuthVerifyResponse(BaseModel):
+ id: str
+ email: str
+ name: str
+ role: str # "platform_admin" | "customer_admin" | "customer_operator"
+ tenant_ids: list[str] # All tenant UUIDs this user has membership in
+ active_tenant_id: str | None # First tenant or None
+```
+
+From packages/shared/shared/api/invitations.py (created in Plan 01):
+```python
+invitations_router = APIRouter(prefix="/api/portal/invitations", tags=["invitations"])
+# POST /api/portal/invitations/accept — accepts {token: str, password: str}
+# Returns user details on success, 400 on invalid/expired token
+```
+
+From packages/shared/shared/api/rbac.py (created in Plan 01):
+```python
+class PortalCaller:
+ user_id: uuid.UUID
+ role: str # "platform_admin" | "customer_admin" | "customer_operator"
+ tenant_id: uuid.UUID | None
+
+# Headers passed from portal proxy to FastAPI:
+# X-Portal-User-Id, X-Portal-User-Role, X-Portal-Tenant-Id
+```
+
+From packages/portal/lib/auth.ts (current — to be updated):
+```typescript
+// Currently passes is_admin in JWT. Must change to role + tenant_ids + active_tenant_id
+export const { handlers, auth, signIn, signOut } = NextAuth({ ... });
+```
+
+From packages/portal/proxy.ts (current — to be extended):
+```typescript
+// Currently only checks session existence. Must add role-based redirects.
+export async function proxy(request: NextRequest): Promise { ... }
+```
+
+From packages/portal/components/nav.tsx (current — to be updated):
+```typescript
+// Currently shows all nav items to all users. Must filter by role.
+const navItems = [ /* dashboard, tenants, agents, usage, billing, api-keys */ ];
+```
+
+
+
+
+
+
+ Task 1: Auth.js JWT update + type augmentation + proxy role redirects
+
+ packages/portal/lib/auth-types.ts,
+ packages/portal/lib/auth.ts,
+ packages/portal/proxy.ts
+
+
+ Create `packages/portal/lib/auth-types.ts`:
+ - Module augmentation for "next-auth" and "next-auth/jwt"
+ - Extend `User` interface: `role?: string; tenant_ids?: string[]; active_tenant_id?: string | null;`
+ - Extend `Session.user`: `role: string; tenant_ids: string[]; active_tenant_id: string | null;`
+ - Extend `JWT`: `role?: string; tenant_ids?: string[]; active_tenant_id?: string | null;`
+ - This file MUST be imported in auth.ts to ensure TypeScript picks up the augmentation
+
+ Update `packages/portal/lib/auth.ts`:
+ - Import `./auth-types` at the top (side-effect import for type augmentation)
+ - Update `authorize()` response type: replace `is_admin: boolean` with `role: string; tenant_ids: string[]; active_tenant_id: string | null`
+ - Update `jwt` callback: store `token.role = u.role`, `token.tenant_ids = u.tenant_ids`, `token.active_tenant_id = u.tenant_ids[0] ?? null`
+ - Add `trigger: "update"` handling in jwt callback: `if (trigger === "update" && session?.active_tenant_id) { token.active_tenant_id = session.active_tenant_id; }` — this enables the tenant switcher to update the JWT mid-session
+ - Update `session` callback: pass `role`, `tenant_ids`, `active_tenant_id` to session.user
+ - Remove all `is_admin` references
+
+ Update `packages/portal/proxy.ts`:
+ - Add invite acceptance path `/invite` to public paths (no auth required — the invite page must be accessible to unauthenticated users accepting an invite)
+ - After session check, extract `role` from session.user
+ - Define restricted path lists:
+ - PLATFORM_ADMIN_ONLY = ["/admin"]
+ - CUSTOMER_ADMIN_ONLY = ["/billing", "/settings/api-keys", "/users"]
+ - Role-based redirect logic (per locked decision — silent redirect, no 403 page):
+ - customer_operator trying restricted paths -> redirect to "/agents"
+ - customer_admin trying platform admin paths -> redirect to "/dashboard"
+ - Role-based landing page after login (replace current hardcoded "/dashboard"):
+ - platform_admin -> "/dashboard"
+ - customer_admin -> "/dashboard"
+ - customer_operator -> "/agents"
+ - Pass role headers to API routes: When the request is forwarded to backend API routes, the proxy should add X-Portal-User-Id, X-Portal-User-Role, and X-Portal-Tenant-Id headers. NOTE: This may be handled in the Next.js API route layer or server actions rather than proxy.ts — check how existing portal API calls work. If the portal uses direct fetch() to the gateway from API routes, the headers should be added there. If proxy.ts doesn't handle API forwarding, skip this and document that API route handlers must add these headers.
+
+
+ cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30
+
+ Auth.js JWT contains role + tenant_ids + active_tenant_id. Proxy redirects operators away from restricted paths. Invite page is publicly accessible. TypeScript compiles without errors.
+
+
+
+ Task 2: Role-filtered nav + tenant switcher + impersonation banner
+
+ packages/portal/components/nav.tsx,
+ packages/portal/components/tenant-switcher.tsx,
+ packages/portal/components/impersonation-banner.tsx,
+ packages/portal/app/(dashboard)/layout.tsx
+
+
+ Update `packages/portal/components/nav.tsx`:
+ - Import `useSession` from "next-auth/react"
+ - Get session via `const { data: session } = useSession();`
+ - Extract role from `session?.user?.role`
+ - Add role visibility metadata to each nav item:
+ - Dashboard: all roles
+ - Tenants: platform_admin only
+ - Employees (agents): all roles
+ - Usage: all roles
+ - Billing: platform_admin, customer_admin
+ - API Keys: platform_admin, customer_admin
+ - NEW — Users: platform_admin, customer_admin (href: "/users")
+ - NEW — Platform (admin): platform_admin only (href: "/admin/users")
+ - Filter navItems to only show items where current role is in the allowed list
+ - Per locked decision: restricted items are HIDDEN, not disabled/grayed
+
+ Create `packages/portal/components/tenant-switcher.tsx`:
+ - "use client" component
+ - Uses `useSession()` to read `session.user.tenant_ids` and `session.user.active_tenant_id`
+ - Only renders if `tenant_ids.length > 1` (single-tenant users see nothing)
+ - Dropdown showing tenant names (fetch from /api/portal/tenants or pass as prop)
+ - On selection change, calls `update({ active_tenant_id: selectedId })` from useSession() — this triggers the JWT callback with `trigger: "update"`, updating the cookie without page reload
+ - Per specifics: "should feel instant — no page reload, just context switch"
+ - Use shadcn/ui Select or DropdownMenu component for the dropdown
+ - After switching, invalidate TanStack Query cache to refetch data for new tenant context
+
+ Create `packages/portal/components/impersonation-banner.tsx`:
+ - "use client" component
+ - Reads a custom JWT claim `impersonating_tenant_id` from session (or a query param/cookie set by platform admin)
+ - If impersonating, shows a fixed banner at top of viewport: "Viewing as [Tenant Name] — Exit" with a distinct background color (e.g., amber/yellow)
+ - Exit button clears impersonation state (calls update() to remove impersonating_tenant_id from JWT)
+ - Per specifics: "clear visual indicator so the admin knows they're viewing as a customer (and can exit easily)"
+
+ Update `packages/portal/app/(dashboard)/layout.tsx`:
+ - Add TenantSwitcher component to the sidebar area (after the brand section, before nav items)
+ - Add ImpersonationBanner component above the main content area
+
+
+ cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30
+
+ Nav filters items by role. Tenant switcher renders for multi-tenant users and switches context without reload. Impersonation banner shows when active. Layout integrates both components. TypeScript compiles clean.
+
+
+
+ Task 3: Invite acceptance page + user management pages
+
+ packages/portal/app/(dashboard)/invite/[token]/page.tsx,
+ packages/portal/app/(dashboard)/users/page.tsx,
+ packages/portal/app/(dashboard)/admin/users/page.tsx
+
+
+ Create `packages/portal/app/(dashboard)/invite/[token]/page.tsx`:
+ - NOTE: This page must be accessible WITHOUT authentication. If the (dashboard) layout requires auth, create this at `packages/portal/app/invite/[token]/page.tsx` instead (outside the dashboard layout group). Check existing route structure.
+ - Reads `token` from URL params
+ - On load, validates token client-side by calling a GET endpoint or just displays the form (server validates on submit)
+ - Form fields: Password (min 8 chars), Confirm Password
+ - On submit, POST to `/api/portal/invitations/accept` with `{ token, password }`
+ - Success: show "Account created successfully" message, redirect to /login after 2 seconds
+ - Error states: "This invitation has expired" (with note to contact admin for a new one), "Invalid invitation link", generic error
+ - Per specifics: should feel professional — show tenant name and invitee name from the invite data if available
+ - Use existing form patterns (standardSchemaResolver + zod v4 per Phase 1 decision)
+
+ Create `packages/portal/app/(dashboard)/users/page.tsx`:
+ - Per-tenant user management page (customer_admin + platform_admin access)
+ - Fetches users for the active tenant via API call (GET /api/portal/tenants/{tenant_id}/users — this endpoint needs to exist; if not created in Plan 01, add a note that Plan 03 must create it, or add a simple users list endpoint to invitations.py)
+ - Table showing: Name, Email, Role, Status (active/pending), Invited date
+ - "Invite User" button opens a form/dialog: name, email, role selector (admin/operator)
+ - For pending invitations: show "Resend" button (calls POST /api/portal/invitations/{id}/resend)
+ - Use TanStack Query for data fetching (established pattern)
+ - Use shadcn/ui Table, Button, Dialog components
+
+ Create `packages/portal/app/(dashboard)/admin/users/page.tsx`:
+ - Platform admin global user management page
+ - Fetches ALL users across all tenants (GET /api/portal/admin/users — platform_admin only endpoint; may need to be added to invitations.py or a new admin.py router)
+ - Table showing: Name, Email, Role, Tenant(s), Status, Created date
+ - Filter controls: by tenant (dropdown), by role (dropdown)
+ - "Invite User" button — same as per-tenant but with tenant selector added
+ - If the backend endpoints for user listing don't exist yet, create stub API calls that Plan 03 will wire up. Use TanStack Query with the expected endpoint paths.
+
+
+ cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30
+
+ Invite acceptance page renders password form and submits to accept endpoint. Per-tenant users page lists users with invite/resend capability. Platform admin users page shows cross-tenant user list with filters. All pages compile without TypeScript errors.
+
+
+
+
+
+- `cd packages/portal && npx tsc --noEmit` — zero TypeScript errors
+- `cd packages/portal && npx next build` — build succeeds
+- Login as platform_admin: JWT contains role="platform_admin", sees all nav items
+- Login as customer_operator: does not see Billing/API Keys/Users in nav, /billing redirects to /agents
+
+
+
+- Auth.js JWT carries role + tenant_ids + active_tenant_id (not is_admin)
+- Proxy silently redirects operators away from restricted paths
+- Nav hides restricted items based on role
+- Tenant switcher works for multi-tenant users (no page reload)
+- Impersonation banner renders when impersonating
+- Invite acceptance page accepts token and creates account
+- User management pages exist for tenant admin and platform admin
+- Portal builds and TypeScript compiles clean
+
+
+
diff --git a/.planning/phases/04-rbac/04-03-PLAN.md b/.planning/phases/04-rbac/04-03-PLAN.md
new file mode 100644
index 0000000..97ea499
--- /dev/null
+++ b/.planning/phases/04-rbac/04-03-PLAN.md
@@ -0,0 +1,324 @@
+---
+phase: 04-rbac
+plan: 03
+type: execute
+wave: 3
+depends_on: ["04-01", "04-02"]
+files_modified:
+ - packages/shared/shared/api/portal.py
+ - packages/shared/shared/api/billing.py
+ - packages/shared/shared/api/channels.py
+ - packages/shared/shared/api/llm_keys.py
+ - packages/shared/shared/api/usage.py
+ - packages/shared/shared/api/invitations.py
+ - tests/integration/test_portal_rbac.py
+ - tests/integration/test_invite_flow.py
+autonomous: false
+requirements:
+ - RBAC-06
+ - RBAC-01
+ - RBAC-02
+ - RBAC-03
+ - RBAC-04
+ - RBAC-05
+
+must_haves:
+ truths:
+ - "Every mutating portal API endpoint (POST/PUT/DELETE) returns 403 for customer_operator"
+ - "Every tenant-scoped endpoint returns 403 for customer_admin accessing a different tenant"
+ - "Platform admin gets 200 on any tenant's endpoints regardless of membership"
+ - "Customer operator gets 200 on read-only endpoints (GET agents, GET usage) for their tenant"
+ - "Impersonation actions are logged in audit_events with platform admin user_id"
+ - "Full invite flow works end-to-end: create invitation -> accept -> login -> correct role"
+ artifacts:
+ - path: "tests/integration/test_portal_rbac.py"
+ provides: "Integration tests for RBAC enforcement on all portal endpoints"
+ min_lines: 80
+ - path: "tests/integration/test_invite_flow.py"
+ provides: "End-to-end invitation flow integration test"
+ min_lines: 40
+ key_links:
+ - from: "packages/shared/shared/api/portal.py"
+ to: "packages/shared/shared/api/rbac.py"
+ via: "Depends(require_tenant_admin) on mutating endpoints"
+ pattern: "Depends\\(require_tenant_admin\\)|Depends\\(require_platform_admin\\)"
+ - from: "packages/shared/shared/api/billing.py"
+ to: "packages/shared/shared/api/rbac.py"
+ via: "Depends(require_tenant_admin) on billing endpoints"
+ pattern: "Depends\\(require_tenant_admin\\)"
+ - from: "tests/integration/test_portal_rbac.py"
+ to: "packages/shared/shared/api/rbac.py"
+ via: "Tests pass role headers and assert 403/200"
+ pattern: "X-Portal-User-Role"
+---
+
+
+Wire RBAC guards to ALL existing portal API endpoints, add impersonation audit logging, add user listing endpoints, and create comprehensive integration tests proving every endpoint enforces role-based authorization.
+
+Purpose: Defense in depth — the UI hides things, but the API MUST enforce authorization. This plan completes the server-side enforcement layer and validates the entire RBAC system end-to-end.
+Output: All portal endpoints guarded, impersonation logged, integration tests for RBAC + invite flow.
+
+
+
+@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
+@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/04-rbac/04-CONTEXT.md
+@.planning/phases/04-rbac/04-RESEARCH.md
+@.planning/phases/04-rbac/04-01-SUMMARY.md
+@.planning/phases/04-rbac/04-02-SUMMARY.md
+
+
+
+
+From packages/shared/shared/api/rbac.py:
+```python
+class PortalCaller:
+ user_id: uuid.UUID
+ role: str
+ tenant_id: uuid.UUID | None
+
+async def get_portal_caller(...) -> PortalCaller: ...
+async def require_platform_admin(caller: PortalCaller) -> PortalCaller: ...
+async def require_tenant_admin(tenant_id: UUID, caller: PortalCaller, session: AsyncSession) -> PortalCaller: ...
+async def require_tenant_member(tenant_id: UUID, caller: PortalCaller, session: AsyncSession) -> PortalCaller: ...
+```
+
+From packages/shared/shared/api/portal.py — existing endpoints to guard:
+```python
+# Tenant CRUD — platform_admin only (or tenant_admin for their own tenant GET)
+GET /api/portal/tenants # platform_admin only (lists ALL tenants)
+POST /api/portal/tenants # platform_admin only
+GET /api/portal/tenants/{tid} # require_tenant_member (own tenant) or platform_admin
+PUT /api/portal/tenants/{tid} # platform_admin only
+DELETE /api/portal/tenants/{tid} # platform_admin only
+
+# Agent CRUD — tenant-scoped
+GET /api/portal/tenants/{tid}/agents # require_tenant_member
+POST /api/portal/tenants/{tid}/agents # require_tenant_admin
+GET /api/portal/tenants/{tid}/agents/{aid} # require_tenant_member
+PUT /api/portal/tenants/{tid}/agents/{aid} # require_tenant_admin
+DELETE /api/portal/tenants/{tid}/agents/{aid} # require_tenant_admin
+```
+
+From packages/shared/shared/api/billing.py, channels.py, llm_keys.py, usage.py:
+```python
+# All tenant-scoped endpoints need guards:
+# billing.py: subscription management — require_tenant_admin
+# channels.py: channel connections — require_tenant_admin (GET: require_tenant_member)
+# llm_keys.py: BYO API keys — require_tenant_admin
+# usage.py: usage metrics — require_tenant_member (read-only OK for operators)
+```
+
+From packages/shared/shared/models/audit.py:
+```python
+class AuditEvent(Base):
+ # action_type, tenant_id, event_metadata — use for impersonation logging
+```
+
+
+
+
+
+
+ Task 1: Wire RBAC guards to all existing API endpoints + impersonation + user listing
+
+ packages/shared/shared/api/portal.py,
+ packages/shared/shared/api/billing.py,
+ packages/shared/shared/api/channels.py,
+ packages/shared/shared/api/llm_keys.py,
+ packages/shared/shared/api/usage.py,
+ packages/shared/shared/api/invitations.py
+
+
+ Add `Depends()` guards to every endpoint across all portal API routers. The guards read X-Portal-User-Id, X-Portal-User-Role, X-Portal-Tenant-Id headers set by the portal proxy layer.
+
+ **packages/shared/shared/api/portal.py:**
+ - `GET /tenants` — add `Depends(require_platform_admin)`. Only platform admins list ALL tenants.
+ - `POST /tenants` — add `Depends(require_platform_admin)`. Only platform admins create tenants.
+ - `GET /tenants/{tenant_id}` — add `Depends(require_tenant_member)`. Any role with membership can view their tenant.
+ - `PUT /tenants/{tenant_id}` — add `Depends(require_platform_admin)`. Only platform admins edit tenant settings.
+ - `DELETE /tenants/{tenant_id}` — add `Depends(require_platform_admin)`. Only platform admins delete tenants.
+ - `GET /tenants/{tenant_id}/agents` — add `Depends(require_tenant_member)`. All roles can list agents.
+ - `POST /tenants/{tenant_id}/agents` — add `Depends(require_tenant_admin)`. Only admins create agents.
+ - `GET /tenants/{tenant_id}/agents/{agent_id}` — add `Depends(require_tenant_member)`.
+ - `PUT /tenants/{tenant_id}/agents/{agent_id}` — add `Depends(require_tenant_admin)`.
+ - `DELETE /tenants/{tenant_id}/agents/{agent_id}` — add `Depends(require_tenant_admin)`.
+ - ADD new endpoint: `GET /tenants/{tenant_id}/users` — requires require_tenant_admin. Queries UserTenantRole JOIN PortalUser WHERE tenant_id matches. Returns list of {id, name, email, role, created_at}. Also queries PortalInvitation WHERE tenant_id AND status='pending' to include pending invites.
+ - ADD new endpoint: `GET /admin/users` — requires require_platform_admin. Queries ALL PortalUser with their UserTenantRole associations. Supports optional query params: tenant_id filter, role filter. Returns list with tenant membership info.
+
+ **packages/shared/shared/api/billing.py:**
+ - All endpoints: add `Depends(require_tenant_admin)` — only admins manage billing.
+
+ **packages/shared/shared/api/channels.py:**
+ - GET endpoints: `Depends(require_tenant_member)` — operators can view channel connections.
+ - POST/PUT/DELETE endpoints: `Depends(require_tenant_admin)` — only admins modify channels.
+
+ **packages/shared/shared/api/llm_keys.py:**
+ - All endpoints: `Depends(require_tenant_admin)` — only admins manage BYO API keys.
+
+ **packages/shared/shared/api/usage.py:**
+ - All GET endpoints: `Depends(require_tenant_member)` — operators can view usage dashboards (per locked decision: operators can view usage).
+
+ **Impersonation endpoint** (add to portal.py or a new admin section):
+ - `POST /api/portal/admin/impersonate` — requires require_platform_admin. Accepts `{tenant_id}`. Logs AuditEvent with action_type="impersonation", event_metadata containing platform_admin user_id and target tenant_id. Returns the tenant details (the portal will use this to trigger a JWT update with impersonating_tenant_id).
+ - `POST /api/portal/admin/stop-impersonation` — requires require_platform_admin. Logs end of impersonation in audit trail.
+
+ **packages/shared/shared/api/invitations.py:**
+ - Ensure all endpoints already have proper guards from Plan 01. Verify and fix if missing.
+
+ IMPORTANT: For each endpoint, the guard dependency must be added as a function parameter so FastAPI's DI resolves it. Example:
+ ```python
+ @portal_router.get("/tenants")
+ async def list_tenants(
+ caller: PortalCaller = Depends(require_platform_admin),
+ session: AsyncSession = Depends(get_session),
+ ) -> TenantsListResponse:
+ ```
+ The `caller` parameter captures the resolved PortalCaller but may not be used in the function body — that's fine, the guard raises 403 before the function executes if unauthorized.
+
+
+ cd /home/adelorenzo/repos/konstruct && python -c "
+from shared.api.portal import portal_router
+from shared.api.billing import billing_router
+from shared.api.channels import channels_router
+routes = [r.path for r in portal_router.routes]
+print(f'Portal routes: {len(routes)}')
+# Verify at least one route has dependencies
+import inspect
+for r in portal_router.routes:
+ if hasattr(r, 'dependant') and r.dependant.dependencies:
+ print(f' {r.path} has {len(r.dependant.dependencies)} dependencies')
+ break
+else:
+ print('WARNING: No routes have dependencies')
+"
+
+ Every portal API endpoint has an RBAC guard. Mutating endpoints require tenant_admin or platform_admin. Read-only tenant endpoints allow tenant_member. Global endpoints require platform_admin. Impersonation endpoint logs to audit trail. User listing endpoints exist for both per-tenant and global views.
+
+
+
+ Task 2: Integration tests for RBAC enforcement and invite flow
+
+ tests/integration/test_portal_rbac.py,
+ tests/integration/test_invite_flow.py
+
+
+ - Platform admin with correct headers gets 200 on all endpoints
+ - Customer admin gets 200 on own-tenant endpoints, 403 on other tenants
+ - Customer operator gets 200 on GET endpoints, 403 on POST/PUT/DELETE
+ - Missing role headers return 401/422 (FastAPI Header() validation)
+ - Impersonation endpoint logs AuditEvent row
+ - Full invite flow: admin creates invite -> token generated -> accept with password -> new user can login -> new user has correct role and tenant membership
+ - Resend invite generates new token and extends expiry
+ - Expired invite acceptance returns error
+
+
+ Create `tests/integration/test_portal_rbac.py`:
+ - Use httpx.AsyncClient with the FastAPI app (established test pattern: `make_app(session)`)
+ - Set up test fixtures: create test tenants, portal_users with different roles, user_tenant_roles
+ - Helper function to add role headers: `def headers(user_id, role, tenant_id=None) -> dict`
+
+ Test matrix (test each combination):
+
+ | Endpoint | platform_admin | customer_admin (own) | customer_admin (other) | customer_operator |
+ |----------|---------------|---------------------|----------------------|-------------------|
+ | GET /tenants | 200 | 403 | 403 | 403 |
+ | POST /tenants | 201 | 403 | 403 | 403 |
+ | GET /tenants/{tid} | 200 | 200 | 403 | 200 |
+ | PUT /tenants/{tid} | 200 | 403 | 403 | 403 |
+ | DELETE /tenants/{tid} | 204 | 403 | 403 | 403 |
+ | GET /tenants/{tid}/agents | 200 | 200 | 403 | 200 |
+ | POST /tenants/{tid}/agents | 201 | 201 | 403 | 403 |
+ | PUT /tenants/{tid}/agents/{aid} | 200 | 200 | 403 | 403 |
+ | DELETE /tenants/{tid}/agents/{aid} | 204 | 204 | 403 | 403 |
+ | GET /tenants/{tid}/users | 200 | 200 | 403 | 403 |
+ | GET /admin/users | 200 | 403 | 403 | 403 |
+
+ Also test:
+ - Request with NO role headers -> 422 (missing required header)
+ - Impersonation endpoint creates AuditEvent row
+ - Billing, channels, llm_keys, usage endpoints follow same pattern (at least one representative test per router)
+
+ Create `tests/integration/test_invite_flow.py`:
+ - Set up: create a tenant, create a customer_admin user with membership
+ - Test full flow:
+ 1. Admin POST /invitations with {email, name, role: "customer_operator", tenant_id} -> 201, returns token
+ 2. Accept POST /invitations/accept with {token, password: "securepass123"} -> 200, returns user
+ 3. Verify PortalUser created with correct email, role from invitation
+ 4. Verify UserTenantRole created linking user to tenant
+ 5. Verify invitation status updated to "accepted"
+ 6. Verify login works: POST /auth/verify with new credentials -> 200, returns role="customer_operator"
+ - Test expired invite: create invitation, manually set expires_at to past, attempt accept -> error
+ - Test resend: create invitation, POST /{id}/resend -> 200, verify new token_hash and extended expires_at
+ - Test double-accept: accept once, attempt accept again -> error (status no longer 'pending')
+
+ Use `pytest.mark.asyncio` and async test functions. Follow existing integration test patterns in `tests/integration/`.
+
+
+ cd /home/adelorenzo/repos/konstruct && pytest tests/integration/test_portal_rbac.py tests/integration/test_invite_flow.py -x -v
+
+ All RBAC integration tests pass — every endpoint returns correct status code for each role. Full invite flow works end-to-end. Expired invites are rejected. Resend works. Double-accept prevented.
+
+
+
+ Task 3: Verify complete RBAC system end-to-end
+
+ Human verification of the complete RBAC system: three-tier role enforcement (platform admin, customer admin, customer operator) with role-based portal navigation, proxy redirects, API guards, invitation flow, tenant switcher, and impersonation.
+
+ Steps to verify:
+ 1. Start the dev environment: `docker compose up -d` and `cd packages/portal && npm run dev`
+ 2. Run the migration: `cd /home/adelorenzo/repos/konstruct && alembic upgrade head`
+ 3. Login as platform admin:
+ - Verify: sees all nav items (Dashboard, Tenants, Employees, Usage, Billing, API Keys, Users, Platform)
+ - Verify: can access /admin/users (global user management)
+ - Verify: can impersonate a tenant (banner appears, can exit)
+ 4. Create a customer_admin invite from the Users page
+ 5. Open the invite link in an incognito window
+ - Verify: activation page shows, can set password
+ - Verify: after activation, redirected to login
+ 6. Login as the new customer admin:
+ - Verify: sees Dashboard, Employees, Usage, Billing, API Keys, Users (no Tenants, no Platform)
+ - Verify: cannot access /admin/users (redirected to /dashboard)
+ 7. Create a customer_operator invite from Users page
+ 8. Accept invite and login as operator:
+ - Verify: sees only Employees and Usage in nav
+ - Verify: navigating to /billing redirects to /agents
+ - Verify: cannot see Billing, API Keys, Users in sidebar
+ 9. If user has multiple tenants, verify tenant switcher appears and switches context
+ 10. Run: `pytest tests/ -x` — all tests pass
+
+ Human confirms all verification steps pass or reports issues
+ All three roles behave correctly in portal UI and API. Invitation flow works end-to-end. Full test suite green.
+
+
+
+
+
+- `pytest tests/integration/test_portal_rbac.py -x -v` — all RBAC endpoint tests pass
+- `pytest tests/integration/test_invite_flow.py -x -v` — full invite flow tests pass
+- `pytest tests/ -x` — entire test suite green (no regressions)
+- Every mutating endpoint returns 403 without proper role headers
+- Platform admin bypasses all tenant membership checks
+
+
+
+- All portal API endpoints enforce role-based authorization via Depends() guards
+- Customer operators cannot mutate any data via API (403 on POST/PUT/DELETE)
+- Customer admins can only access their own tenant's data (403 on other tenants)
+- Platform admin has unrestricted access to all endpoints
+- Impersonation actions logged in audit_events table
+- User listing endpoints exist for per-tenant and global views
+- Integration tests comprehensively cover the RBAC matrix
+- Full invite flow works end-to-end in integration tests
+- Human verification confirms visual role-based behavior in portal
+
+
+