# 06 — Platform staff roles **Agent:** 6/20 **Contract:** [`04-contract.md`](./04-contract.md) §3 **Status:** Implemented (additive schema + middleware + admin APIs + tests) --- ## PROBLEM Platform access was a single boolean (`users.is_platform_admin`). Support desk and billing/plan mutation shared the same gate. Contract 04 requires three staff roles with least privilege for `support_staff`. --- ## Model | Column | Meaning | |--------|---------| | `users.is_platform_admin` | Retained. Any platform console access (including support_staff). | | `users.staff_role` | `admin` \| `developer` \| `support_staff` \| NULL | **Resolution** (`ResolveStaffRole` / `ResolveStaffAccess` in `apps/api/internal/auth/staff.go`): 1. Inactive → none 2. `staff_role` if set 3. Else `is_platform_admin` → treat as `admin` (legacy back-compat) 4. Else none **Capabilities:** | Role | Full `/api/admin/*` | Support desk | Notes | |------|:-------------------:|:------------:|-------| | `admin` | yes | yes | Full console | | `developer` | yes | yes | Same as admin; env-gated dev tools unchanged | | `support_staff` | **no** | yes | Tickets only — no plans/billing/settings/credits | | legacy `is_platform_admin` + NULL role | yes | yes | Migrated admins | **Invariant:** assigning a non-empty `staff_role` sets `is_platform_admin=true`. Clearing role clears both. --- ## Schema Migration: `apps/api/sql/schema/029_staff_roles.sql` - Additive `staff_role` CHECK + partial index - Idempotent backfill: `staff_role='admin'` where `is_platform_admin` and role NULL - Migrator `applyPlatformAdmins` also `COALESCE(staff_role, 'admin')` --- ## Middleware (extends existing — no parallel auth) | Middleware | Allows | |------------|--------| | `RequirePlatformAdmin` | `FullAdmin` (admin/developer/legacy) — **excludes** support_staff | | `RequireSupportDesk` | `SupportDesk` (admin/developer/support_staff/legacy) | | `IsPlatformAdmin` / `checkPlatformAdmin` | Now resolves via `GetStaffAccess().FullAdmin` | Router (`server.go`): - `/api/admin/support/tickets*` → `RequireSupportDesk` - All other `/api/admin/*` → `RequirePlatformAdmin` - Staff assign APIs sit under full admin group `StaffRoleAllowsAdminRoute`: support_staff → `/admin/support*` only (contract 04). --- ## APIs (admin \| developer only) | Method | Path | Body | Behavior | |--------|------|------|----------| | `GET` | `/api/admin/staff` | — | List users with staff access | | `PATCH` | `/api/admin/users/{id}/staff-role` | `{"staff_role":"admin"\|"developer"\|"support_staff"\|null}` | Assign/clear; **cannot change own role** | | `GET` | `/api/admin/users` | — | Includes `staff_role` | | `GET` | `/api/auth/me` | — | Additive `staff_access`, `staff_capabilities` when staff | Errors: 400 invalid role, 403 self-change / capability, 404 unknown user. CSRF still required on mutating admin routes. Support convenience (also full-admin): `PUT /api/admin/support/agents/{id}` grants/revokes `support_staff` only (does not demote admin/developer). --- ## Tests - `internal/auth`: `TestResolveStaffAccess`, `TestNormalizeStaffRole`, `TestStaffCapabilities`, `TestStaffRoleAllowsAdminRouteContract` - `internal/httpapi`: `TestRequirePlatformAdmin*` (incl. support_staff forbidden), `TestRequireSupportDesk`, `TestHandleAdminSetStaffRoleRejectsSelf` ```text go test ./internal/auth/ ./internal/httpapi/ -count=1 ``` --- ## Files touched - `apps/api/sql/schema/029_staff_roles.sql` - `apps/api/internal/auth/staff.go`, `staff_test.go`, `staff_role_defaults.go`, `service.go`, `invites.go` - `apps/api/internal/httpapi/middleware.go` (existing Require*), `server.go`, `admin_staff_handlers.go`, `admin_handlers.go`, `auth_handlers.go`, `admin_authz_test.go` - `apps/api/cmd/migrator/admins.go` - `apps/api/internal/support/agents.go` (platform_admin invariant on grant/revoke) --- ## Out of scope / follow-ups - AdminNav UI filter (agent 7/10/14) — consume `staff_access` / `staff_capabilities` from `/me` - Ticket queue assignee filtering (agent 12/14) - Do not conflate with company `memberships.role` or LLM AI roles