Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user