Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/capability-reference-lint.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .changeset/per-operation-required-permissions.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 11 additions & 6 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0066-unified-authorization-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 16 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
18 changes: 17 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>);
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);

Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
98 changes: 98 additions & 0 deletions packages/lint/src/validate-capability-references.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)).toEqual([]);
expect(validateCapabilityReferences({ objects: [null, 42, { name: 'x' }] as unknown })).toEqual([]);
});
});
Loading