Files
konstruct/.planning/phases/04-rbac/04-02-PLAN.md

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-rbac 02 execute 2
04-01
packages/portal/lib/auth.ts
packages/portal/lib/auth-types.ts
packages/portal/proxy.ts
packages/portal/components/nav.tsx
packages/portal/components/tenant-switcher.tsx
packages/portal/components/impersonation-banner.tsx
packages/portal/app/(dashboard)/invite/[token]/page.tsx
packages/portal/app/(dashboard)/users/page.tsx
packages/portal/app/(dashboard)/admin/users/page.tsx
packages/portal/app/(dashboard)/layout.tsx
true
RBAC-05
RBAC-04
RBAC-01
truths artifacts key_links
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
path provides contains
packages/portal/lib/auth-types.ts TypeScript module augmentation for Auth.js types with role + tenant fields declare module
path provides contains
packages/portal/lib/auth.ts Updated Auth.js config with role + tenant_ids in JWT token.role
path provides contains
packages/portal/proxy.ts Role-based redirects for unauthorized paths customer_operator
path provides contains
packages/portal/components/nav.tsx Role-filtered sidebar navigation useSession
path provides min_lines
packages/portal/components/tenant-switcher.tsx Tenant switcher dropdown component 30
path provides min_lines
packages/portal/components/impersonation-banner.tsx Impersonation indicator banner 15
path provides min_lines
packages/portal/app/(dashboard)/invite/[token]/page.tsx Invite acceptance page with password form 40
path provides min_lines
packages/portal/app/(dashboard)/users/page.tsx Per-tenant user management page 40
path provides min_lines
packages/portal/app/(dashboard)/admin/users/page.tsx Platform admin global user management page 40
from to via pattern
packages/portal/lib/auth.ts /api/portal/auth/verify fetch in authorize(), receives role + tenant_ids role.*tenant_ids
from to via pattern
packages/portal/proxy.ts packages/portal/lib/auth.ts reads session.user.role for redirect logic session.*user.*role
from to via pattern
packages/portal/components/nav.tsx next-auth/react useSession() to read role for nav filtering useSession
from to via pattern
packages/portal/components/tenant-switcher.tsx next-auth/react update() to change active_tenant_id in JWT update.*active_tenant_id
Portal RBAC integration: Auth.js JWT updates, role-based proxy redirects, role-filtered navigation, tenant switcher, impersonation banner, invite acceptance page, and user management pages.

Purpose: The portal must adapt its UI and routing based on user role — hiding restricted items for operators, redirecting unauthorized URL access, and providing user management for admins. Output: Updated auth config, proxy, nav, plus new components (tenant switcher, impersonation banner) and pages (invite acceptance, user management).

<execution_context> @/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md @/home/adelorenzo/.claude/get-shit-done/templates/summary.md </execution_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

From packages/shared/shared/api/portal.py (updated in Plan 01):

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):

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):

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):

// 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):

// 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):

// Currently shows all nav items to all users. Must filter by role.
const navItems = [ /* dashboard, tenants, agents, usage, billing, api-keys */ ];
Task 1: Auth.js JWT update + type augmentation + proxy role redirects packages/portal/lib/auth-types.ts, packages/portal/lib/auth.ts, packages/portal/proxy.ts Create `packages/portal/lib/auth-types.ts`: - Module augmentation for "next-auth" and "next-auth/jwt" - Extend `User` interface: `role?: string; tenant_ids?: string[]; active_tenant_id?: string | null;` - Extend `Session.user`: `role: string; tenant_ids: string[]; active_tenant_id: string | null;` - Extend `JWT`: `role?: string; tenant_ids?: string[]; active_tenant_id?: string | null;` - This file MUST be imported in auth.ts to ensure TypeScript picks up the augmentation
Update `packages/portal/lib/auth.ts`:
- Import `./auth-types` at the top (side-effect import for type augmentation)
- Update `authorize()` response type: replace `is_admin: boolean` with `role: string; tenant_ids: string[]; active_tenant_id: string | null`
- Update `jwt` callback: store `token.role = u.role`, `token.tenant_ids = u.tenant_ids`, `token.active_tenant_id = u.tenant_ids[0] ?? null`
- Add `trigger: "update"` handling in jwt callback: `if (trigger === "update" && session?.active_tenant_id) { token.active_tenant_id = session.active_tenant_id; }` — this enables the tenant switcher to update the JWT mid-session
- Update `session` callback: pass `role`, `tenant_ids`, `active_tenant_id` to session.user
- Remove all `is_admin` references

Update `packages/portal/proxy.ts`:
- Add invite acceptance path `/invite` to public paths (no auth required — the invite page must be accessible to unauthenticated users accepting an invite)
- After session check, extract `role` from session.user
- Define restricted path lists:
  - PLATFORM_ADMIN_ONLY = ["/admin"]
  - CUSTOMER_ADMIN_ONLY = ["/billing", "/settings/api-keys", "/users"]
- Role-based redirect logic (per locked decision — silent redirect, no 403 page):
  - customer_operator trying restricted paths -> redirect to "/agents"
  - customer_admin trying platform admin paths -> redirect to "/dashboard"
- Role-based landing page after login (replace current hardcoded "/dashboard"):
  - platform_admin -> "/dashboard"
  - customer_admin -> "/dashboard"
  - customer_operator -> "/agents"
- Pass role headers to API routes: When the request is forwarded to backend API routes, the proxy should add X-Portal-User-Id, X-Portal-User-Role, and X-Portal-Tenant-Id headers. NOTE: This may be handled in the Next.js API route layer or server actions rather than proxy.ts — check how existing portal API calls work. If the portal uses direct fetch() to the gateway from API routes, the headers should be added there. If proxy.ts doesn't handle API forwarding, skip this and document that API route handlers must add these headers.
cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30 Auth.js JWT contains role + tenant_ids + active_tenant_id. Proxy redirects operators away from restricted paths. Invite page is publicly accessible. TypeScript compiles without errors. Task 2: Role-filtered nav + tenant switcher + impersonation banner packages/portal/components/nav.tsx, packages/portal/components/tenant-switcher.tsx, packages/portal/components/impersonation-banner.tsx, packages/portal/app/(dashboard)/layout.tsx Update `packages/portal/components/nav.tsx`: - Import `useSession` from "next-auth/react" - Get session via `const { data: session } = useSession();` - Extract role from `session?.user?.role` - Add role visibility metadata to each nav item: - Dashboard: all roles - Tenants: platform_admin only - Employees (agents): all roles - Usage: all roles - Billing: platform_admin, customer_admin - API Keys: platform_admin, customer_admin - NEW — Users: platform_admin, customer_admin (href: "/users") - NEW — Platform (admin): platform_admin only (href: "/admin/users") - Filter navItems to only show items where current role is in the allowed list - Per locked decision: restricted items are HIDDEN, not disabled/grayed
Create `packages/portal/components/tenant-switcher.tsx`:
- "use client" component
- Uses `useSession()` to read `session.user.tenant_ids` and `session.user.active_tenant_id`
- Only renders if `tenant_ids.length > 1` (single-tenant users see nothing)
- Dropdown showing tenant names (fetch from /api/portal/tenants or pass as prop)
- On selection change, calls `update({ active_tenant_id: selectedId })` from useSession() — this triggers the JWT callback with `trigger: "update"`, updating the cookie without page reload
- Per specifics: "should feel instant — no page reload, just context switch"
- Use shadcn/ui Select or DropdownMenu component for the dropdown
- After switching, invalidate TanStack Query cache to refetch data for new tenant context

Create `packages/portal/components/impersonation-banner.tsx`:
- "use client" component
- Reads a custom JWT claim `impersonating_tenant_id` from session (or a query param/cookie set by platform admin)
- If impersonating, shows a fixed banner at top of viewport: "Viewing as [Tenant Name] — Exit" with a distinct background color (e.g., amber/yellow)
- Exit button clears impersonation state (calls update() to remove impersonating_tenant_id from JWT)
- Per specifics: "clear visual indicator so the admin knows they're viewing as a customer (and can exit easily)"

Update `packages/portal/app/(dashboard)/layout.tsx`:
- Add TenantSwitcher component to the sidebar area (after the brand section, before nav items)
- Add ImpersonationBanner component above the main content area
cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30 Nav filters items by role. Tenant switcher renders for multi-tenant users and switches context without reload. Impersonation banner shows when active. Layout integrates both components. TypeScript compiles clean. Task 3: Invite acceptance page + user management pages packages/portal/app/(dashboard)/invite/[token]/page.tsx, packages/portal/app/(dashboard)/users/page.tsx, packages/portal/app/(dashboard)/admin/users/page.tsx Create `packages/portal/app/(dashboard)/invite/[token]/page.tsx`: - NOTE: This page must be accessible WITHOUT authentication. If the (dashboard) layout requires auth, create this at `packages/portal/app/invite/[token]/page.tsx` instead (outside the dashboard layout group). Check existing route structure. - Reads `token` from URL params - On load, validates token client-side by calling a GET endpoint or just displays the form (server validates on submit) - Form fields: Password (min 8 chars), Confirm Password - On submit, POST to `/api/portal/invitations/accept` with `{ token, password }` - Success: show "Account created successfully" message, redirect to /login after 2 seconds - Error states: "This invitation has expired" (with note to contact admin for a new one), "Invalid invitation link", generic error - Per specifics: should feel professional — show tenant name and invitee name from the invite data if available - Use existing form patterns (standardSchemaResolver + zod v4 per Phase 1 decision)
Create `packages/portal/app/(dashboard)/users/page.tsx`:
- Per-tenant user management page (customer_admin + platform_admin access)
- Fetches users for the active tenant via API call (GET /api/portal/tenants/{tenant_id}/users — this endpoint needs to exist; if not created in Plan 01, add a note that Plan 03 must create it, or add a simple users list endpoint to invitations.py)
- Table showing: Name, Email, Role, Status (active/pending), Invited date
- "Invite User" button opens a form/dialog: name, email, role selector (admin/operator)
- For pending invitations: show "Resend" button (calls POST /api/portal/invitations/{id}/resend)
- Use TanStack Query for data fetching (established pattern)
- Use shadcn/ui Table, Button, Dialog components

Create `packages/portal/app/(dashboard)/admin/users/page.tsx`:
- Platform admin global user management page
- Fetches ALL users across all tenants (GET /api/portal/admin/users — platform_admin only endpoint; may need to be added to invitations.py or a new admin.py router)
- Table showing: Name, Email, Role, Tenant(s), Status, Created date
- Filter controls: by tenant (dropdown), by role (dropdown)
- "Invite User" button — same as per-tenant but with tenant selector added
- If the backend endpoints for user listing don't exist yet, create stub API calls that Plan 03 will wire up. Use TanStack Query with the expected endpoint paths.
cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30 Invite acceptance page renders password form and submits to accept endpoint. Per-tenant users page lists users with invite/resend capability. Platform admin users page shows cross-tenant user list with filters. All pages compile without TypeScript errors. - `cd packages/portal && npx tsc --noEmit` — zero TypeScript errors - `cd packages/portal && npx next build` — build succeeds - Login as platform_admin: JWT contains role="platform_admin", sees all nav items - Login as customer_operator: does not see Billing/API Keys/Users in nav, /billing redirects to /agents

<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 accepts token and creates account
  • User management pages exist for tenant admin and platform admin
  • Portal builds and TypeScript compiles clean </success_criteria>
After completion, create `.planning/phases/04-rbac/04-02-SUMMARY.md`