277 lines
10 KiB
JSON
277 lines
10 KiB
JSON
{
|
|||
|
|
"agent": "04/20",
|
||
|
|
"title": "Unified contract: plan features + staff roles + legacy",
|
||
|
|
"status": "design_only",
|
||
|
|
"version": "1.0.0",
|
||
|
|
"coordinates_with": [
|
||
|
|
"docs/admin-roles-support/01-ux-research.md",
|
||
|
|
"docs/admin-roles-support/02-current-inventory.md",
|
||
|
|
"docs/admin-roles-support/03-roles-matrix.md",
|
||
|
|
"docs/admin-roles-support/03-roles-matrix.json",
|
||
|
|
"docs/plan-permissions/03-permission-contract.md",
|
||
|
|
"docs/plan-permissions/03-permission-contract.json",
|
||
|
|
"docs/plan-permissions/01-feature-keys.json",
|
||
|
|
"docs/plan-permissions/06-defaults-matrix.json"
|
||
|
|
],
|
||
|
|
"assumptions": [
|
||
|
|
"Product cohort (legacy/standard/custom) is a plan feature_profile, not a stored end-user RBAC role; legacy_user/standard_user are derived labels",
|
||
|
|
"support_staff is least privilege: ticket queue assign/reply only — narrower than optional read-assist in 03",
|
||
|
|
"packages ≡ plans rows; no parallel permission service outside billing + auth staff flags",
|
||
|
|
"is_platform_admin retained; staff_role additive with null+admin-flag ⇒ staff_role admin back-compat",
|
||
|
|
"A1/Local Demo Co Enterprise → Legacy assign is explicit ops step, not silent migration"
|
||
|
|
],
|
||
|
|
"axes": {
|
||
|
|
"plan_features": {
|
||
|
|
"subject": "company.active_plan",
|
||
|
|
"storage": ["plans.features", "plans.feature_profile", "platform_feature_gates"],
|
||
|
|
"resolver": "ResolveEffectiveFeatures / CapabilitiesForCompany",
|
||
|
|
"failure": { "http": 402, "code": "plan_gate" }
|
||
|
|
},
|
||
|
|
"company_membership": {
|
||
|
|
"subject": "user in company",
|
||
|
|
"storage": "memberships.role",
|
||
|
|
"values": ["admin", "member"],
|
||
|
|
"notes": "Orthogonal; do not overload with platform admin"
|
||
|
|
},
|
||
|
|
"platform_staff": {
|
||
|
|
"subject": "user",
|
||
|
|
"storage": ["users.is_platform_admin", "users.staff_role"],
|
||
|
|
"resolver": "resolve_staff_role + staff_allows(capability)",
|
||
|
|
"failure": { "http": 403, "error": "staff capability required" }
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"runtime_formulas": {
|
||
|
|
"effective_feature": "plan_allows(key) AND global_section_enabled(section(key)) AND global_feature_enabled(key)",
|
||
|
|
"plan_allows": [
|
||
|
|
"if key in plans.features -> plans.features[key]",
|
||
|
|
"else if resolve_plan_profile == legacy -> LegacyMatrix[key] (missing -> false)",
|
||
|
|
"else if resolve_plan_profile == custom -> true",
|
||
|
|
"else -> DefaultPlanFeatures(name, false)[key]"
|
||
|
|
],
|
||
|
|
"resolve_plan_profile_priority": [
|
||
|
|
"plans.feature_profile if set (legacy|ladder|custom)",
|
||
|
|
"legacy name patterns from 03-roles-matrix",
|
||
|
|
"IsCustomPackage -> custom",
|
||
|
|
"else ladder"
|
||
|
|
],
|
||
|
|
"staff_allows": "user.is_active AND resolve_staff_role(user) in allowed_roles(capability)",
|
||
|
|
"resolve_staff_role": [
|
||
|
|
"inactive -> none",
|
||
|
|
"staff_role if not null",
|
||
|
|
"else if is_platform_admin -> admin",
|
||
|
|
"else none"
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"legacy": {
|
||
|
|
"profile": "legacy",
|
||
|
|
"shipping_modes": [
|
||
|
|
{
|
||
|
|
"id": "named_profile",
|
||
|
|
"preferred": true,
|
||
|
|
"mechanism": "plans.feature_profile='legacy' or name-pattern derive; DefaultPlanFeatures/LegacyMatrix"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "sparse_or_dense_features",
|
||
|
|
"preferred": false,
|
||
|
|
"mechanism": "plans.features explicit map; requires profile precedence so missing keys are not custom-all-on"
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"must_not": "Fall through IsCustomPackage all-ON for A1/legacy names",
|
||
|
|
"allowlist_authority": "docs/admin-roles-support/03-roles-matrix.md",
|
||
|
|
"excluded_sections": [
|
||
|
|
"processing.monitor",
|
||
|
|
"stores.*",
|
||
|
|
"marketing.*",
|
||
|
|
"integrations.*",
|
||
|
|
"support.*"
|
||
|
|
],
|
||
|
|
"name_patterns_ref": "docs/admin-roles-support/03-roles-matrix.json#plan_name_patterns",
|
||
|
|
"a1_example": {
|
||
|
|
"legacy_company_id": "97e1a309-3d23-4aa2-b518-8e8d7afdfec7",
|
||
|
|
"demo_note": "Local Demo Co may still be on Enterprise until explicit Legacy assign"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"staff_roles": {
|
||
|
|
"values": ["admin", "developer", "support_staff"],
|
||
|
|
"storage": {
|
||
|
|
"keep": "users.is_platform_admin",
|
||
|
|
"add": {
|
||
|
|
"column": "users.staff_role",
|
||
|
|
"type": "TEXT NULL CHECK IN (admin, developer, support_staff)",
|
||
|
|
"invariant": "staff_role set implies is_platform_admin=true"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"backfill": "UPDATE users SET staff_role='admin' WHERE is_platform_admin AND staff_role IS NULL",
|
||
|
|
"capabilities": {
|
||
|
|
"staff.admin_shell": ["admin", "developer", "support_staff"],
|
||
|
|
"staff.support.queue": ["admin", "developer", "support_staff"],
|
||
|
|
"staff.support.reply": ["admin", "developer", "support_staff"],
|
||
|
|
"staff.support.assign": ["admin", "developer", "support_staff"],
|
||
|
|
"staff.users.read": ["admin", "developer"],
|
||
|
|
"staff.users.write": ["admin", "developer"],
|
||
|
|
"staff.analytics": ["admin", "developer"],
|
||
|
|
"staff.billing": ["admin", "developer"],
|
||
|
|
"staff.plans_features": ["admin", "developer"],
|
||
|
|
"staff.feature_gates": ["admin", "developer"],
|
||
|
|
"staff.settings": ["admin", "developer"],
|
||
|
|
"staff.jobs_stuck": ["admin", "developer"],
|
||
|
|
"staff.impersonate": ["admin", "developer"],
|
||
|
|
"staff.dev_password": ["admin", "developer"]
|
||
|
|
},
|
||
|
|
"support_staff_least_privilege": {
|
||
|
|
"allowed_web": ["/admin/support", "/admin/support/[id]"],
|
||
|
|
"allowed_api": [
|
||
|
|
"GET /api/admin/support/tickets",
|
||
|
|
"GET /api/admin/support/tickets/{id}",
|
||
|
|
"POST /api/admin/support/tickets/{id}/messages",
|
||
|
|
"PATCH /api/admin/support/tickets/{id}"
|
||
|
|
],
|
||
|
|
"denied": [
|
||
|
|
"billing",
|
||
|
|
"plans",
|
||
|
|
"feature_gates",
|
||
|
|
"credits",
|
||
|
|
"settings",
|
||
|
|
"users_write",
|
||
|
|
"impersonate",
|
||
|
|
"stuck_cleanup",
|
||
|
|
"self_escalate_staff_role"
|
||
|
|
],
|
||
|
|
"deferred": "ticket-side read-only company context without tenant write APIs"
|
||
|
|
},
|
||
|
|
"middleware": {
|
||
|
|
"prefer": ["RequireStaff", "RequireStaffCapability"],
|
||
|
|
"extend": [
|
||
|
|
"apps/api/internal/httpapi/middleware.go#RequirePlatformAdmin",
|
||
|
|
"apps/api/internal/httpapi/middleware.go#checkPlatformAdmin",
|
||
|
|
"apps/api/internal/auth/invites.go#IsPlatformAdmin"
|
||
|
|
],
|
||
|
|
"router_today": "apps/api/internal/httpapi/server.go /api/admin RequireSession+RequirePlatformAdmin",
|
||
|
|
"ui": [
|
||
|
|
"apps/web/src/lib/admin-gate.ts",
|
||
|
|
"apps/web/src/lib/components/AdminNav.svelte"
|
||
|
|
]
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"security": {
|
||
|
|
"session": "scs cookie + RequireSession; staff flags from DB not client claims",
|
||
|
|
"csrf": "double-submit X-CSRF-Token on dashboard /api including /api/admin; v1/public/webhooks exempt",
|
||
|
|
"company_isolation": [
|
||
|
|
"tenant APIs RequireCompany scoped",
|
||
|
|
"admin support cross-company ticket view does not open tenant write APIs",
|
||
|
|
"impersonate admin|developer non-prod only; never support_staff",
|
||
|
|
"allowCompanyAdminOrPlatform: only admin|developer count as platform bypass — not support_staff"
|
||
|
|
],
|
||
|
|
"no_privilege_escalation": [
|
||
|
|
"capability middleware on admin routes",
|
||
|
|
"no self-service staff_role",
|
||
|
|
"plan features never grant /admin",
|
||
|
|
"staff never granted by plan JSON",
|
||
|
|
"fail closed on missing role/capability"
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"performance": {
|
||
|
|
"existing": {
|
||
|
|
"symbol": "featureETag",
|
||
|
|
"path": "apps/api/internal/billing/plan_features.go",
|
||
|
|
"semantics": "sha256 of sorted enabled feature keys"
|
||
|
|
},
|
||
|
|
"rules": [
|
||
|
|
"Do not fold staff_role into feature_etag",
|
||
|
|
"Keep company capabilities cache keyed by company_id + feature_etag",
|
||
|
|
"Invalidate on plan assign, plans.features write, global gates write",
|
||
|
|
"etag remains pure function of effective enabled-key set (profile already reflected in map)",
|
||
|
|
"Resolve staff role once per request in middleware"
|
||
|
|
],
|
||
|
|
"me_payload_additive": {
|
||
|
|
"user.staff_role": "string|null",
|
||
|
|
"staff_capabilities": "string[]",
|
||
|
|
"credits.feature_profile": "legacy|ladder|custom|null",
|
||
|
|
"credits.feature_etag": "unchanged semantics"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"migration": {
|
||
|
|
"file_suggestion": "apps/api/sql/schema/027_staff_roles_legacy_profile.sql",
|
||
|
|
"idempotent": true,
|
||
|
|
"destructive": false,
|
||
|
|
"steps": [
|
||
|
|
"ALTER users ADD staff_role NULL CHECK",
|
||
|
|
"ALTER plans ADD feature_profile NULL CHECK",
|
||
|
|
"Backfill staff_role=admin for existing platform admins",
|
||
|
|
"Ensure Legacy plan row with feature_profile=legacy",
|
||
|
|
"Optional sparse plans.features from LegacyMatrix",
|
||
|
|
"Explicit A1/demo assign — no silent Enterprise downgrade",
|
||
|
|
"EnsureDefaultPlans must not clobber features or feature_profile"
|
||
|
|
],
|
||
|
|
"optional_flag": "STAFF_RBAC=1 to enforce capability middleware",
|
||
|
|
"breaking_changes": [],
|
||
|
|
"preserve": [
|
||
|
|
"is_platform_admin",
|
||
|
|
"public ladder names",
|
||
|
|
"Stripe public-ladder checkout",
|
||
|
|
"402 plan_gate shapes",
|
||
|
|
"support assignee_admin_user_id schema",
|
||
|
|
"CSRF session behavior"
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"reuse_symbols": {
|
||
|
|
"billing": [
|
||
|
|
"DefaultPlanFeatures",
|
||
|
|
"SparseDefaultOverrides",
|
||
|
|
"IsCustomPackage",
|
||
|
|
"PlanAllowsFeature",
|
||
|
|
"ResolveEffectiveFeatures",
|
||
|
|
"CapabilitiesForCompany",
|
||
|
|
"featureETag",
|
||
|
|
"EntitlementsForCompany"
|
||
|
|
],
|
||
|
|
"auth_http": [
|
||
|
|
"IsPlatformAdmin",
|
||
|
|
"RequirePlatformAdmin",
|
||
|
|
"checkPlatformAdmin",
|
||
|
|
"CSRF",
|
||
|
|
"RequireSession",
|
||
|
|
"allowCompanyAdminOrPlatform"
|
||
|
|
],
|
||
|
|
"support": [
|
||
|
|
"ListAdmin",
|
||
|
|
"GetAdmin",
|
||
|
|
"ReplyAsAgent",
|
||
|
|
"UpdateAdmin",
|
||
|
|
"handleAdminListSupportTickets"
|
||
|
|
],
|
||
|
|
"web": [
|
||
|
|
"requirePlatformAdmin",
|
||
|
|
"AdminNav",
|
||
|
|
"PlanPermissionsPanel",
|
||
|
|
"planCapabilities",
|
||
|
|
"Nav.svelte"
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"do_not_add": [
|
||
|
|
"parallel permissions package outside billing/auth",
|
||
|
|
"packages table",
|
||
|
|
"tenant feature_keys for /admin/*",
|
||
|
|
"mixing LLM admin-ai-roles into staff RBAC",
|
||
|
|
"staff fields inside feature_etag"
|
||
|
|
],
|
||
|
|
"verification": [
|
||
|
|
"legacy excludes processing/stores/marketing/integrations/support",
|
||
|
|
"A1/legacy profile not custom-all-on",
|
||
|
|
"custom non-legacy still all-on",
|
||
|
|
"ladder Free/Starter denials unchanged",
|
||
|
|
"null staff_role + is_platform_admin => admin",
|
||
|
|
"support_staff 403 on billing/plans/settings",
|
||
|
|
"support_staff AdminNav Support-only",
|
||
|
|
"CSRF still on admin mutations",
|
||
|
|
"feature_etag stable across staff_role-only changes",
|
||
|
|
"tenant APIs remain company-scoped for support_staff"
|
||
|
|
],
|
||
|
|
"open_questions_defaults": {
|
||
|
|
"a1_demo_cutover": "explicit_ops_assign",
|
||
|
|
"support_staff_sets_is_platform_admin": true,
|
||
|
|
"company_admin_platform_bypass": "admin_and_developer_only",
|
||
|
|
"legacy_seed_density": "profile_first_sparse_optional"
|
||
|
|
}
|
||
|
|
}
|