Files
descrybe/docs/admin-roles-support/04-contract.md
T

406 lines
19 KiB
Markdown
Raw Normal View History

# 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 |