Compare commits

..

4 Commits

6 changed files with 1807 additions and 20 deletions

View File

@@ -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 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 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) - [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 ## Phase Details
@@ -75,23 +76,6 @@ Plans:
- [ ] 03-04-PLAN.md — Cost tracking dashboard with Recharts charts, budget alert badges, time range filtering - [ ] 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) - [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 ### 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 **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 **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 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 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 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: 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* *Roadmap created: 2026-03-23*
*Coverage: 25/25 v1 requirements mapped* *Coverage: 25/25 v1 requirements + 6 RBAC requirements mapped*

View File

@@ -0,0 +1,360 @@
---
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
- tests/unit/test_portal_auth.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
- path: "tests/unit/test_portal_auth.py"
provides: "Unit tests for auth/verify endpoint returning role + tenant_ids claims"
min_lines: 30
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"
---
<objective>
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.
</objective>
<execution_context>
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-rbac/04-CONTEXT.md
@.planning/phases/04-rbac/04-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
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
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: DB migration + ORM models for RBAC</name>
<files>
migrations/versions/006_rbac_roles.py,
packages/shared/shared/models/auth.py,
packages/shared/shared/config.py
</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>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')"</automated>
</verify>
<done>Migration 006 exists with upgrade/downgrade. PortalUser has role (not is_admin). UserTenantRole and PortalInvitation models exist. Settings has invite_secret and SMTP fields.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: RBAC guard dependencies + invite token + email utility + invitation API</name>
<files>
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
</files>
<behavior>
- 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
</behavior>
<action>
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)`
</action>
<verify>
<automated>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')"</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Unit tests for RBAC guards, invitation system, and portal auth</name>
<files>
tests/unit/test_rbac_guards.py,
tests/unit/test_invitations.py,
tests/unit/test_portal_auth.py
</files>
<behavior>
- 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
- test_auth_verify_returns_role: auth/verify response contains role field (not is_admin)
- test_auth_verify_returns_tenant_ids: auth/verify response contains tenant_ids list
- test_auth_verify_returns_active_tenant: auth/verify response contains active_tenant_id
</behavior>
<action>
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
Create `tests/unit/test_portal_auth.py`:
- Test the updated auth/verify endpoint returns role (not is_admin) in the response
- Test auth/verify returns tenant_ids as a list of UUID strings for a user with UserTenantRole memberships
- Test auth/verify returns active_tenant_id as the first tenant ID (or None for users with no memberships)
- Test auth/verify for platform_admin returns all tenant IDs from the tenants table
- Test auth/verify for customer_admin returns only tenant IDs from their UserTenantRole rows
- Mock the DB session with appropriate PortalUser (role field) and UserTenantRole rows
- Use httpx.AsyncClient with the app, following existing test patterns (make_app(session) factory)
Follow existing test patterns: use `make_app(session)` factory from tests/ or direct httpx.AsyncClient with app.
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct && pytest tests/unit/test_rbac_guards.py tests/unit/test_invitations.py tests/unit/test_portal_auth.py -x -v</automated>
</verify>
<done>All RBAC guard unit tests pass. All invitation token and API unit tests pass. All portal auth unit tests pass verifying role + tenant_ids claims. Coverage includes platform_admin bypass, tenant membership checks, token tampering, expiry validation, and auth/verify response shape.</done>
</task>
</tasks>
<verification>
- `pytest tests/unit/test_rbac_guards.py tests/unit/test_invitations.py tests/unit/test_portal_auth.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
</verification>
<success_criteria>
- 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 (including test_portal_auth.py for JWT callback claims)
</success_criteria>
<output>
After completion, create `.planning/phases/04-rbac/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,318 @@
---
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/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/invite/[token]/page.tsx"
provides: "Invite acceptance page with password form (outside dashboard layout — no auth required)"
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"
---
<objective>
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).
</objective>
<execution_context>
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- From Plan 01 outputs — executor needs these contracts -->
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<NextResponse> { ... }
```
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 */ ];
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Auth.js JWT update + type augmentation + proxy role redirects</name>
<files>
packages/portal/lib/auth-types.ts,
packages/portal/lib/auth.ts,
packages/portal/proxy.ts
</files>
<action>
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.
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Role-filtered nav + tenant switcher + impersonation banner</name>
<files>
packages/portal/components/nav.tsx,
packages/portal/components/tenant-switcher.tsx,
packages/portal/components/impersonation-banner.tsx,
packages/portal/app/(dashboard)/layout.tsx
</files>
<action>
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
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Invite acceptance page + user management pages</name>
<files>
packages/portal/app/invite/[token]/page.tsx,
packages/portal/app/(dashboard)/users/page.tsx,
packages/portal/app/(dashboard)/admin/users/page.tsx
</files>
<action>
Create `packages/portal/app/invite/[token]/page.tsx`:
- IMPORTANT: This page is created OUTSIDE the (dashboard) route group because the (dashboard) layout enforces authentication. Invite acceptance must be accessible to unauthenticated users who are creating their account.
- 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.
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>Invite acceptance page renders password form and submits to accept endpoint. Page is outside (dashboard) group so unauthenticated users can access it. 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.</done>
</task>
</tasks>
<verification>
- `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
- Visit /invite/{token} while logged out: page renders without auth redirect
</verification>
<success_criteria>
- 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 at /invite/[token] (outside dashboard layout) accepts token and creates account without requiring auth
- User management pages exist for tenant admin and platform admin
- Portal builds and TypeScript compiles clean
</success_criteria>
<output>
After completion, create `.planning/phases/04-rbac/04-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,339 @@
---
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"
- "Customer operator can POST a test message to an agent (POST /tenants/{tid}/agents/{aid}/test) and get 200"
- "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, Depends(require_tenant_member) on test-message endpoint"
pattern: "Depends\\(require_tenant_admin\\)|Depends\\(require_platform_admin\\)|Depends\\(require_tenant_member\\)"
- 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"
---
<objective>
Wire RBAC guards to ALL existing portal API endpoints, add test-message endpoint for operators, 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, test-message endpoint for operators, impersonation logged, integration tests for RBAC + invite flow.
</objective>
<execution_context>
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- From Plan 01 — RBAC guard dependencies -->
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
# NEW — Test message (per locked decision: operators can send test messages)
POST /api/portal/tenants/{tid}/agents/{aid}/test # require_tenant_member (operators included)
```
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
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire RBAC guards to all existing API endpoints + test-message endpoint + impersonation + user listing</name>
<files>
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
</files>
<action>
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: `POST /tenants/{tenant_id}/agents/{agent_id}/test` — requires `Depends(require_tenant_member)` (NOT require_tenant_admin). This is the test-message endpoint per locked decision: "operators can send test messages to agents." Accepts `{message: str}` body. Dispatches the message through the agent orchestrator pipeline (or a lightweight test handler) for the specified agent, returns the agent's response. This allows operators to QA agent behavior without agent CRUD access.
- 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.
</action>
<verify>
<automated>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 test-message endpoint exists
test_routes = [r.path for r in portal_router.routes if 'test' in r.path]
assert test_routes, 'Missing /test endpoint for agent test messages'
print(f'Test-message route: {test_routes}')
# Verify at least one route has dependencies
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')
"</automated>
</verify>
<done>Every portal API endpoint has an RBAC guard. Mutating endpoints require tenant_admin or platform_admin. Read-only tenant endpoints allow tenant_member. Test-message endpoint (POST /tenants/{tid}/agents/{aid}/test) allows tenant_member including operators. Global endpoints require platform_admin. Impersonation endpoint logs to audit trail. User listing endpoints exist for both per-tenant and global views.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Integration tests for RBAC enforcement and invite flow</name>
<files>
tests/integration/test_portal_rbac.py,
tests/integration/test_invite_flow.py
</files>
<behavior>
- 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
- Customer operator gets 200 on POST /tenants/{tid}/agents/{aid}/test (test message — exception to POST restriction)
- 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
</behavior>
<action>
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 |
| POST /tenants/{tid}/agents/{aid}/test | 200 | 200 | 403 | 200 |
| 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)
- Specific test: customer_operator can POST to /test endpoint but NOT to agent CRUD POST
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/`.
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct && pytest tests/integration/test_portal_rbac.py tests/integration/test_invite_flow.py -x -v</automated>
</verify>
<done>All RBAC integration tests pass — every endpoint returns correct status code for each role. Operator test-message endpoint returns 200. Full invite flow works end-to-end. Expired invites are rejected. Resend works. Double-accept prevented.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify complete RBAC system end-to-end</name>
<action>
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 (URL will be /invite/{token} — NOT inside dashboard)
- Verify: activation page shows without requiring login
- Verify: can set password and complete account creation
- 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
- Verify: can click "Test" on an agent to send a test message (per locked decision)
9. If user has multiple tenants, verify tenant switcher appears and switches context
10. Run: `pytest tests/ -x` — all tests pass
</action>
<verify>Human confirms all verification steps pass or reports issues</verify>
<done>All three roles behave correctly in portal UI and API. Operators can send test messages but not edit agents. Invitation flow works end-to-end. Full test suite green.</done>
</task>
</tasks>
<verification>
- `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
- Operator gets 200 on POST /tenants/{tid}/agents/{aid}/test
</verification>
<success_criteria>
- All portal API endpoints enforce role-based authorization via Depends() guards
- Customer operators cannot mutate any data via API (403 on POST/PUT/DELETE) EXCEPT test-message endpoint
- Customer operators CAN send test messages to agents (POST /tenants/{tid}/agents/{aid}/test returns 200)
- 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 including test-message operator access
- Full invite flow works end-to-end in integration tests
- Human verification confirms visual role-based behavior in portal
</success_criteria>
<output>
After completion, create `.planning/phases/04-rbac/04-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,684 @@
# Phase 4: RBAC - Research
**Researched:** 2026-03-24
**Domain:** Role-Based Access Control — FastAPI authorization middleware, Auth.js v5 JWT role claims, PostgreSQL schema migration, SMTP email via Python stdlib, Next.js 16 proxy-layer redirects
**Confidence:** HIGH
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Role Definitions & Boundaries**
- Platform admin: Full access to all tenants, all agents, all users, platform settings. Uses the same portal with elevated access (no separate admin panel).
- Customer admin: Full control over their tenant — agents (CRUD), channels, billing (self-service via Stripe), BYO API keys, user management (invite/remove users). Can manage multiple tenants (agency/reseller use case).
- Customer operator: View agents, view conversations, view usage dashboards, send test messages to agents. Cannot create/edit/delete agents, no billing access, no API key management, no user management. Fixed role — granular permissions deferred to v2.
- Operators can send test messages to agents — useful for QA without giving edit access.
- Customer admins manage their own billing (subscribe, upgrade, cancel) — self-service, not admin-gated.
- Customer admins manage their own BYO API keys — self-service.
**Invitation & Onboarding Flow**
- Customer admin creates user in portal (name, email, role selection: admin or operator)
- System sends invite email via SMTP direct (no third-party transactional email service)
- Invite link valid for 48 hours — expired links show a clear message
- Customer admin can resend expired invites with a new 48-hour window (resend button on pending invites list)
- All user creation goes through the invite flow — even platform admins must use invites, no direct account creation with temporary passwords. Consistent and auditable.
- Activation page: Claude's discretion (set password only recommended — minimal friction)
**Portal Experience Per Role**
- Role-specific landing pages after login:
- Platform admin → platform overview (all tenants, global stats)
- Customer admin → tenant dashboard (their agents, usage summary)
- Customer operator → agent list (read-only view of their tenant's agents)
- Users with multiple tenants get a tenant switcher dropdown in the sidebar/header — switch without logging out
- Restricted nav items are hidden (not disabled/grayed) — operators don't see Billing, API Keys, User Management in sidebar
- Unauthorized URL access (e.g., operator navigates to /billing) → silent redirect to their home dashboard (no 403 error page)
- API endpoints return 403 Forbidden for unauthorized actions — defense in depth, not just hidden UI
**Platform Admin Capabilities**
- Impersonation: platform admin can "view as" a tenant — all impersonation actions logged in audit trail
- Global user management page: see all users across all tenants, filter by tenant/role, manage invites
- Platform admin sees the same portal as customers but with elevated access and a tenant picker (existing from Phase 1)
### Claude's Discretion
- Activation page design (set password only vs full profile setup)
- Invite email template content and styling
- SMTP configuration approach (env vars vs portal settings)
- Impersonation UI pattern (banner at top, dropdown, etc.)
- How role is stored in JWT (claim name, encoding)
- Database schema for user-tenant association (join table vs embedded)
- Tenant switcher dropdown visual design
### Deferred Ideas (OUT OF SCOPE)
- Granular operator permissions (configurable by customer admin) — v2 RBAC enhancement
- SSO/SAML for enterprise tenants — future authentication method
- Activity log visible to customer admins (who did what in their tenant) — separate observability phase
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| RBAC-01 | Platform admin role with full access to all tenants, agents, users, and platform settings | FastAPI `Depends(require_platform_admin)` dependency; JWT claim `role=platform_admin`; no RLS tenant scoping for platform_admin queries |
| RBAC-02 | Customer admin role scoped to a single tenant with full control over agents, channels, billing, API keys, and user management | `Depends(require_tenant_admin)` with tenant membership check; many-to-many `user_tenant_roles` join table; scoped to caller's tenant_id |
| RBAC-03 | Customer operator role scoped to a single tenant with read-only access to agents, conversations, and usage dashboards | `Depends(require_tenant_member)` dependency; HTTP verbs restricted (GET only) for operator paths; test-message endpoint operator-allowed explicitly |
| RBAC-04 | Customer admin can invite users by email — invitee receives activation link to set password | `portal_invitations` table with HMAC-signed token + 48h expiry; Python stdlib `smtplib`/`email.mime` for SMTP; bcrypt password set on accept |
| RBAC-05 | Portal navigation, pages, and UI elements adapt based on user role | Auth.js v5 JWT carries `role` + `tenant_ids`; Nav component filters by role from `useSession()`; proxy.ts redirects unauthorized paths to role home |
| RBAC-06 | API endpoints enforce role-based authorization — unauthorized actions return 403 Forbidden, not just hidden UI | FastAPI `HTTPException(status_code=403)` from role-checking dependencies on all portal router endpoints |
</phase_requirements>
---
## Summary
Phase 4 adds RBAC on top of an already working auth system (Auth.js v5 JWT + FastAPI bcrypt verify). The existing `PortalUser` model has a boolean `is_admin` flag that must be replaced with a proper role enum (`platform_admin`, `customer_admin`, `customer_operator`). Because a customer admin can belong to multiple tenants (agency use case), user-tenant association requires a join table (`user_tenant_roles`) rather than a foreign key on `portal_users`. The invitation system uses time-limited HMAC-signed tokens stored in a `portal_invitations` table and delivered via Python's built-in `smtplib` — no third-party dependency.
Authorization enforcement splits into two layers: the Next.js 16 `proxy.ts` handles optimistic role-based redirects (reading role from the JWT cookie, no DB round-trip), and FastAPI `Depends()` decorators enforce the hard server-side rules returning 403. The proxy layer is the correct place for silent redirects per the official Next.js 16 auth guide. FastAPI dependency injection is the correct place for 403 enforcement — this is an additive layer on top of PostgreSQL RLS, not a replacement for it.
The impersonation feature needs one new JWT claim (`impersonating_tenant_id`) plus an AuditEvent row on every impersonated action. The tenant switcher is purely client-side state: update `active_tenant_id` in the JWT and re-issue a new token without a full page reload.
**Primary recommendation:** Migrate `portal_users.is_admin` to a `role` enum in a single Alembic migration. Add `user_tenant_roles` join table. Add `portal_invitations` table. Wire FastAPI `Depends()` guards. Then update Auth.js JWT callbacks and proxy.ts last.
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| SQLAlchemy 2.0 | already in use (>=2.0.36) | ORM for new RBAC tables | Already established in codebase |
| Alembic | already in use (>=1.14.0) | DB migration for role enum + join table | Already established in codebase |
| FastAPI | already in use (>=0.115.0) | `Depends()` for role-checking decorators | Already established in codebase |
| bcrypt | already in use (>=4.0.0) | Password hashing for invite activation | Already established in codebase |
| Python stdlib: `smtplib`, `email.mime` | stdlib (3.12) | SMTP email sending for invite emails | No new dependency; locked decision to avoid third-party transactional email |
| Python stdlib: `hmac`, `hashlib`, `secrets` | stdlib (3.12) | HMAC-signed invite token generation | No new dependency; cryptographically safe |
| Auth.js v5 | ^5.0.0-beta.30 (already in use) | JWT JWT callbacks for `role` + `tenant_ids` claims | Already established in codebase |
| Next.js 16 `proxy.ts` | 16.2.1 (already in use) | Role-based redirect in proxy layer | Official Next.js 16 pattern (confirmed in bundled docs) |
| `useSession` from next-auth/react | already in use | Read role/tenant from JWT in client components | Already established pattern |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `cryptography` (Fernet) | already in use (>=42.0.0) | Alternative token signing approach | Not recommended here — HMAC+secrets is simpler for short-lived invite tokens; Fernet used for BYO key encryption |
| `pydantic[email]` | already in use (>=2.12.0) | Email format validation on invite request | Already in shared pyproject.toml |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Python stdlib smtplib | `aiosmtplib` | Async SMTP, but adds a dependency. smtplib works fine when called from a Celery task (sync context). Use aiosmtplib only if sending directly from an async FastAPI route without Celery. |
| HMAC token in URL | JWT invite token | JWT adds sub-second crypto overhead and library; HMAC+secrets is more transparent. Both are safe for 48h tokens. |
| Join table `user_tenant_roles` | `tenant_ids: list` on `portal_users` | PostgreSQL array on the user row is simpler but cannot store per-tenant role without extra complexity. Join table is the correct relational approach. |
**Installation:**
No new Python packages required — all needed libraries are already in `packages/shared/pyproject.toml` or Python stdlib.
Portal: no new npm packages required.
---
## Architecture Patterns
### Recommended Project Structure
New files needed:
```
packages/
├── shared/
│ └── shared/
│ ├── models/
│ │ └── auth.py # Add role enum, UserTenantRole model, Invitation model
│ └── api/
│ ├── portal.py # Add RBAC guards to all existing endpoints
│ ├── rbac.py # NEW: FastAPI Depends() guards (require_platform_admin, etc.)
│ └── invitations.py # NEW: Invite CRUD + accept endpoints
migrations/
│ └── versions/
│ └── 006_rbac_roles.py # NEW: role enum + user_tenant_roles + portal_invitations
packages/portal/
├── lib/
│ ├── auth.ts # Update JWT callbacks: role + tenant_ids + active_tenant_id
│ └── auth-types.ts # NEW: TypeScript types for role, augmented session
├── proxy.ts # Update: role-based redirects
├── components/
│ ├── nav.tsx # Update: role-filtered nav items
│ ├── tenant-switcher.tsx # NEW: dropdown for multi-tenant users
│ └── impersonation-banner.tsx # NEW: visible banner when impersonating
└── app/(dashboard)/
├── users/ # NEW: per-tenant user management page
│ └── page.tsx
├── admin/ # NEW: platform admin — global users, all tenants
│ └── users/
│ └── page.tsx
└── invite/ # NEW: public invite acceptance page
└── [token]/
└── page.tsx
```
### Pattern 1: FastAPI Role-Checking Dependency
**What:** A dependency factory that reads the `X-Portal-User-Role` and `X-Portal-Tenant-Id` headers injected by the Next.js proxy, then validates the caller's permission.
**When to use:** On every portal API endpoint that has role requirements.
The existing portal calls FastAPI with no auth headers — Phase 4 must add a mechanism to pass the authenticated user's role and tenant context from the JWT to FastAPI. Two established approaches:
**Option A (recommended): Next.js proxy forwards role headers**
The Next.js API routes (or Server Actions) extract the JWT session via `auth()` and add `X-Portal-User-Id`, `X-Portal-User-Role`, and `X-Portal-Tenant-Id` headers to requests forwarded to FastAPI. FastAPI reads these trusted headers (only accepts them from the internal network / trusted origin).
**Option B: FastAPI validates the Auth.js JWT directly**
FastAPI re-validates the Auth.js JWT using the shared `AUTH_SECRET`. This is more secure in theory but adds `python-jose` or `PyJWT` as a new dependency and couples FastAPI to Auth.js token format.
**Recommendation: Option A** — consistent with how the existing portal API proxy works, simpler, and the internal network boundary already provides the trust layer. This is the same pattern used by the existing billing/channel endpoints.
```python
# Source: FastAPI dependency injection pattern (established in codebase)
# packages/shared/shared/api/rbac.py
from fastapi import Header, HTTPException, status
from typing import Annotated
import uuid
class PortalCaller:
"""Extracted caller context from trusted proxy headers."""
def __init__(self, user_id: uuid.UUID, role: str, tenant_id: uuid.UUID | None = None):
self.user_id = user_id
self.role = role
self.tenant_id = tenant_id # None for platform_admin calls not scoped to a tenant
async def get_portal_caller(
x_portal_user_id: Annotated[str, Header()],
x_portal_user_role: Annotated[str, Header()],
x_portal_tenant_id: Annotated[str | None, Header()] = None,
) -> PortalCaller:
try:
user_id = uuid.UUID(x_portal_user_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid caller identity")
tenant_id = uuid.UUID(x_portal_tenant_id) if x_portal_tenant_id else None
return PortalCaller(user_id=user_id, role=x_portal_user_role, tenant_id=tenant_id)
async def require_platform_admin(caller: Annotated[PortalCaller, Depends(get_portal_caller)]) -> PortalCaller:
if caller.role != "platform_admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Platform admin required")
return caller
async def require_tenant_admin(
tenant_id: uuid.UUID, # from path param
caller: Annotated[PortalCaller, Depends(get_portal_caller)],
session: AsyncSession = Depends(get_session),
) -> PortalCaller:
if caller.role == "platform_admin":
return caller # platform_admin bypasses tenant check
if caller.role != "customer_admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
# Verify caller has admin role in this specific tenant
membership = await session.execute(
select(UserTenantRole).where(
UserTenantRole.user_id == caller.user_id,
UserTenantRole.tenant_id == tenant_id,
UserTenantRole.role == "customer_admin",
)
)
if membership.scalar_one_or_none() is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this tenant")
return caller
```
### Pattern 2: Auth.js v5 JWT with Role + Tenant Claims
**What:** Extend the existing JWT callback to store `role`, `tenant_ids`, and `active_tenant_id`.
**When to use:** Once on login, and when tenant switcher changes active tenant.
```typescript
// Source: Auth.js v5 JWT callback pattern — extends existing lib/auth.ts
// The authorize() response from FastAPI /auth/verify now returns role + tenant_ids
async jwt({ token, user }) {
if (user) {
const u = user as AuthVerifyResponse;
token.role = u.role; // "platform_admin" | "customer_admin" | "customer_operator"
token.tenant_ids = u.tenant_ids; // string[] — all tenants this user belongs to
token.active_tenant_id = u.tenant_ids[0] ?? null; // default to first tenant
}
return token;
},
async session({ session, token }) {
session.user.id = token.sub ?? "";
session.user.role = token.role as string;
session.user.tenant_ids = token.tenant_ids as string[];
session.user.active_tenant_id = token.active_tenant_id as string | null;
return session;
},
```
### Pattern 3: Next.js 16 Proxy Role-Based Redirect
**What:** Extend `proxy.ts` to redirect unauthorized paths based on JWT role claim.
**When to use:** For silent redirects when an operator navigates to a restricted page.
Per the official Next.js 16 docs bundled in this repo (`node_modules/next/dist/docs/01-app/02-guides/authentication.md`): proxy should do **optimistic checks only** — read role from the JWT cookie without DB queries. Secure enforcement is FastAPI's responsibility.
The `redirect` in proxy.ts uses `NextResponse.redirect`, which is already in use in `proxy.ts`.
```typescript
// Extend existing proxy.ts
const PLATFORM_ADMIN_ONLY = ["/admin", "/tenants"];
const CUSTOMER_ADMIN_ONLY = ["/billing", "/settings/api-keys", "/users"];
const OPERATOR_HOME = "/agents";
const CUSTOMER_ADMIN_HOME = "/dashboard";
const PLATFORM_ADMIN_HOME = "/dashboard";
// After session check, add role-based redirect:
const role = (session?.user as { role?: string })?.role;
if (role === "customer_operator") {
const isRestricted = [...PLATFORM_ADMIN_ONLY, ...CUSTOMER_ADMIN_ONLY].some(
(path) => pathname.startsWith(path)
);
if (isRestricted) {
return NextResponse.redirect(new URL(OPERATOR_HOME, request.url));
}
}
```
### Pattern 4: Invite Token Generation and Validation
**What:** HMAC-SHA256 signed, URL-safe token with 48-hour expiry embedded in the invite URL.
**When to use:** Creating and accepting invite links.
```python
# Source: Python stdlib hmac + secrets (same approach used for WhatsApp HMAC in Phase 2)
import hmac
import hashlib
import secrets
import time
INVITE_SECRET = settings.invite_secret # From .env — 32+ random bytes
INVITE_TTL_SECONDS = 48 * 3600
def generate_invite_token(invitation_id: str) -> str:
"""Generate a URL-safe HMAC-signed token embedding invite ID + timestamp."""
timestamp = str(int(time.time()))
payload = f"{invitation_id}:{timestamp}"
sig = hmac.new(
INVITE_SECRET.encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
# Encode as base64url for URL safety
import base64
raw = f"{payload}:{sig}"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def validate_invite_token(token: str) -> str:
"""Returns invitation_id if valid, raises ValueError if expired or tampered."""
import base64
# Pad base64
padded = token + "=" * (-len(token) % 4)
raw = base64.urlsafe_b64decode(padded).decode()
invitation_id, timestamp, provided_sig = raw.rsplit(":", 2)
# Constant-time comparison
expected_payload = f"{invitation_id}:{timestamp}"
expected_sig = hmac.new(
INVITE_SECRET.encode(),
expected_payload.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, provided_sig):
raise ValueError("Invalid token signature")
if int(time.time()) - int(timestamp) > INVITE_TTL_SECONDS:
raise ValueError("Invite token expired")
return invitation_id
```
### Pattern 5: SMTP Email via Python stdlib
**What:** Send invite emails using Python's `smtplib` + `email.mime`. Called from a Celery task (sync context — consistent with established codebase pattern that all Celery tasks are `sync def`).
**When to use:** Sending invite emails.
```python
# Source: Python stdlib email + smtplib
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
smtp_host: str,
smtp_port: int,
smtp_username: str,
smtp_password: str,
from_email: str,
) -> None:
"""Sync function — call from Celery task, not async FastAPI handler."""
msg = MIMEMultipart("alternative")
msg["Subject"] = f"You've been invited to join {tenant_name} on Konstruct"
msg["From"] = from_email
msg["To"] = to_email
text_body = f"""
Hi {invitee_name},
You've been invited to join {tenant_name} on Konstruct as an AI workforce administrator.
Accept your invitation and set up your account here:
{invite_url}
This link expires in 48 hours.
— The Konstruct Team
"""
msg.attach(MIMEText(text_body, "plain"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_username, smtp_password)
server.sendmail(from_email, to_email, msg.as_string())
```
### Anti-Patterns to Avoid
- **Checking role only in the UI:** Nav hiding is cosmetic. Every API endpoint that mutates data must also check role via FastAPI `Depends()`. The decision text explicitly states "defense in depth, not just hidden UI."
- **Using RLS for RBAC enforcement:** RLS enforces tenant isolation (which tenant's data). RBAC enforces what the user can DO within a tenant. These are separate layers — RLS is additive protection, not a substitute for endpoint guards.
- **Storing role in `portal_users` as a single column:** Customer admins can belong to multiple tenants with potentially different roles per tenant (admin in tenant A, operator in tenant B). The join table `user_tenant_roles` is required.
- **Database lookup in proxy.ts:** The official Next.js 16 docs explicitly warn: proxy should only read from cookies, not make DB calls. The proxy layer is for optimistic redirects only.
- **Skipping impersonation audit logging:** Impersonated actions must emit `AuditEvent` rows with `action_type='impersonation'` and the platform admin's user_id in `event_metadata`. This is a locked decision.
- **Async def for Celery email task:** The codebase has a hard constraint: all Celery tasks are `sync def` with `asyncio.run()`. The SMTP send function must follow this pattern.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| HMAC token timing-safe comparison | Custom string compare | `hmac.compare_digest()` | Prevents timing attacks — already used in WhatsApp signature verification (Phase 2) |
| Password hashing | Custom hash | `bcrypt` (already in use) | bcrypt already used for all PortalUser passwords |
| Email format validation | Regex | `pydantic[email]` (already in use) | Already declared in shared pyproject.toml |
| JWT claims augmentation | Custom token issuer | Auth.js v5 JWT callbacks (already in use) | Cleanest extension point for existing JWT strategy |
| Role enum validation | Custom if/else | PostgreSQL `CHECK` constraint + Python `enum.Enum` | DB-level constraint catches bugs at persistence layer |
**Key insight:** No new dependencies needed. All building blocks (HMAC, bcrypt, smtplib, FastAPI Depends, SQLAlchemy enum, Auth.js JWT callbacks) are already present in the codebase.
---
## Common Pitfalls
### Pitfall 1: `platform_admin` Bypassing Tenant Scope Must Be Explicit
**What goes wrong:** A `require_tenant_admin` dependency that checks tenant membership will block platform admins from cross-tenant operations unless the code explicitly short-circuits for `role == "platform_admin"`.
**Why it happens:** The membership check looks up `user_tenant_roles` — platform admin may not have rows in that table for most tenants.
**How to avoid:** Every `require_tenant_*` dependency must have: `if caller.role == "platform_admin": return caller` as the first check.
**Warning signs:** Platform admin getting 403 on cross-tenant endpoints.
### Pitfall 2: Auth.js v5 TypeScript Type Augmentation Required
**What goes wrong:** TypeScript errors when accessing `session.user.role` because the default Auth.js `User` and `Session` types don't include `role` or `tenant_ids`.
**Why it happens:** Auth.js v5 uses module augmentation for type extensions, not direct type overriding.
**How to avoid:** Create `lib/auth-types.ts` that extends Auth.js types:
```typescript
// lib/auth-types.ts
declare module "next-auth" {
interface User {
role?: string;
tenant_ids?: string[];
active_tenant_id?: string | null;
}
interface Session {
user: User & { id: string; role: string; tenant_ids: string[]; active_tenant_id: string | null };
}
}
declare module "next-auth/jwt" {
interface JWT {
role?: string;
tenant_ids?: string[];
active_tenant_id?: string | null;
}
}
```
**Warning signs:** TypeScript compilation errors on `session.user.role` in proxy.ts or nav components.
### Pitfall 3: Invitation Token Expiry Check Must Be at Accept Time, Not Just Display Time
**What goes wrong:** Checking only `invitation.expires_at < now()` in the UI still allows a race where a valid-looking token is submitted after expiry.
**Why it happens:** Frontend-only expiry check is not authoritative.
**How to avoid:** The FastAPI `/invitations/accept` endpoint must re-validate the token timestamp and check `portal_invitations.status == 'pending'` in an atomic DB operation. Mark invitation as `accepted` in the same transaction as creating the user account.
**Warning signs:** Accepted invites still show in pending list; double-activation possible if link clicked twice.
### Pitfall 4: Celery Task for Email Must Not Use `async def`
**What goes wrong:** An `async def` Celery task that calls `smtplib` (sync) or tries to use `await` in the task body — Celery's worker does not run an event loop natively.
**Why it happens:** Developer instinct to make everything async in an async codebase.
**How to avoid:** Celery tasks are always `sync def`. If async DB access is needed inside the task, use `asyncio.run()` (established pattern from Phase 1 — all existing Celery tasks do this).
**Warning signs:** `RuntimeError: no running event loop` in Celery worker logs.
### Pitfall 5: JWT Token Size Limit
**What goes wrong:** Adding `tenant_ids` (list of UUIDs) to the JWT makes the cookie exceed browser limits (~4KB) for users with many tenants.
**Why it happens:** JWT cookies are bounded by HTTP cookie size.
**How to avoid:** Store only `active_tenant_id` (single UUID) in the JWT. For users with multiple tenants, store the full list in a compact form (array of UUIDs, not full objects). Realistically, v1 users will have 1-3 tenants; this is a precaution, not an immediate crisis.
**Warning signs:** Auth.js session errors for users with >20 tenant memberships.
### Pitfall 6: `portal_users` Table Has No RLS — User Enumeration Risk
**What goes wrong:** The `/users` endpoint for global user management (platform admin only) queries `portal_users` without RLS. Without the `require_platform_admin` guard, any authenticated user could enumerate all users.
**Why it happens:** `portal_users` intentionally has no RLS (noted in the existing model comment: "RLS is NOT applied to this table"). Authorization is application-layer only.
**How to avoid:** Every endpoint that touches `portal_users` without a tenant filter MUST use `require_platform_admin`. Per-tenant user management endpoints use `require_tenant_admin` + filter by `user_tenant_roles.tenant_id`.
**Warning signs:** Customer admin able to see users from other tenants.
### Pitfall 7: `is_admin` → `role` Migration Must Handle Existing Data
**What goes wrong:** Alembic migration drops `is_admin` and adds `role` enum without migrating existing rows — existing platform admins lose access.
**Why it happens:** Schema-only migration without data backfill.
**How to avoid:** Migration must: (1) add `role` column with default `'customer_admin'`, (2) UPDATE rows where `is_admin = true` to `role = 'platform_admin'`, (3) then drop `is_admin`. Use a single migration step — do not split across multiple migrations.
**Warning signs:** Existing users cannot log in after migration.
---
## Code Examples
### Database Schema: New Tables
```python
# Source: SQLAlchemy 2.0 ORM pattern — established in packages/shared/shared/models/
import enum
class UserRole(str, enum.Enum):
PLATFORM_ADMIN = "platform_admin"
CUSTOMER_ADMIN = "customer_admin"
CUSTOMER_OPERATOR = "customer_operator"
class UserTenantRole(Base):
"""
Associates a portal user with a tenant and their role in that tenant.
A user can have different roles in different tenants (agency use case).
platform_admin users do not require rows here — they bypass tenant checks.
"""
__tablename__ = "user_tenant_roles"
__table_args__ = (
UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("portal_users.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(50), nullable=False) # TEXT with CHECK constraint — avoids SQLAlchemy Enum DDL issues (per Phase 1 decision)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class PortalInvitation(Base):
"""
Pending email invitations. Token is HMAC-signed and expires after 48 hours.
Status: 'pending' | 'accepted' | 'expired'
"""
__tablename__ = "portal_invitations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(50), nullable=False) # customer_admin | customer_operator
invited_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("portal_users.id"), nullable=False)
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) # SHA-256 hash of raw token
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
```
### `portal_users` Migration: `is_admin` → `role`
```python
# Source: Alembic migration pattern — established in migrations/versions/
def upgrade() -> None:
# 1. Add role column (nullable initially to allow backfill)
op.add_column("portal_users", sa.Column("role", sa.String(50), nullable=True))
# 2. Backfill: existing is_admin=True → platform_admin, others → customer_admin
op.execute("""
UPDATE portal_users
SET role = CASE WHEN is_admin = TRUE THEN 'platform_admin' ELSE 'customer_admin' END
""")
# 3. Add NOT NULL constraint now that all rows have a value
op.alter_column("portal_users", "role", nullable=False)
# 4. Add CHECK constraint (TEXT enum pattern — avoids SQLAlchemy Enum DDL issues per Phase 1 decision)
op.execute("""
ALTER TABLE portal_users
ADD CONSTRAINT ck_portal_users_role
CHECK (role IN ('platform_admin', 'customer_admin', 'customer_operator'))
""")
# 5. Drop is_admin column
op.drop_column("portal_users", "is_admin")
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `is_admin: bool` on PortalUser | `role: str` enum + `user_tenant_roles` join table | Phase 4 | Enables multi-tenant membership and typed roles |
| No API authorization | FastAPI `Depends(require_*)` guards on every endpoint | Phase 4 | All portal endpoints get 403 enforcement |
| No invite flow; direct registration via `/auth/register` | Invite-only user creation; `/auth/register` endpoint deprecated/removed | Phase 4 | All users created through auditable invite flow |
| `is_admin` in JWT | `role` + `tenant_ids` + `active_tenant_id` in JWT | Phase 4 | Proxy can redirect by role; tenant switcher uses active_tenant_id |
**Deprecated/outdated after Phase 4:**
- `portal_users.is_admin`: Replaced by `portal_users.role` + `user_tenant_roles`.
- `/api/portal/auth/register` endpoint: Replaced by invite-only flow. Should be removed or locked to platform_admin only with an immediate deprecation comment.
- `AuthVerifyResponse.is_admin` field: Replaced by `role` + `tenant_ids` + `active_tenant_id`.
---
## Open Questions
1. **Tenant switcher: re-issue JWT or use URL state?**
- What we know: The locked decision says "switch without logging out" and "no page reload." Auth.js v5 JWT strategy means the token is a signed cookie.
- What's unclear: Auth.js v5 does not natively support updating a token mid-session without a new sign-in. The `update()` function from `useSession()` can trigger a JWT refresh callback if implemented.
- Recommendation: Use Auth.js v5 `update()` session method which triggers the `jwt` callback with `trigger: "update"` — pass `{ active_tenant_id: newTenantId }` as the update payload. This is the supported pattern for mid-session JWT updates in Auth.js v5.
2. **`/auth/register` endpoint — remove or gate?**
- What we know: All user creation goes through invites per locked decision. The existing `/auth/register` endpoint allows direct account creation.
- What's unclear: Whether there's a seeding/bootstrap use case for initial platform admin creation.
- Recommendation: Keep the endpoint but gate it behind `require_platform_admin` with a deprecation notice. Initial platform admin seeded via a one-time script or environment variable bootstrap (not via the portal).
3. **SMTP configuration approach**
- What we know: SMTP direct is locked; configuration approach is discretionary.
- Recommendation: Store SMTP config in `.env` / `settings` (same pattern as all other secrets — `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`). No portal settings UI needed for v1.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest 8.3+ with pytest-asyncio 0.25+ |
| Config file | `pyproject.toml` (`[tool.pytest.ini_options]`) |
| Quick run command | `pytest tests/unit -x` |
| Full suite command | `pytest tests/ -x` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| RBAC-01 | Platform admin gets 200 on cross-tenant endpoints; non-admin gets 403 | unit | `pytest tests/unit/test_rbac_guards.py -x` | Wave 0 |
| RBAC-02 | Customer admin gets 200 on own-tenant endpoints; gets 403 on other tenants | unit | `pytest tests/unit/test_rbac_guards.py -x` | Wave 0 |
| RBAC-03 | Customer operator gets 403 on mutating endpoints; gets 200 on GET endpoints | unit | `pytest tests/unit/test_rbac_guards.py -x` | Wave 0 |
| RBAC-04 | Invite creation, token generation, token validation (TTL + HMAC), accept flow | unit | `pytest tests/unit/test_invitations.py -x` | Wave 0 |
| RBAC-04 | Full invite→accept integration: invite created, email triggered, user activated | integration | `pytest tests/integration/test_invite_flow.py -x` | Wave 0 |
| RBAC-05 | JWT contains role + tenant_ids after verify; active_tenant_id present | unit | `pytest tests/unit/test_portal_auth.py -x` | Wave 0 (extend existing test_portal_tenants.py pattern) |
| RBAC-06 | Every portal endpoint returns 403 without role headers; returns 200 with correct role | integration | `pytest tests/integration/test_portal_rbac.py -x` | Wave 0 |
### Sampling Rate
- **Per task commit:** `pytest tests/unit -x`
- **Per wave merge:** `pytest tests/ -x`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `tests/unit/test_rbac_guards.py` — unit tests for FastAPI `require_platform_admin`, `require_tenant_admin`, `require_tenant_member` dependencies
- [ ] `tests/unit/test_invitations.py` — unit tests for HMAC token generation, expiry validation, token tampering detection
- [ ] `tests/integration/test_invite_flow.py` — end-to-end invite creation → email mock → accept → login
- [ ] `tests/integration/test_portal_rbac.py` — covers RBAC-06: all portal endpoints tested with correct/incorrect role headers
---
## Sources
### Primary (HIGH confidence)
- Official Next.js 16 docs (bundled): `packages/portal/node_modules/next/dist/docs/01-app/02-guides/authentication.md` — proxy-layer auth pattern, optimistic check guidance, Data Access Layer recommendation
- Official Next.js 16 docs (bundled): `packages/portal/node_modules/next/dist/docs/01-app/02-guides/redirecting.md``NextResponse.redirect` in proxy.ts
- Codebase review: `packages/shared/shared/models/auth.py` — current PortalUser schema (is_admin flag)
- Codebase review: `packages/portal/lib/auth.ts` — Auth.js v5 JWT callbacks (existing pattern to extend)
- Codebase review: `packages/shared/shared/api/portal.py` — all existing endpoints needing guards
- Codebase review: `packages/portal/proxy.ts` — proxy.ts structure to extend
- Codebase review: `migrations/versions/001_initial_schema.py` — TEXT+CHECK pattern for enum columns (Phase 1 decision)
- Codebase review: `packages/shared/shared/models/audit.py` — AuditEvent model for impersonation logging
- Codebase review: `.planning/STATE.md` — critical architecture decisions from all prior phases
### Secondary (MEDIUM confidence)
- Python stdlib `smtplib` and `email.mime` documentation — no version dependency, stable since Python 3.x
- Auth.js v5 `update()` session method — documented in Auth.js v5 beta docs; consistent with JWT callback `trigger: "update"` pattern
### Tertiary (LOW confidence)
- Auth.js v5 module augmentation TypeScript pattern — inferred from Auth.js v5 docs and TypeScript convention; confirmed functional in existing portal TypeScript setup
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all core dependencies already in codebase; no new libraries introduced
- Architecture patterns: HIGH — FastAPI Depends() and Auth.js JWT callbacks are established patterns; schema migration pattern confirmed from prior phases
- Pitfalls: HIGH — directly derived from prior phase decisions logged in STATE.md (TEXT+CHECK for enums, sync Celery tasks, portal_users has no RLS)
**Research date:** 2026-03-24
**Valid until:** 2026-05-24 (stable stack — all libraries at fixed versions in pyproject.toml)

View File

@@ -0,0 +1,82 @@
---
phase: 4
slug: rbac
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-03-24
---
# Phase 4 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | pytest 8.x + pytest-asyncio (existing) |
| **Config file** | `pyproject.toml` (existing) |
| **Quick run command** | `pytest tests/unit -x -q` |
| **Full suite command** | `pytest tests/ -x` |
| **Estimated runtime** | ~30 seconds |
---
## Sampling Rate
- **After every task commit:** Run `pytest tests/unit -x -q`
- **After every plan wave:** Run `pytest tests/ -x`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 30 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 04-xx | 01 | 1 | RBAC-01,02,03 | unit | `pytest tests/unit/test_rbac_guards.py -x` | ❌ W0 | ⬜ pending |
| 04-xx | 01 | 1 | RBAC-04 | unit | `pytest tests/unit/test_invitations.py -x` | ❌ W0 | ⬜ pending |
| 04-xx | 01 | 1 | RBAC-05 | unit | `pytest tests/unit/test_portal_auth.py -x` | ❌ W0 | ⬜ pending |
| 04-xx | 01 | 1 | RBAC-06 | integration | `pytest tests/integration/test_portal_rbac.py -x` | ❌ W0 | ⬜ pending |
| 04-xx | 02 | 2 | RBAC-04 | integration | `pytest tests/integration/test_invite_flow.py -x` | ❌ W0 | ⬜ pending |
| 04-xx | 02 | 2 | RBAC-05 | unit | `cd packages/portal && npx next build` | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `tests/unit/test_rbac_guards.py` — RBAC-01,02,03: FastAPI require_* dependency tests
- [ ] `tests/unit/test_invitations.py` — RBAC-04: HMAC token generation, expiry, tampering detection
- [ ] `tests/unit/test_portal_auth.py` — RBAC-05: JWT contains role + tenant_ids
- [ ] `tests/integration/test_invite_flow.py` — RBAC-04: end-to-end invite → accept → login
- [ ] `tests/integration/test_portal_rbac.py` — RBAC-06: all endpoints tested with correct/incorrect roles
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Role-specific landing pages render correctly | RBAC-05 | UI visual layout | Login as each role, verify correct dashboard renders |
| Tenant switcher dropdown works | RBAC-05 | UI interaction | Login as multi-tenant user, switch tenants, verify context changes |
| Impersonation banner visible and exit works | RBAC-01 | UI interaction | Platform admin clicks "view as", verify banner shows, click exit |
| Invite email arrives and link works | RBAC-04 | Requires live SMTP | Send invite, check inbox, click link, complete activation |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 30s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending