Files
descrybe/docs/admin-roles-support/04-contract.md
T
greeneclipse 8580c996c3 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.
2026-08-09 22:47:43 +02:00

406 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04 — Unified contract: plan features + staff roles + legacy
**Status:** Design only — no app code in this doc. Implementers own schema/API/UI.
**Agent:** 4/20
**Machine-readable twin:** [`04-contract.json`](./04-contract.json)
**Coordinates with:** [`01-ux-research.md`](./01-ux-research.md), [`02-current-inventory.md`](./02-current-inventory.md), [`03-roles-matrix.md`](./03-roles-matrix.md) / [`.json`](./03-roles-matrix.json), [`docs/plan-permissions/03-permission-contract.md`](../plan-permissions/03-permission-contract.md)
---
## PROBLEM
Three concerns are blurred in production today:
1. **Legacy / A1 cohort** should see a **limited** dashboard (nav allow-list in `03-roles-matrix`) but currently resolves like **custom all-ON** via `IsCustomPackage`.
2. **Platform staff** is a single boolean (`users.is_platform_admin`) — no `admin` / `developer` / `support_staff` split; support queue sits behind full admin.
3. Implementers need one **additive** contract that extends Entitlements / plan features / platform admin flags — **not** a parallel permission service.
---
## CONTEXT (tools / sources)
| Source | Finding |
|--------|---------|
| Agent 1 UX | Keep plan entitlements and human/staff roles as **two axes**; never merge with LLM `admin-ai-roles` |
| Agent 2 inventory | Binary `is_platform_admin`; membership `admin\|member`; support admin under `RequirePlatformAdmin`; no legacy profile |
| Agent 3 matrix | Legacy ON keys + plan name patterns; staff role ceilings; company membership stays orthogonal |
| Plan-permissions 03 | `plans.features` sparse + globals + `ResolveEffectiveFeatures` / `featureETag` / `CapabilitiesForCompany` |
| Code | `DefaultPlanFeatures`, `IsCustomPackage`, `RequirePlatformAdmin`, `CSRF` + session on dashboard `/api/*` |
**ASSUMPTION:** Product cohort (`legacy` vs public ladder vs custom deal) is expressed as a **plan feature profile**, not a second end-user RBAC table. Agent 3s `legacy_user` / `standard_user` are **derived labels** for docs/UI, not stored membership roles.
**ASSUMPTION:** `support_staff` is **least privilege**: ticket queue (+ assign/reply) only — narrower than agent 3s optional read-assist/impersonation notes.
**ASSUMPTION:** Packages remain `plans` rows (no packages table) — same as plan-permissions contract.
---
## 1. Three orthogonal axes (normative)
| Axis | Question | Storage / resolver | Gates |
|------|----------|--------------------|-------|
| **A. Plan features** | What did this *company* buy / inherit? | `plans.features` + profile + globals | Tenant nav, `AssertFeature`, meters compose |
| **B. Company membership** | What can this *person* do *in that company*? | `memberships.role` (`admin` \| `member`) | Invites, team, API keys, Stripe portal |
| **C. Platform staff** | What can this *person* do on the *platform console*? | `users.is_platform_admin` + `users.staff_role` | `/admin/*`, `/api/admin/*` |
**Never:**
- Put `/admin/*` keys into the tenant `feature_key` registry.
- Use company `memberships.role = admin` as platform admin.
- Treat `is_custom` / client-deal packaging as the same thing as **legacy limited nav**.
- Mix LLM provider “AI roles” into this matrix.
```
# Tenant dashboard / tenant APIs (company-scoped)
effective_feature(key) =
plan_allows(company.active_plan, key)
AND global_section_enabled(section(key))
AND global_feature_enabled(key)
# Platform console (user-scoped, company plan irrelevant)
staff_allows(user, capability) =
user.is_active
AND resolve_staff_role(user) IN allowed_roles(capability)
```
Company membership checks stay as today (`requireCompanyAdmin`, `allowCompanyAdminOrPlatform`) and **compose** with `effective_feature` for tenant mutations (e.g. invite requires company admin **and** `settings.team_invite` when enforced).
---
## 2. Plan feature profiles (legacy + ladder + custom)
### 2.1 Profile enum
| Profile | Meaning | Default matrix source |
|---------|---------|------------------------|
| `legacy` | Migrated/limited cohort (A1 + explicit Legacy) | Allow-list in `03-roles-matrix` — all other registry keys **OFF** |
| `ladder` | Public Free→Enterprise by plan name | `06-defaults-matrix` / `DefaultPlanFeatures` |
| `custom` | Client deal / enable-all package | All registry keys ON unless override `false` |
### 2.2 How to select profile (priority)
```
resolve_plan_profile(plan) =
1. if plans.feature_profile IN ('legacy','ladder','custom') -> that value # preferred additive column
2. else if normalize(name) matches legacy patterns (03) -> 'legacy'
3. else if IsCustomPackage(name, is_custom) -> 'custom'
4. else -> 'ladder'
```
Legacy patterns (from agent 3): exact `legacy`; `(?i)^a1(\b|[\s_-])`; `(?i)a1\s*slovenija`; known A1 company id `97e1a309-…` when ops assign a Legacy plan to that tenant.
**Critical fix vs today:** Profile `legacy` **must not** fall through `IsCustomPackage` → all-ON. Name `A1` alone currently yields custom treatment (`custom_package_features_test`); the contract requires legacy detection **before** custom all-ON.
### 2.3 `plan_allows` (extends plan-permissions 03)
```
plan_allows(key) =
if key present in plans.features -> plans.features[key] # sparse or dense override wins
else if resolve_plan_profile(plan) == 'legacy' -> LegacyMatrix[key] # missing key -> false
else if resolve_plan_profile(plan) == 'custom' -> true
else -> DefaultPlanFeatures(name, false)[key] # ladder; unknown -> false
```
Globals unchanged:
```
effective_feature(key) = plan_allows(key)
AND global_section_enabled(section(key))
AND global_feature_enabled(key)
```
### 2.4 Two equivalent ways to ship legacy limited matrix
Both are valid; prefer (a) for clarity, (b) works without waiting on every resolver caller:
| Mode | Mechanism | When |
|------|-----------|------|
| **(a) Named profile `legacy`** | `plans.feature_profile = 'legacy'` (or name-pattern derive) + `DefaultPlanFeatures` / `LegacyMatrix` | Steady state; PlanPermissionsPanel badge “Legacy” |
| **(b) Sparse / dense `plans.features`** | Store explicit map: ON keys `true`, and either all OFF keys `false` **or** rely on profile so missing ≠ custom-all-on | Migration seed / one-off admin fix |
**Seed recommendation:** create/ensure a public-or-deal plan row named `Legacy` with `feature_profile='legacy'` and optionally materialize sparse overrides (false keys only) for audit in admin UI. Assign A1 / Local Demo Co **away from Enterprise all-ON** when the product intent is legacy nav (ops decision — see migration).
### 2.5 Symbols to extend (no parallel system)
| Concern | Extend |
|---------|--------|
| Defaults | `DefaultPlanFeatures`, `SparseDefaultOverrides`, `IsCustomPackage` (legacy short-circuit) |
| Resolve | `PlanAllowsFeature`, `ResolveEffectiveFeatures`, `CapabilitiesForCompany` |
| Admin | `PlanPermissionsPanel` + plan feature APIs — Legacy badge / “apply legacy profile” |
| Nav | Existing `planCapabilities.can` / `Nav.svelte` feature keys |
---
## 3. Platform staff roles
### 3.1 Role keys
| `staff_role` | Who | Ceiling |
|--------------|-----|---------|
| `admin` | Full platform operators | All staff capabilities |
| `developer` | Engineering / ops | Same as `admin` for console; **plus** non-prod debug tools (existing `admin_dev_handlers` stay env-gated) |
| `support_staff` | Support agents | **Ticket queue only** (list/get/reply/assign/status) |
### 3.2 Additive storage
Prefer extending users — **do not** replace `is_platform_admin` in v1:
```text
users.is_platform_admin -- keep: “has any platform console access”
users.staff_role TEXT NULL
CHECK (staff_role IS NULL OR staff_role IN ('admin','developer','support_staff'))
```
**Resolution:**
```
resolve_staff_role(user) =
if not user.is_active -> none
if user.staff_role IS NOT NULL -> user.staff_role
else if user.is_platform_admin -> 'admin' # back-compat for migrated admin_users
else -> none
```
**Invariant:** `staff_role` set ⇒ `is_platform_admin = true` (writers enforce). Clearing staff access sets both off / null.
**Migrator:** existing `applyPlatformAdmins` continues to set `is_platform_admin`; backfill `staff_role='admin'` where admin and role null.
### 3.3 Staff capability catalog (platform — not feature_keys)
| Capability | admin | developer | support_staff |
|------------|:-----:|:---------:|:-------------:|
| `staff.admin_shell` | ✓ | ✓ | ✓ (Support-only nav) |
| `staff.support.queue` | ✓ | ✓ | ✓ |
| `staff.support.reply` | ✓ | ✓ | ✓ |
| `staff.support.assign` | ✓ | ✓ | ✓ |
| `staff.users.read` | ✓ | ✓ | ✗ |
| `staff.users.write` | ✓ | ✓ | ✗ |
| `staff.analytics` | ✓ | ✓ | ✗ |
| `staff.billing` | ✓ | ✓ | ✗ |
| `staff.plans_features` | ✓ | ✓ | ✗ |
| `staff.feature_gates` | ✓ | ✓ | ✗ |
| `staff.settings` | ✓ | ✓ | ✗ |
| `staff.jobs_stuck` | ✓ | ✓ | ✗ |
| `staff.impersonate` | ✓* | ✓* | ✗ |
| `staff.dev_password` | ✓* | ✓* | ✗ |
\* Existing non-prod / env gates remain (`Config.IsProduction()`).
### 3.4 HTTP / UI mapping
| Surface | Gate |
|---------|------|
| `GET/PATCH /api/admin/support/tickets*`, `POST …/messages` | `staff.support.*` (`admin` \| `developer` \| `support_staff`) |
| All other `/api/admin/*` | `admin` \| `developer` only |
| Web `/admin/support/**` | support_staff allowed |
| Web `/admin/**` (billing, settings, users, …) | admin \| developer; support_staff → 403 / redirect |
| `AdminNav` | Filter `menuItems` by staff capability (Support only for `support_staff`) |
**Middleware shape (preferred):** keep `RequireSession` on `/api/admin`; replace blanket-only `RequirePlatformAdmin` with:
1. `RequireStaff``resolve_staff_role ≠ none` (replaces binary admin check for “any staff”).
2. `RequireStaffCapability(cap)` — per-route or route-group allow-list.
Until split lands, **do not** grant support_staff by setting `is_platform_admin` alone without capability middleware — that would privilege-escalate to billing.
### 3.5 Difference from plan features (explicit)
| | Plan features | Staff roles |
|-|---------------|-------------|
| Subject | Company (active plan) | User |
| Payload | `features` / `feature_etag` on credits & capabilities | `staff_role` / `staff_capabilities` on `/api/auth/me` |
| Failure | 402 `plan_gate` / `feature_disabled` | 403 `platform admin required` / `staff capability required` |
| Cache | Company-scoped capabilities cache | User session / me payload — **not** folded into `feature_etag` |
---
## 4. Support staff — least privilege
Normative for `support_staff`:
**Allowed**
- Open `/admin/support` and ticket detail.
- List / filter / search admin ticket queue.
- Reply as agent; update status / priority / `assignee_admin_user_id`.
**Denied**
- Platform billing, plan upsert, feature matrices, global feature gates, credits, cycle runs.
- Platform settings (mail, AI configs, etc.).
- User list mutations, set-password blast, impersonation, stuck-job cleanup.
- Tenant session switching without a future audited impersonation flow (out of scope for support_staff).
- Expanding own `staff_role` or `is_platform_admin` via any API.
**Optional later (not v1 contract):** read-only company context cards on the ticket — still **no** tenant write APIs. Agent 3s broader “read-assist” ceiling is **deferred**; least privilege wins.
Tenant `/support` (company-scoped tickets) remains gated by plan feature `support.center` and `RequireCompany` — unrelated to staff queue.
---
## 5. Security
### 5.1 Session
- Staff and tenant routes use the same cookie session (`scs`) + `RequireSession`.
- Staff flags loaded from **DB** (`IsPlatformAdmin` / future `staff_role`) — never trust client claims alone (`admin-gate.ts` is UX only).
- Inactive users (`is_active=false`) fail all staff checks.
### 5.2 CSRF
- Dashboard `/api/*` (including `/api/admin/*`) stays behind existing double-submit `CSRF` middleware (`X-CSRF-Token`).
- Public `/api/v1`, `/api/public/`, `/api/webhooks/` remain CSRF-exempt (API key / signature).
- No new CSRF bypass for staff tools.
### 5.3 Company isolation
- Tenant handlers keep `RequireCompany` + company-scoped queries.
- Admin support `ListAdmin` / `GetAdmin` may return cross-company ticket metadata; that does **not** open tenant data APIs.
- Impersonation (when enabled) is `admin`/`developer` + non-prod only; must audit; support_staff **cannot** impersonate.
- `allowCompanyAdminOrPlatform` must not treat `support_staff` as company admin for invites/billing unless an explicit product decision says otherwise — **default: platform staff ≠ company admin**. Prefer checking `resolve_staff_role ∈ {admin, developer}` if platform bypass remains for team cutover.
### 5.4 No privilege escalation
| Risk | Control |
|------|---------|
| support_staff hits `/api/admin/plans` | Capability middleware 403 |
| User sets `is_platform_admin` via profile API | No self-service; only admin/developer staff APIs (future) or DB/migrator |
| Plan feature ON does not grant `/admin` | Staff axis independent |
| Custom/legacy plan does not grant staff | Staff axis independent |
| Client sends `staff_role` on `/me` POST | Ignore; server is SOT |
| Feature override JSON includes admin keys | Reject unknown / non-registry keys on upsert (existing) |
Fail closed on missing staff role / capability.
---
## 6. Performance — capabilities + etag
Existing:
- `CapabilitiesForCompany` resolves plan ∩ globals.
- `featureETag(features)` = `sha256:` of sorted **enabled** feature keys (`plan_features.go`).
**Contract for extension:**
1. **Do not** put `staff_role` into `feature_etag` — staff is per-user; features are per-company. Mixing forces every staff login to invalidate tenant feature caches.
2. Optional additive fields on `/api/auth/me`:
```json
{
"user": {
"is_platform_admin": true,
"staff_role": "support_staff"
},
"credits": {
"features": { "...": true },
"feature_etag": "sha256:…",
"feature_profile": "legacy"
},
"staff_capabilities": ["staff.support.queue", "staff.support.reply", "staff.support.assign"]
}
```
3. If caching capabilities by `(company_id, feature_etag)`, keep that key. Invalidate on plan assign, `plans.features` write, global gate write — **unchanged**.
4. If `feature_profile` is exposed, include it in etag **input** only when it changes effective features (hash profile name + enabled keys, or keep hashing enabled keys alone since profile is already reflected in the map). Prefer **etag remains a pure function of the effective enabled-key set** so clients need not special-case profile.
5. Avoid N+1: resolve staff role once per request in middleware; do not re-query per admin handler beyond existing patterns.
---
## 7. Migration strategy (additive)
Suggested goose file: `027_staff_roles_legacy_profile.sql` (number may shift — after `026_plan_features`).
### 7.1 Schema (idempotent)
```sql
ALTER TABLE users
ADD COLUMN IF NOT EXISTS staff_role TEXT NULL
CHECK (staff_role IS NULL OR staff_role IN ('admin', 'developer', 'support_staff'));
ALTER TABLE plans
ADD COLUMN IF NOT EXISTS feature_profile TEXT NULL
CHECK (feature_profile IS NULL OR feature_profile IN ('legacy', 'ladder', 'custom'));
-- Optional helpful indexes
CREATE INDEX IF NOT EXISTS users_staff_role_idx ON users (staff_role)
WHERE staff_role IS NOT NULL;
```
No drops; no rewriting meters; ETL ignores unknown columns safely.
### 7.2 Data backfill (safe order)
1. `UPDATE users SET staff_role = 'admin' WHERE is_platform_admin = true AND staff_role IS NULL;`
2. Ensure `Legacy` plan row: `feature_profile='legacy'`, meters as ops decide; **do not** set `is_custom` in a way that bypasses legacy (resolver must honor profile first).
3. Optionally seed `plans.features` sparse false-map from LegacyMatrix for admin visibility.
4. **A1 / Local Demo Co:** do **not** auto-downgrade Enterprise without ops confirmation — document a one-shot assign script (`05-legacy-seed` agent). Default contract: *tools exist*; cutover is explicit.
5. `EnsureDefaultPlans` / feature seeders: never clobber non-empty `features` or explicit `feature_profile`.
### 7.3 Rollout flags (optional)
| Flag | Purpose |
|------|---------|
| (none required) | Additive columns default null → todays behavior via back-compat resolution |
| `STAFF_RBAC=1` | Enforce capability middleware (vs temporary “any platform admin”) |
| existing `FEATURES_ENFORCE` | Unrelated; keep for tenant AssertFeature |
### 7.4 BREAKING
**None preferred.** Additive columns and fields only. Preserve:
- `is_platform_admin` semantics for existing admins.
- Public ladder names, Stripe checkout rules, 402 `plan_gate` shapes.
- Support ticket schema (`assignee_admin_user_id`, etc.).
---
## 8. API / me contract (additive fields)
| Endpoint | Add |
|----------|-----|
| `GET /api/auth/me` | `user.staff_role`; optional `staff_capabilities[]`; `credits.feature_profile` |
| `GET /api/billing/capabilities` | `feature_profile` alongside existing `features` / `feature_etag` |
| `GET/POST /api/admin/plans` | `feature_profile` read/write for admin\|developer |
| Future | `PATCH /api/admin/users/{id}/staff-role` — admin\|developer only; validate escalation rules |
Errors:
```json
{ "error": "staff capability required", "capability": "staff.billing" }
```
HTTP 403 (not 402 — not a plan gate).
---
## 9. Verification checklist
- [ ] Legacy profile: `processing.monitor`, `stores.*`, `marketing.*`, `integrations.*`, `support.*` effective false
- [ ] Name `A1` / `feature_profile=legacy` does **not** all-ON via `IsCustomPackage`
- [ ] Custom non-legacy deal still all-ON
- [ ] Free/Starter ladder denials unchanged (`06-defaults-matrix`)
- [ ] `is_platform_admin` + null `staff_role` ⇒ behaves as `admin`
- [ ] `support_staff` can hit support admin APIs; 403 on plans/settings/credits
- [ ] support_staff AdminNav shows Support only
- [ ] CSRF still required on admin POST/PATCH
- [ ] `feature_etag` unchanged when only `staff_role` changes
- [ ] Company tenant APIs still company-scoped for support_staff sessions
- [ ] Migrator admin backfill sets `staff_role='admin'`
---
## 10. Open questions (defaults chosen)
1. **A1 demo plan cutover** — Local Demo Co is on Enterprise today; legacy assign is explicit ops step (default: do not silent-migrate).
2. **support_staff + `is_platform_admin`** — both true when role set (default).
3. **Company-admin bypass for platform staff** — only `admin`/`developer`, not `support_staff` (default).
4. **Dense vs sparse legacy seed** — profile + LegacyMatrix sufficient; sparse false keys optional for UI (default: profile-first).
---
## Related implementer docs (downstream agents)
| Doc | Expected owner |
|-----|----------------|
| `05-legacy-seed` | Seed Legacy plan + A1 assign runbook |
| `06-staff-roles` | Schema + middleware + AdminNav filter |
| Plan-permissions enforcement | Keep AssertFeature on tenant mutations |