diff --git a/.changeset/capability-reference-lint.md b/.changeset/capability-reference-lint.md new file mode 100644 index 000000000..de67396dc --- /dev/null +++ b/.changeset/capability-reference-lint.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/plugin-security': patch +'@objectstack/cli': patch +--- + +Author-time capability-reference lint (ADR-0066 ⑨) — `os validate` / `os lint` +now warn when a `requiredPermissions` names a capability that is registered +nowhere. + +`requiredPermissions` (on objects, fields, apps, actions) is a free string, so a +typo like `mange_users` is schema-valid and fails closed at runtime (the caller +is denied) — safe, but silent. The new `validateCapabilityReferences` rule +(`@objectstack/lint`) resolves every reference against the author-time known set +and warns on the unresolved ones: + +- built-in platform capabilities — now sourced from a single canonical list in + `@objectstack/spec` (`security/capabilities.ts`: `PLATFORM_CAPABILITIES` / + `PLATFORM_CAPABILITY_NAMES`), which `@objectstack/plugin-security`'s + `bootstrapSystemCapabilities` also seeds from (one source of truth, no drift), +- any capability a permission set in the stack grants via `systemPermissions` + (granting is what declares it — mirrors the runtime derived-defaults rule), and +- any `sys_capability` row shipped as seed data. + +It is a **warning**, not an error: a single package can't see capabilities +declared by other installed packages, and the reference fails closed anyway. +`systemPermissions` itself is never flagged — it is the declaration side, and a +package legitimately introduces new capabilities there. The object case also +understands the per-operation `requiredPermissions` map form (ADR-0066 ⑤) and +points a finding at the exact operation slice. diff --git a/.changeset/per-operation-required-permissions.md b/.changeset/per-operation-required-permissions.md new file mode 100644 index 000000000..de8f46605 --- /dev/null +++ b/.changeset/per-operation-required-permissions.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': minor +--- + +Per-operation object `requiredPermissions` (ADR-0066 ⑤) — an object can now be +read-open / write-gated instead of gating all of CRUD on one capability set. + +`Object.requiredPermissions` accepts either the original `string[]` (capabilities +required for **all** operations) **or** a `{ read?, create?, update?, delete? }` +map that gates each operation class independently — mirroring how Salesforce and +Dataverse separate capability by operation. plugin-security enforces the caps for +the request's operation class as the same D3 AND-gate (checked before the CRUD +grant, fail-closed). The mapping folds `transfer`/`restore` into `update` and +`purge` into `delete`, derived from the existing CRUD permission bits so it stays +in lockstep with them. + +Backward-compatible: the `string[]` form keeps its gate-every-operation semantics +(normalized into an `all` bucket that unions with the per-operation bucket), so +existing objects are unaffected. The per-operation map's keys are validated +`.strict()`, so a mistyped key (e.g. `reads`) is rejected at author time rather +than silently ignored. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index b45502d26..2966a1e74 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -170,12 +170,17 @@ The complete, prioritized gap map lives in issue **#2561** (the production - **Deny/muting layer** (ADR-0066 ⑦⑧) — union-only grants can't take access away; needed for large-org governance and packaged-set adjustment. - **Capability registry** (ADR-0066 D1) — **landed**: capabilities are seeded - as first-class `sys_capability` records - (`packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts`); - the remaining gap is the authoring lint for unregistered `systemPermissions` - references (ADR-0066 ⑨). -- **Per-operation `requiredPermissions`** (ADR-0066 ⑤) — read-open / - write-gated objects. + as first-class `sys_capability` records from the canonical list in + `@objectstack/spec` (`security/capabilities.ts` → `PLATFORM_CAPABILITIES`), + seeded by `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts`. + The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences` + (`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a + `requiredPermissions` names a capability registered nowhere — no built-in, no + permission set grants it via `systemPermissions`, no `sys_capability` seed. +- **Per-operation `requiredPermissions`** (ADR-0066 ⑤) — **landed**: an + object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a + `{ read, create, update, delete }` map (read-open / write-gated), enforced + per operation by plugin-security (`security-plugin.ts` capability AND-gate). - **Deny/muting subtract layer** (ADR-0005 overlay; ADR-0066 precedence step 4) — how an environment adjusts a *packaged* set without forking it; deferred until proven need (ADR-0086 P2). diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index d218c13ff..b1643173b 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -103,8 +103,8 @@ All of this is **open mechanism** (framework `spec` + `plugin-security`): schema Captured for the record; **out of scope for Phases 1–4 above**. Each is anchored to a mainstream-platform precedent. - **④ Deny-by-default target for sensitive objects.** Salesforce / Dataverse / ServiceNow / SAP are all deny-by-default; ObjectStack stays allow-by-default for *tenant business* objects (low-code ergonomics, à la Airtable/Notion within a workspace) but should make **system / control-plane / sensitive** objects `private` by default, ship genuine reference data (countries, currencies, picklists) as explicit `public`, and surface each object's posture visibly in Studio. The `access` flag (D2) is the primitive; this is a defaults + visibility call, staged per object — no forced migration. -- **⑤ Per-operation `requiredPermissions`.** Today object-level `requiredPermissions` gates all of CRUD. ERP routinely needs "read-open / write-gated" (Salesforce & Dataverse separate capability by operation). Allow `requiredPermissions` to be either `string[]` (all operations) or a per-operation map `{ read, create, update, delete }`. Field-level (D3) and action-level (D4) requirements already give finer control; this closes the object-level gap. +- **⑤ Per-operation `requiredPermissions`.** Today object-level `requiredPermissions` gates all of CRUD. ERP routinely needs "read-open / write-gated" (Salesforce & Dataverse separate capability by operation). Allow `requiredPermissions` to be either `string[]` (all operations) or a per-operation map `{ read, create, update, delete }`. Field-level (D3) and action-level (D4) requirements already give finer control; this closes the object-level gap. **Landed (2026-07):** spec `ObjectRequiredPermissionsSchema` union (`spec/data/object.zod.ts`); enforced per operation in `plugin-security/security-plugin.ts` (the `all` bucket preserves the array form's gate-everything semantics, so it is backward-compatible). `transfer`/`restore` fold into `update`, `purge` into `delete` via `crudBucketForOperation`. - **⑥ Capabilities in the expression surface.** Salesforce *Custom Permissions* are referenceable in formulas / validation / flows (`$Permission.X`). Expose the caller's held capabilities to the CEL/predicate surface (ADR-0058) so `visible` / validation / sharing predicates can branch on a capability. High-leverage once D1 makes capabilities first-class. - **⑦ Permission-set groups + subtractive *muting*.** Pure union does not scale governance ("permission-set explosion"); Salesforce added permission-set-group *muting* precisely to allow taking access away. Roles→permission-sets already bundle; a subtractive/deny layer (precedence step 4) is the missing piece for large-org administration. Pairs with delegated admin (#9). - **⑧ FLS posture: unlisted fields are visible, and field grants union without deny** *(recorded 2026-07 pre-launch architecture assessment)*. Runtime FLS is block-list-shaped: `field-masker.ts` hides a field only when an explicit rule marks it `readable:false`, so an **undeclared** sensitive field is exposed by default, and `getFieldPermissions` merges most-permissively, so one set's `readable:true` permanently out-votes another's `false`. Consequences: protecting a salary/ID-number field relies on discipline (grant it only where needed), not mechanism. The object-level `private` posture (D2/④) mitigates the unlisted-default-visible half for objects that adopt it; the union half is the field-level face of ⑦ — a muting/deny layer must cover **field** grants, not just object grants, when it lands. Until then, treat "sensitive field on a `public` object" as a smell in review. -- **⑨ Authoring-time validation for capability references** *(recorded 2026-07 pre-launch architecture assessment)*. `systemPermissions` / `requiredPermissions` are free strings today; a typo (`mange_users`) fails closed at runtime — the safe direction — but is undiscoverable: nothing reports that the referenced capability exists nowhere. When D1 lands the `sys_permission` registry, add the authoring/publish-gate lint alongside it: referencing an unregistered capability warns at author time (consistent with ADR-0049 honesty and the contract-first Prime Directive — reject at the producer, don't tolerate at the consumer). +- **⑨ Authoring-time validation for capability references** *(recorded 2026-07 pre-launch architecture assessment)*. `systemPermissions` / `requiredPermissions` are free strings today; a typo (`mange_users`) fails closed at runtime — the safe direction — but is undiscoverable: nothing reports that the referenced capability exists nowhere. When D1 lands the `sys_permission` registry, add the authoring/publish-gate lint alongside it: referencing an unregistered capability warns at author time (consistent with ADR-0049 honesty and the contract-first Prime Directive — reject at the producer, don't tolerate at the consumer). **Landed (2026-07):** `validateCapabilityReferences` in `@objectstack/lint` warns (never errors — cross-package capabilities and the fail-closed runtime make a hard gate wrong) when a `requiredPermissions` reference resolves against neither the built-in set (`@objectstack/spec` `PLATFORM_CAPABILITY_NAMES`), nor a capability the stack grants via a permission set's `systemPermissions`, nor a `sys_capability` seed row. Wired into `os validate` and `os lint`. `systemPermissions` (the declaration side) is deliberately not flagged. diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 42d665214..a7569f4be 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -8,7 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; import { validateWidgetBindings } from '@objectstack/lint'; -import { validateRecordTitle, validateSemanticRoles } from '@objectstack/lint'; +import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -337,6 +337,21 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Capability references (ADR-0066 ⑨) ── + // requiredPermissions naming a capability that is registered nowhere + // (no built-in, no permission set grants it, no sys_capability seed) is + // almost certainly a typo. Advisory — the reference fails closed at runtime, + // and the capability may legitimately be provided by another installed package. + for (const t of validateCapabilityReferences(config)) { + issues.push({ + severity: t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + return issues; } diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 6b084c4a1..43cd34ee6 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -13,6 +13,7 @@ import { validateListViewMode } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; +import { validateCapabilityReferences } from '@objectstack/lint'; import { printHeader, printKV, @@ -316,6 +317,21 @@ export default class Validate extends Command { } } + // 3f. Capability references (ADR-0066 ⑨): a requiredPermissions entry + // naming a capability registered nowhere (no built-in, no permission + // set grants it, no sys_capability seed) is almost certainly a typo — + // it fails closed at runtime. Advisory: the capability may legitimately + // be provided by another installed package. + if (!flags.json) printStep('Checking capability references (ADR-0066)...'); + const capFindings = validateCapabilityReferences(result.data as Record); + const capWarnings = capFindings.filter((f) => f.severity === 'warning'); + if (!flags.json) { + for (const w of capWarnings.slice(0, 50)) { + console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); + console.log(chalk.dim(` ${w.hint}`)); + } + } + // 4. Collect and display stats const stats = collectMetadataStats(config); @@ -324,7 +340,7 @@ export default class Validate extends Command { valid: true, manifest: config.manifest, stats, - warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings], + warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings], duration: timer.elapsed(), }, null, 2)); return; diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index aef1216e8..bfcdf8192 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -68,3 +68,9 @@ export { FORM_COLSPAN_ABSOLUTE, } from './validate-form-layout.js'; export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js'; + +export { + validateCapabilityReferences, + CAPABILITY_REFERENCE_UNKNOWN, +} from './validate-capability-references.js'; +export type { CapabilityRefFinding, CapabilityRefSeverity } from './validate-capability-references.js'; diff --git a/packages/lint/src/validate-capability-references.test.ts b/packages/lint/src/validate-capability-references.test.ts new file mode 100644 index 000000000..274f71885 --- /dev/null +++ b/packages/lint/src/validate-capability-references.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateCapabilityReferences, + CAPABILITY_REFERENCE_UNKNOWN, +} from './validate-capability-references'; + +describe('validateCapabilityReferences (ADR-0066 ⑨)', () => { + it('passes a reference to a built-in platform capability', () => { + const findings = validateCapabilityReferences({ + objects: [{ name: 'sys_license', requiredPermissions: ['manage_platform_settings'] }], + }); + expect(findings).toEqual([]); + }); + + it('passes a reference to a capability the stack grants via systemPermissions', () => { + const findings = validateCapabilityReferences({ + permissions: [{ name: 'billing_admin', systemPermissions: ['manage_billing'] }], + objects: [{ name: 'inv_invoice', requiredPermissions: ['manage_billing'] }], + }); + expect(findings).toEqual([]); + }); + + it('passes a reference to a capability shipped as a sys_capability seed row', () => { + const findings = validateCapabilityReferences({ + data: [{ object: 'sys_capability', records: [{ name: 'approve_invoice' }] }], + objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }], + }); + expect(findings).toEqual([]); + }); + + it('warns on an object requiredPermissions typo (registered nowhere)', () => { + const findings = validateCapabilityReferences({ + objects: [{ name: 'sys_license', requiredPermissions: ['mange_users'] }], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: CAPABILITY_REFERENCE_UNKNOWN, + where: 'object "sys_license"', + path: 'objects[0].requiredPermissions', + }); + expect(findings[0].message).toContain('mange_users'); + }); + + it('warns per operation for the per-operation map form (ADR-0066 ⑤) and points at the slice', () => { + const findings = validateCapabilityReferences({ + objects: [{ + name: 'inv_invoice', + requiredPermissions: { read: ['manage_metadata'], update: ['mange_invoices'] }, + }], + }); + // `manage_metadata` is built-in → ok; only the `update` typo warns. + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ path: 'objects[0].requiredPermissions.update' }); + expect(findings[0].message).toContain('mange_invoices'); + }); + + it('warns on field, action, and app references', () => { + const findings = validateCapabilityReferences({ + objects: [{ + name: 'hr_employee', + fields: { salary: { type: 'currency', requiredPermissions: ['view_salaryy'] } }, + actions: [{ name: 'promote', requiredPermissions: ['approve_promo'] }], + }], + apps: [{ + name: 'hr', + requiredPermissions: ['hr_admin'], + navigation: [{ type: 'object', objectName: 'hr_employee', requiredPermissions: ['hr_nav_cap'] }], + }], + actions: [{ name: 'run_payroll', requiredPermissions: ['run_payroll_cap'] }], + }); + const paths = findings.map((f) => f.path).sort(); + expect(paths).toEqual([ + 'actions[0].requiredPermissions', + 'apps[0].navigation[0].requiredPermissions', + 'apps[0].requiredPermissions', + 'objects[0].actions[0].requiredPermissions', + 'objects[0].fields.salary.requiredPermissions', + ]); + expect(findings.every((f) => f.severity === 'warning')).toBe(true); + }); + + it('does NOT flag systemPermissions itself (the declaration side)', () => { + // A package introduces a new capability by GRANTING it — never a warning. + const findings = validateCapabilityReferences({ + permissions: [{ name: 'p', systemPermissions: ['brand_new_capability'] }], + }); + expect(findings).toEqual([]); + }); + + it('tolerates junk / empty input', () => { + expect(validateCapabilityReferences({})).toEqual([]); + expect(validateCapabilityReferences(undefined as unknown as Record)).toEqual([]); + expect(validateCapabilityReferences({ objects: [null, 42, { name: 'x' }] as unknown })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts new file mode 100644 index 000000000..b7e0a90cf --- /dev/null +++ b/packages/lint/src/validate-capability-references.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0066 ⑨] Authoring-time validation for capability references. + * + * `requiredPermissions` (on objects, fields, apps, and actions) and + * `systemPermissions` (on permission sets) are free capability strings. A typo + * — `mange_users` for `manage_users` — is Zod-valid and fails CLOSED at runtime + * (the caller is denied), which is the safe direction but UNDISCOVERABLE: nothing + * tells the author the referenced capability exists nowhere. This rule closes + * that gap by resolving every `requiredPermissions` reference against the set of + * capabilities known at author time and warning on the unresolved ones — + * "reject at the producer" (Prime Directive / ADR-0049 honesty). + * + * The author-time "known" set is: + * 1. the built-in platform capabilities (`PLATFORM_CAPABILITY_NAMES`), + * 2. every capability a permission set in this stack GRANTS via + * `systemPermissions` (granting a capability is what declares it — mirrors + * the runtime `bootstrapSystemCapabilities` derived-defaults rule), and + * 3. any `sys_capability` row shipped as seed data. + * + * WARNING, not error: a single package's lint cannot see capabilities declared + * by OTHER installed packages, and the reference fails closed at runtime anyway, + * so a dangling reference is "almost certainly a typo" — surface it, don't break + * the build. Assignment (`systemPermissions`) is NOT flagged: it is the + * declaration side, and a package legitimately introduces new capabilities there. + */ + +import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; + +export const CAPABILITY_REFERENCE_UNKNOWN = 'capability-reference-unknown'; + +export type CapabilityRefSeverity = 'error' | 'warning'; + +export interface CapabilityRefFinding { + /** Always `warning` — the reference fails closed at runtime (see module note). */ + severity: CapabilityRefSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `object "sys_license"`. */ + where: string; + /** Config path, e.g. `objects[3].requiredPermissions`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** The capability strings in a `string[]` value. */ +function asCapArray(v: unknown): string[] { + return Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.length > 0) : []; +} + +/** + * Flatten an object-level `requiredPermissions` — either a `string[]` (all + * operations) or a per-operation `{ read, create, update, delete }` map (ADR-0066 + * ⑤) — into `[{ cap, key }]`, where `key` is the map key (or `undefined` for the + * array form) so a finding can point at the exact operation slice. + */ +function flattenObjectRequired(v: unknown): Array<{ cap: string; key?: string }> { + if (Array.isArray(v)) return asCapArray(v).map((cap) => ({ cap })); + if (v && typeof v === 'object') { + const out: Array<{ cap: string; key?: string }> = []; + for (const [key, val] of Object.entries(v as AnyRec)) { + for (const cap of asCapArray(val)) out.push({ cap, key }); + } + return out; + } + return []; +} + +/** + * Validate every capability reference in a stack. Returns findings (empty = + * clean). Advisory only — callers must not fail the build on these alone. + */ +export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFinding[] { + const findings: CapabilityRefFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + // ── Build the author-time "known capability" set ── + const known = new Set(PLATFORM_CAPABILITY_NAMES); + for (const ps of asArray(stack.permissions)) { + for (const cap of asCapArray(ps.systemPermissions)) known.add(cap); + } + for (const seed of asArray(stack.data)) { + if (seed.object !== 'sys_capability') continue; + for (const rec of Array.isArray(seed.records) ? seed.records : []) { + const name = (rec as AnyRec | null)?.name; + if (typeof name === 'string' && name.length > 0) known.add(name); + } + } + + const hint = + 'Fix the capability name, declare it on a permission set’s systemPermissions, ' + + 'ship a sys_capability seed row, or ignore this if the capability is provided by ' + + 'another installed package (references fail closed at runtime).'; + + const flag = (cap: string, where: string, path: string) => { + if (known.has(cap)) return; + findings.push({ + severity: 'warning', + rule: CAPABILITY_REFERENCE_UNKNOWN, + where, + path, + message: + `requiredPermissions references capability "${cap}" which is registered ` + + `nowhere — no built-in capability, no permission set in this package grants ` + + `it via systemPermissions, and no sys_capability seed declares it`, + hint, + }); + }; + + // ── Objects (D3) + their fields (D3) + embedded actions (D4) ── + const objects = asArray(stack.objects); + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + if (!obj || typeof obj !== 'object') continue; + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + const objPath = `objects[${i}]`; + + for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) { + flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ''}`); + } + + const fields = asArray(obj.fields); + for (const f of fields) { + const fname = typeof f.name === 'string' ? f.name : '(field)'; + for (const cap of asCapArray(f.requiredPermissions)) { + flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`); + } + } + + for (const [ai, action] of asArray(obj.actions).entries()) { + const aName = typeof action.name === 'string' ? action.name : `(action ${ai})`; + for (const cap of asCapArray(action.requiredPermissions)) { + flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`); + } + } + } + + // ── Top-level actions (D4) ── + for (const [i, action] of asArray(stack.actions).entries()) { + const aName = typeof action.name === 'string' ? action.name : `(action ${i})`; + for (const cap of asCapArray(action.requiredPermissions)) { + flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`); + } + } + + // ── Apps: requiredPermissions can appear at the app, area/tab, and nav-item + // (recursively through groups) levels. Walk each app subtree. ── + const apps = asArray(stack.apps); + for (let i = 0; i < apps.length; i++) { + const app = apps[i]; + if (!app || typeof app !== 'object') continue; + const appName = typeof app.name === 'string' ? app.name : `(app ${i})`; + const walk = (node: unknown, path: string) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach((child, ci) => walk(child, `${path}[${ci}]`)); + return; + } + const rec = node as AnyRec; + for (const cap of asCapArray(rec.requiredPermissions)) { + flag(cap, `app "${appName}"`, `${path}.requiredPermissions`); + } + // Recurse only into the sub-structures that carry requiredPermissions. + if (rec.navigation) walk(rec.navigation, `${path}.navigation`); + if (rec.areas) walk(rec.areas, `${path}.areas`); + if (rec.tabs) walk(rec.tabs, `${path}.tabs`); + if (rec.children) walk(rec.children, `${path}.children`); + if (rec.items) walk(rec.items, `${path}.items`); + }; + walk(app, `apps[${i}]`); + } + + return findings; +} diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts index a9c887734..bb42934ff 100644 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts +++ b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts @@ -20,6 +20,8 @@ * security bootstraps. */ +import { PLATFORM_CAPABILITIES, type PlatformCapability } from '@objectstack/spec/security'; + const SYSTEM_CTX = { isSystem: true }; function genId(prefix: string): string { @@ -41,25 +43,15 @@ async function tryUpdate(ql: any, object: string, data: any): Promise { try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; } } -interface CapabilityDef { - name: string; - label: string; - description: string; - scope: 'platform' | 'org'; -} +type CapabilityDef = PlatformCapability; /** - * Well-known platform capabilities. Exported so tests + tooling can assert the - * back-compat set. `managed_by` is always `'platform'` for these. + * Well-known platform capabilities. Re-exported from the canonical spec registry + * (`@objectstack/spec/security` `PLATFORM_CAPABILITIES`) so the seeder and the + * authoring lint (ADR-0066 ⑨) share ONE source of truth. `managed_by` is always + * `'platform'` for these. Kept as a named export for back-compat consumers/tests. */ -export const KNOWN_CAPABILITIES: readonly CapabilityDef[] = [ - { name: 'manage_users', label: 'Manage Users', description: 'Create, edit, and deactivate users across the platform.', scope: 'platform' }, - { name: 'manage_org_users', label: 'Manage Organization Users', description: 'Manage members within the caller’s organization.', scope: 'org' }, - { name: 'manage_metadata', label: 'Manage Metadata', description: 'Author and publish object/view/flow and other metadata.', scope: 'platform' }, - { name: 'manage_platform_settings', label: 'Manage Platform Settings', description: 'Configure global platform settings (mail, storage, AI, licensing, …) and platform-only Setup pages.', scope: 'platform' }, - { name: 'setup.access', label: 'Setup Access', description: 'Enter the Setup app shell.', scope: 'platform' }, - { name: 'studio.access', label: 'Studio Access', description: 'Enter the Studio metadata-design surfaces.', scope: 'platform' }, -]; +export const KNOWN_CAPABILITIES: readonly CapabilityDef[] = PLATFORM_CAPABILITIES; function humanize(name: string): string { return name diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts index 8e4921168..3f45d523a 100644 --- a/packages/plugins/plugin-security/src/permission-evaluator.ts +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -52,6 +52,31 @@ const MODIFY_ALL_WRITE_KEYS = new Set([ ...[...DESTRUCTIVE_OPERATIONS].map((op) => OPERATION_TO_PERMISSION[op]), ]); +/** CRUD operation class an object-level `requiredPermissions` map keys on. */ +export type CrudBucket = 'read' | 'create' | 'update' | 'delete'; + +/** + * [ADR-0066 ⑤] Map a raw ObjectQL operation to the CRUD class a per-operation + * `requiredPermissions` map is keyed on, DERIVED from `OPERATION_TO_PERMISSION` + * so it stays in lockstep with the CRUD permission bits (and any future + * destructive op added there). `transfer`/`restore` fold into `update`, + * `purge` into `delete`. Returns `null` for an operation with no CRUD mapping + * (e.g. a custom read-side op) — such an op is never matched by a per-operation + * map, but the flat `string[]` form still gates it via its `all` bucket. + */ +export function crudBucketForOperation(operation: string): CrudBucket | null { + switch (OPERATION_TO_PERMISSION[operation]) { + case 'allowRead': return 'read'; + case 'allowCreate': return 'create'; + case 'allowEdit': + case 'allowTransfer': + case 'allowRestore': return 'update'; + case 'allowDelete': + case 'allowPurge': return 'delete'; + default: return null; + } +} + /** * [ADR-0066 D2] Resolve the object permission a permission set contributes for * `objectName`, honouring the secure-by-default posture: diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index d65c814c3..abeb4ca57 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { SecurityPlugin } from './security-plugin.js'; -import { PermissionEvaluator } from './permission-evaluator.js'; +import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { FieldMasker } from './field-masker.js'; import { RLSCompiler, RLS_DENY_FILTER, isSupportedRlsExpression } from './rls-compiler.js'; import type { PermissionSet } from '@objectstack/spec/security'; @@ -563,6 +563,86 @@ describe('SecurityPlugin', () => { }); }); + // ------------------------------------------------------------------------- + // ADR-0066 ⑤ — per-operation requiredPermissions (read-open / write-gated) + // ------------------------------------------------------------------------- + describe('ADR-0066 ⑤ per-operation requiredPermissions (middleware)', () => { + const memberSet: PermissionSet = { + name: 'member_default', label: 'Member', isProfile: true, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + } as any; + const memberWithCap: PermissionSet = { + name: 'member_default', label: 'Member', isProfile: true, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + systemPermissions: ['manage_x'], + } as any; + + // Object is read-open (no `read` key) but create-gated on `manage_x`. + const createGated = { requiredPermissions: { create: ['manage_x'] } }; + + it('does NOT gate read — a caller without the capability can find', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [memberSet], schemaExtra: createGated }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await expect(harness.run(opCtx)).resolves.toBeDefined(); + }); + + it('DENIES the gated operation (create) for a caller missing the capability', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [memberSet], schemaExtra: createGated }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'insert', data: { name: 'A' }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await expect(harness.run(opCtx)).rejects.toMatchObject({ + name: 'PermissionDeniedError', + message: expect.stringContaining("operation 'insert'"), + }); + }); + + it('ALLOWS the gated operation once the caller holds the capability', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [memberWithCap], schemaExtra: createGated }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'insert', data: { name: 'A' }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await expect(harness.run(opCtx)).resolves.toBeDefined(); + expect(opCtx.data.owner_id).toBe('u1'); // proves it flowed past the gate + }); + + it('array form still gates EVERY operation (backward compat)', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [memberSet], + schemaExtra: { requiredPermissions: ['manage_x'] }, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + // read is denied under the array form (unlike the per-op map above)… + const readCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await expect(harness.run(readCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + // …and so is create. + const createCtx: any = { + object: 'task', operation: 'insert', data: { name: 'A' }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await expect(harness.run(createCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + }); + // ------------------------------------------------------------------------- // getReadFilter service (ADR-0021 D-C) — the reusable READ scope the // analytics raw-SQL path bridges to. Must produce the SAME FilterCondition @@ -1339,6 +1419,33 @@ describe('PermissionEvaluator — ADR-0066 posture + capabilities', () => { }); }); +// --------------------------------------------------------------------------- +// ADR-0066 ⑤ — crudBucketForOperation (operation → CRUD class mapping) +// --------------------------------------------------------------------------- +describe('crudBucketForOperation (ADR-0066 ⑤)', () => { + it('maps read operations to `read`', () => { + for (const op of ['find', 'findOne', 'count', 'aggregate']) { + expect(crudBucketForOperation(op)).toBe('read'); + } + }); + it('maps insert to `create`', () => { + expect(crudBucketForOperation('insert')).toBe('create'); + }); + it('folds update/transfer/restore into `update`', () => { + for (const op of ['update', 'transfer', 'restore']) { + expect(crudBucketForOperation(op)).toBe('update'); + } + }); + it('folds delete/purge into `delete`', () => { + for (const op of ['delete', 'purge']) { + expect(crudBucketForOperation(op)).toBe('delete'); + } + }); + it('returns null for an operation with no CRUD mapping', () => { + expect(crudBucketForOperation('customReadSideOp')).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // FieldMasker // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7605f0787..da047790b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,7 +2,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; -import { PermissionEvaluator } from './permission-evaluator.js'; +import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { bootstrapDeclaredRoles } from './bootstrap-declared-roles.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-roles.js'; @@ -24,6 +24,67 @@ import { securityPluginManifestHeader, } from './manifest.js'; +/** + * [ADR-0066 D3/⑤] Object `requiredPermissions` normalized into per-CRUD buckets. + * `all` holds capabilities required for EVERY operation (the `string[]` form); + * the per-op buckets hold capabilities from the `{read,create,update,delete}` + * map form. The effective requirement for an operation is `all ∪ `. + */ +interface NormalizedRequiredPermissions { + all: string[]; + read: string[]; + create: string[]; + update: string[]; + delete: string[]; +} + +/** Per-object security posture resolved once and cached (see getObjectSecurityMeta). */ +interface ObjectSecurityMeta { + isPrivate: boolean; + tenancyDisabled: boolean; + isBetterAuthManaged: boolean; + requiredPermissions: NormalizedRequiredPermissions; + fieldRequiredPermissions: Record; +} + +const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze({ + all: [], read: [], create: [], update: [], delete: [], +}) as NormalizedRequiredPermissions; + +/** Normalize a raw object `requiredPermissions` (string[] | per-op map) into buckets. */ +function normalizeRequiredPermissions(raw: unknown): NormalizedRequiredPermissions { + if (Array.isArray(raw)) { + return { all: raw.map(String), read: [], create: [], update: [], delete: [] }; + } + if (raw && typeof raw === 'object') { + const m = raw as Record; + const bucket = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); + return { + all: [], + read: bucket(m.read), + create: bucket(m.create), + update: bucket(m.update), + delete: bucket(m.delete), + }; + } + return { all: [], read: [], create: [], update: [], delete: [] }; +} + +/** + * [ADR-0066 ⑤] Capabilities required for `operation` = the `all` bucket UNION the + * operation's CRUD bucket. The array form (only `all` populated) thus gates EVERY + * operation exactly as before; the map form gates only the mapped CRUD classes and + * leaves an unmapped custom op ungated. De-duplicated for a clean error message. + */ +function requiredCapsForOperation( + spec: NormalizedRequiredPermissions, + operation: string, +): string[] { + const bucket = crudBucketForOperation(operation); + const caps = bucket ? [...spec.all, ...spec[bucket]] : spec.all; + return caps.length > 0 ? [...new Set(caps)] : []; +} + export interface SecurityPluginOptions { /** * Additional permission sets to register with the metadata service on @@ -122,7 +183,7 @@ export class SecurityPlugin implements Plugin { * `requiredPermissions` capability contract. Populated lazily from the schema; * cleared on metadata change alongside the other schema-derived caches. */ - private readonly objectSecurityMetaCache = new Map }>(); + private readonly objectSecurityMetaCache = new Map(); private dbLoader?: (names: string[]) => Promise; private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {}; @@ -397,28 +458,32 @@ export class SecurityPlugin implements Plugin { const secMeta = permissionSets.length > 0 ? await this.getObjectSecurityMeta(opCtx.object) - : { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: [] as string[], fieldRequiredPermissions: {} as Record }; + : { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, fieldRequiredPermissions: {} as Record }; - // 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability + // 1.5. [ADR-0066 D3/⑤] requiredPermissions AND-gate — a capability // prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a // caller missing any required capability is denied regardless of how - // permissive their grants are. - if (permissionSets.length > 0 && secMeta.requiredPermissions.length > 0) { - const held = this.permissionEvaluator.getSystemPermissions(permissionSets); - const missing = secMeta.requiredPermissions.filter((cap) => !held.has(cap)); - if (missing.length > 0) { - throw new PermissionDeniedError( - `[Security] Access denied: '${opCtx.object}' requires capability ` + - `[${secMeta.requiredPermissions.join(', ')}] — caller is missing [${missing.join(', ')}]`, - { - operation: opCtx.operation, - object: opCtx.object, - roles, - permissionSets: explicitPermissionSets, - requiredPermissions: secMeta.requiredPermissions, - missingPermissions: missing, - }, - ); + // permissive their grants are. Per-operation (⑤): only the caps for + // THIS operation's CRUD class (plus any all-operations caps) apply. + if (permissionSets.length > 0) { + const required = requiredCapsForOperation(secMeta.requiredPermissions, opCtx.operation); + if (required.length > 0) { + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + const missing = required.filter((cap) => !held.has(cap)); + if (missing.length > 0) { + throw new PermissionDeniedError( + `[Security] Access denied: '${opCtx.object}' (operation '${opCtx.operation}') requires capability ` + + `[${required.join(', ')}] — caller is missing [${missing.join(', ')}]`, + { + operation: opCtx.operation, + object: opCtx.object, + roles, + permissionSets: explicitPermissionSets, + requiredPermissions: required, + missingPermissions: missing, + }, + ); + } } } @@ -1420,7 +1485,7 @@ export class SecurityPlugin implements Plugin { */ private async getObjectSecurityMeta( object: string, - ): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; isBetterAuthManaged: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record }> { + ): Promise { const cached = this.objectSecurityMetaCache.get(object); if (cached) return cached; let obj: any = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null; @@ -1458,9 +1523,7 @@ export class SecurityPlugin implements Plugin { // carve-outs / tenant_isolation still apply) and is NOT used for the // wildcard-policy drop below, so it can never leak rows to non-admins. isBetterAuthManaged: (obj as any)?.managedBy === 'better-auth', - requiredPermissions: Array.isArray((obj as any)?.requiredPermissions) - ? (obj as any).requiredPermissions.map(String) - : [], + requiredPermissions: normalizeRequiredPermissions((obj as any)?.requiredPermissions), fieldRequiredPermissions, }; if (obj) this.objectSecurityMetaCache.set(object, meta); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 0d738dff5..8d0970a45 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -352,9 +352,13 @@ "ObjectIndex (type)", "ObjectOwnership (type)", "ObjectOwnershipEnum (const)", + "ObjectRequiredPermissions (type)", + "ObjectRequiredPermissionsSchema (const)", "ObjectSchema (const)", "ObjectTitleCompleteness (interface)", "OperatorKey (type)", + "PerOperationRequiredPermissions (type)", + "PerOperationRequiredPermissionsSchema (const)", "PoolConfig (type)", "PoolConfigSchema (const)", "ProvisionPrimaryOptions (interface)", @@ -3869,9 +3873,12 @@ "ObjectPermissionSchema (const)", "OwnerSharingRule (type)", "OwnerSharingRuleSchema (const)", + "PLATFORM_CAPABILITIES (const)", + "PLATFORM_CAPABILITY_NAMES (const)", "PermissionSet (type)", "PermissionSetInput (type)", "PermissionSetSchema (const)", + "PlatformCapability (interface)", "RLS (const)", "RLSAuditConfig (type)", "RLSAuditConfigSchema (const)", diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index e59f6b5ba..7f9c418df 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -161,6 +161,35 @@ export const ObjectAccessConfigSchema = lazySchema(() => z.object({ .describe('Default exposure posture: public (covered by wildcard grants) | private (needs explicit grant; exempt from wildcard RLS).'), })); +/** + * [ADR-0066 ⑤] Per-operation capability requirements for an object. Each key + * lists the capabilities a caller must hold for that operation CLASS; an absent + * key means that operation carries no capability gate. Lets an object be + * "read-open / write-gated" (Salesforce & Dataverse separate capability by + * operation) instead of the flat all-CRUD gate the `string[]` form applies. + * Operation→class mapping mirrors the CRUD permission bits: `transfer`/`restore` + * fold into `update`, `purge` into `delete`. `.strict()` so a mistyped key + * (e.g. `reads`) is rejected at author time rather than silently ignored. + */ +export const PerOperationRequiredPermissionsSchema = z.object({ + read: z.array(z.string()).optional().describe('Capabilities required to read (find/findOne/count/aggregate).'), + create: z.array(z.string()).optional().describe('Capabilities required to create (insert).'), + update: z.array(z.string()).optional().describe('Capabilities required to update (update/transfer/restore).'), + delete: z.array(z.string()).optional().describe('Capabilities required to delete (delete/purge).'), +}).strict(); + +/** + * [ADR-0066 D3/⑤] Object capability contract — either capabilities required for + * ALL operations (`string[]`, the original shape) or a per-operation map + * (narrows the gate by operation). See the field doc on `Object.requiredPermissions`. + */ +export const ObjectRequiredPermissionsSchema = z.union([ + z.array(z.string()), + PerOperationRequiredPermissionsSchema, +]); +export type PerOperationRequiredPermissions = z.infer; +export type ObjectRequiredPermissions = z.infer; + /** * Soft Delete Configuration Schema * Implements recycle bin / trash functionality @@ -522,14 +551,19 @@ const ObjectSchemaBase = z.object({ access: ObjectAccessConfigSchema.optional().describe('[ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default).'), /** - * [ADR-0066 D3] Capability contract — capability name(s) (permission-set - * `systemPermissions`; D1 records) a caller MUST hold to access this object at - * all. Mirrors `App.requiredPermissions`. Enforced by plugin-security as an + * [ADR-0066 D3/⑤] Capability contract — capability name(s) (permission-set + * `systemPermissions`; D1 records) a caller MUST hold to access this object. + * Mirrors `App.requiredPermissions`. Enforced by plugin-security as an * AND-gate: checked IN ADDITION to permission-set CRUD grants — a caller - * missing any listed capability is denied regardless of grants. Absent/empty - * ⇒ no capability gate. + * missing any required capability is denied regardless of grants. + * + * Two shapes: + * - `string[]` — required for ALL operations (read/create/update/delete). + * - `{ read?, create?, update?, delete? }` (⑤) — required only for the listed + * operation class, so an object can be read-open but write-gated. + * Absent/empty ⇒ no capability gate. */ - requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D3] Capabilities required to access this object (AND-gate, checked alongside CRUD grants).'), + requiredPermissions: ObjectRequiredPermissionsSchema.optional().describe('[ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation.'), // Soft delete configuration softDelete: SoftDeleteConfigSchema.optional().describe('Soft delete (trash/recycle bin) configuration'), diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts new file mode 100644 index 000000000..791f9a53f --- /dev/null +++ b/packages/spec/src/security/capabilities.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0066 D1] Canonical platform capability registry. + * + * The built-in authorization capabilities the framework ships, as first-class + * definitions (name / label / description / scope). This module is the SINGLE + * SOURCE OF TRUTH consumed by: + * - `@objectstack/plugin-security` `bootstrapSystemCapabilities`, which seeds + * these into `sys_capability` records at boot (`managed_by: 'platform'`), and + * - the authoring lint `validateCapabilityReferences` (ADR-0066 ⑨), which + * resolves capability references (`requiredPermissions`) against these names + * plus any a stack declares via a permission set's `systemPermissions`. + * + * Keeping the list here (in the contract package, which everything depends on) + * avoids a spec→plugin dependency inversion and keeps the seeder and the lint + * from drifting apart. + */ + +export interface PlatformCapability { + /** Stable capability name referenced by `systemPermissions` / `requiredPermissions`. */ + name: string; + /** Human label shown in Setup. */ + label: string; + /** What holding the capability permits. */ + description: string; + /** `platform` = global; `org` = scoped to the caller's organization. */ + scope: 'platform' | 'org'; +} + +/** + * The curated built-in capabilities. Back-compat: string references to any of + * these keep resolving because they are seeded as records with the same `name`. + */ +export const PLATFORM_CAPABILITIES: readonly PlatformCapability[] = [ + { name: 'manage_users', label: 'Manage Users', description: 'Create, edit, and deactivate users across the platform.', scope: 'platform' }, + { name: 'manage_org_users', label: 'Manage Organization Users', description: 'Manage members within the caller’s organization.', scope: 'org' }, + { name: 'manage_metadata', label: 'Manage Metadata', description: 'Author and publish object/view/flow and other metadata.', scope: 'platform' }, + { name: 'manage_platform_settings', label: 'Manage Platform Settings', description: 'Configure global platform settings (mail, storage, AI, licensing, …) and platform-only Setup pages.', scope: 'platform' }, + { name: 'setup.access', label: 'Setup Access', description: 'Enter the Setup app shell.', scope: 'platform' }, + { name: 'studio.access', label: 'Studio Access', description: 'Enter the Studio metadata-design surfaces.', scope: 'platform' }, +]; + +/** Set of built-in capability names, for fast membership checks (lint, gating). */ +export const PLATFORM_CAPABILITY_NAMES: ReadonlySet = new Set( + PLATFORM_CAPABILITIES.map((c) => c.name), +); diff --git a/packages/spec/src/security/index.ts b/packages/spec/src/security/index.ts index c6ba74360..507f33a99 100644 --- a/packages/spec/src/security/index.ts +++ b/packages/spec/src/security/index.ts @@ -12,6 +12,7 @@ export * from './permission.zod'; export * from './permission.form'; +export * from './capabilities'; export * from './sharing.zod'; export * from './territory.zod'; export * from './rls.zod';