Files

111 lines
4.1 KiB
Markdown
Raw Permalink Normal View History

# 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