Files
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

19 KiB
Raw Permalink Blame 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
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:

  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:

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. RequireStaffresolve_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:
{
  "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"]
}
  1. If caching capabilities by (company_id, feature_etag), keep that key. Invalidate on plan assign, plans.features write, global gate write — unchanged.
  2. 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.
  3. 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)

  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:

{ "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).

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