Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
19 KiB
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
Coordinates with: 01-ux-research.md, 02-current-inventory.md, 03-roles-matrix.md / .json, docs/plan-permissions/03-permission-contract.md
PROBLEM
Three concerns are blurred in production today:
- Legacy / A1 cohort should see a limited dashboard (nav allow-list in
03-roles-matrix) but currently resolves like custom all-ON viaIsCustomPackage. - Platform staff is a single boolean (
users.is_platform_admin) — noadmin/developer/support_staffsplit; support queue sits behind full admin. - 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 3’s 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 3’s 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 tenantfeature_keyregistry. - Use company
memberships.role = adminas 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:
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:
RequireStaff—resolve_staff_role ≠ none(replaces binary admin check for “any staff”).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/supportand 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_roleoris_platform_adminvia any API.
Optional later (not v1 contract): read-only company context cards on the ticket — still no tenant write APIs. Agent 3’s 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/ futurestaff_role) — never trust client claims alone (admin-gate.tsis UX only). - Inactive users (
is_active=false) fail all staff checks.
5.2 CSRF
- Dashboard
/api/*(including/api/admin/*) stays behind existing double-submitCSRFmiddleware (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/GetAdminmay 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. allowCompanyAdminOrPlatformmust not treatsupport_staffas company admin for invites/billing unless an explicit product decision says otherwise — default: platform staff ≠ company admin. Prefer checkingresolve_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:
CapabilitiesForCompanyresolves plan ∩ globals.featureETag(features)=sha256:of sorted enabled feature keys (plan_features.go).
Contract for extension:
- Do not put
staff_roleintofeature_etag— staff is per-user; features are per-company. Mixing forces every staff login to invalidate tenant feature caches. - Optional additive fields on
/api/auth/me:
{
"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"]
}
- If caching capabilities by
(company_id, feature_etag), keep that key. Invalidate on plan assign,plans.featureswrite, global gate write — unchanged. - If
feature_profileis 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. - 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)
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)
UPDATE users SET staff_role = 'admin' WHERE is_platform_admin = true AND staff_role IS NULL;- Ensure
Legacyplan row:feature_profile='legacy', meters as ops decide; do not setis_customin a way that bypasses legacy (resolver must honor profile first). - Optionally seed
plans.featuressparse false-map from LegacyMatrix for admin visibility. - A1 / Local Demo Co: do not auto-downgrade Enterprise without ops confirmation — document a one-shot assign script (
05-legacy-seedagent). Default contract: tools exist; cutover is explicit. EnsureDefaultPlans/ feature seeders: never clobber non-emptyfeaturesor explicitfeature_profile.
7.3 Rollout flags (optional)
| Flag | Purpose |
|---|---|
| (none required) | Additive columns default null → today’s 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_adminsemantics for existing admins.- Public ladder names, Stripe checkout rules, 402
plan_gateshapes. - 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:
{ "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=legacydoes not all-ON viaIsCustomPackage - Custom non-legacy deal still all-ON
- Free/Starter ladder denials unchanged (
06-defaults-matrix) is_platform_admin+ nullstaff_role⇒ behaves asadminsupport_staffcan hit support admin APIs; 403 on plans/settings/credits- support_staff AdminNav shows Support only
- CSRF still required on admin POST/PATCH
feature_etagunchanged when onlystaff_rolechanges- Company tenant APIs still company-scoped for support_staff sessions
- Migrator admin backfill sets
staff_role='admin'
10. Open questions (defaults chosen)
- A1 demo plan cutover — Local Demo Co is on Enterprise today; legacy assign is explicit ops step (default: do not silent-migrate).
- support_staff +
is_platform_admin— both true when role set (default). - Company-admin bypass for platform staff — only
admin/developer, notsupport_staff(default). - 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 |