Skip to content

Commit f32478a

Browse files
committed
feat(spec,plugin-security): A5 — package-level capability declaration API (#2920)
Give packages a formal, EXPLICIT entry point to DEFINE their own authorization capabilities, so package-owned capabilities flow into the sys_capability registry with managed_by:'package' + package_id provenance instead of relying on the implicit "derive an untitled capability from a permission set's systemPermissions[]" back-door (ADR-0066 D1; aligns with ADR-0094 D5). - spec: new defineCapability / CapabilityDeclarationSchema ({ name, label?, description?, scope, packageId? }); new `capabilities` field on the stack definition (+ compose concat). - plugin-security: new bootstrapDeclaredCapabilities seeds declared capabilities with package provenance (new package_id field + index on sys_capability). Idempotent, upgrade-aware; refuses to hijack curated platform capabilities or a foreign package's rows, never clobbers admin rows, and CLAIMS a pre-existing derived placeholder. bootstrapSystemCapabilities gains declaredCapabilityNames so its back-compat derivation skips (never clobbers) declared capabilities. - runtime: stack-declared `capabilities` registered into the metadata registry (type `capability`) for the boot seeder to read. - lint: validateCapabilityReferences treats stack.capabilities as a known source. - docs: authorization.mdx + ADR-0066 D1 note; app-showcase example (define + grant the showcase.export_data capability). Note: corrects the tracking issue's premise — capability was already a single source of truth (no app→framework duplication); this task adds the missing explicit package DECLARATION path + provenance, not a de-dup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
1 parent 94f33ec commit f32478a

20 files changed

Lines changed: 706 additions & 13 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/lint": minor
6+
---
7+
8+
feat(spec,plugin-security): package-level capability declaration API (ADR-0066 D1)
9+
10+
Packages can now DEFINE their own authorization capabilities explicitly via the
11+
new `defineCapability` factory and a stack's `capabilities` array, instead of
12+
relying on the implicit "derive an untitled capability from whatever a permission
13+
set references in `systemPermissions[]`" back-door.
14+
15+
- `@objectstack/spec`: new `defineCapability` / `CapabilityDeclarationSchema`
16+
(`{ name, label?, description?, scope, packageId? }`) and a `capabilities`
17+
field on the stack definition.
18+
- `@objectstack/plugin-security`: new `bootstrapDeclaredCapabilities` seeds
19+
declared capabilities into `sys_capability` with `managed_by:'package'` +
20+
`package_id` provenance (new `package_id` field on the object). Idempotent,
21+
upgrade-aware; refuses to hijack curated platform capabilities or another
22+
package's rows, never clobbers admin-authored rows, and CLAIMS a pre-existing
23+
derived placeholder (upgrading it to package provenance). The implicit
24+
derive-from-`systemPermissions` path still runs for back-compat but now skips
25+
any explicitly-declared name so it can't clobber authored metadata.
26+
- `@objectstack/runtime`: stack-declared `capabilities` are registered into the
27+
metadata registry (type `capability`) so the boot seeder can read them.
28+
- `@objectstack/lint`: `validateCapabilityReferences` treats
29+
`stack.capabilities` names as a known capability source.
30+
31+
A capability is not a contract: DEFINE it (`defineCapability`), GRANT it
32+
(`systemPermissions`), REQUIRE it (`requiredPermissions`) — no `inputs`.
33+
Aligns with ADR-0094 D5 (retire implicit `managed_by`-guessing back-doors).

content/docs/permissions/authorization.mdx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ primitive fails the build.
2727
Authorization splits into three concerns that stay decoupled:
2828

2929
1. **Capability***what can be done* (`manage_users`, `export_data`).
30-
Defined by the platform or a package; extended by admins.
30+
Defined by the platform (curated `PLATFORM_CAPABILITIES`) or by a package
31+
via [`defineCapability`](#package-capability-declaration-adr-0066-d1); extended
32+
by admins in Setup. A capability is **not** a contract and has no `inputs` — a
33+
resource merely *references* one by name (see Requirement, below).
3134
2. **Assignment***who holds it* — permission sets / positions / user
3235
bindings (`sys_permission_set`, `sys_position`, `sys_user_permission_set`,
3336
`sys_position_permission_set`, `sys_user_position`). Runtime records,
@@ -114,6 +117,51 @@ package (metadata); subject bindings and env-specific values stay as config.**
114117
(`bootstrapDeclaredPositions`, ADR-0057 D6) — a declarable-but-never-seeded
115118
array is exactly the inert-metadata smell ADR-0078 prohibits.
116119

120+
### Package capability declaration (ADR-0066 D1)
121+
122+
A package DEFINES its own authorization capabilities with **`defineCapability`**,
123+
collected on the stack's `capabilities` array — the declaration-side counterpart
124+
of the platform's curated `PLATFORM_CAPABILITIES`:
125+
126+
```ts
127+
import { defineCapability, defineStack } from '@objectstack/spec';
128+
129+
export const ExportDataCapability = defineCapability({
130+
name: 'export_data',
131+
label: 'Export Data',
132+
description: 'Bulk-export records to CSV/XLSX.',
133+
scope: 'org', // 'platform' (global) | 'org' (scoped to the caller's org)
134+
});
135+
136+
export default defineStack({
137+
manifest: { namespace: 'billing' },
138+
capabilities: [ExportDataCapability], // ← DEFINE
139+
permissions: [{ name: 'billing_admin', systemPermissions: ['export_data'] }], // ← GRANT
140+
// a resource REQUIRES it: requiredPermissions: ['export_data']
141+
});
142+
```
143+
144+
At boot `bootstrapDeclaredCapabilities`
145+
(`packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`) seeds
146+
each declaration into `sys_capability` with **`managed_by: 'package'` + `package_id`**
147+
provenance — idempotent, re-seeded on upgrade, and it **never clobbers**
148+
admin-authored rows, refuses to hijack a curated platform capability, and refuses
149+
to write into another package's capability.
150+
151+
This **replaces the implicit back-door** where a capability existed only as an
152+
untitled placeholder derived from whatever a permission set happened to reference
153+
in `systemPermissions[]`. That derivation still runs for back-compat (a
154+
reference with no declaration keeps resolving as a `managed_by:'platform'`
155+
placeholder), but an explicit `defineCapability` takes precedence and — for a
156+
pre-existing derived placeholder — **claims** it, upgrading the row to package
157+
provenance with the authored label/description/scope. This is the ADR-0094 D5
158+
direction: retire the implicit `managed_by`-guessing back-doors in favour of
159+
explicit, attributable declarations.
160+
161+
Remember the three-way separation: a capability is **not** a contract. You
162+
**DEFINE** it here, **GRANT** it via a permission set's `systemPermissions`, and
163+
**REQUIRE** it on a resource via `requiredPermissions`. There is no `inputs`.
164+
117165
### Two doors, one metadata (ADR-0086 D6/D7)
118166

119167
The `managedBy` provenance axis is not just descriptive — the platform
@@ -280,6 +328,11 @@ The complete, prioritized gap map lives in issue **#2561** (the production
280328
as first-class `sys_capability` records from the canonical list in
281329
`@objectstack/spec` (`security/capabilities.ts``PLATFORM_CAPABILITIES`),
282330
seeded by `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts`.
331+
A **package** now DEFINES its own capabilities explicitly via
332+
[`defineCapability` / `stack.capabilities`](#package-capability-declaration-adr-0066-d1),
333+
seeded with `managed_by:'package'` + `package_id` provenance by
334+
`bootstrap-declared-capabilities.ts` — replacing the implicit
335+
derive-from-`systemPermissions` back-door (which stays for back-compat).
283336
The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences`
284337
(`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a
285338
`requiredPermissions` names a capability registered nowhere — no built-in, no

docs/adr/0066-unified-authorization-model.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ The design rule that resolves the recurring confusion: **a resource declares the
4242
### D1 — Capability registry [new]
4343
Promote capabilities from bare strings to **first-class records** (`sys_permission` / capability definition) with `name`, `label`, `description`, `scope` (platform | org), and a `managedBy` (platform | package | admin). `systemPermissions[]` on permission sets and `requiredPermissions[]` on resources become **references** to these records. Packages declare their capabilities; admins add new ones in Setup. Back-compat: existing string capabilities are seeded as records with the same `name`, so all current references keep resolving.
4444

45+
**Landed (2026-07):** the curated platform set is `@objectstack/spec` `PLATFORM_CAPABILITIES` (`security/capabilities.ts`), seeded `managed_by:'platform'` by `bootstrap-system-capabilities.ts`. "**Packages declare their capabilities**" is now an EXPLICIT API — `defineCapability` + a stack's `capabilities` array (`{ name, label?, description?, scope, packageId? }`) — materialized into `sys_capability` with `managed_by:'package'` + `package_id` provenance by `bootstrap-declared-capabilities.ts` (idempotent, upgrade-aware; refuses to hijack curated/foreign capabilities, never clobbers admin rows, and *claims* a pre-existing derived placeholder). This retires the implicit back-door where a capability existed only as an untitled placeholder derived from a permission set's `systemPermissions[]`; that derivation still runs for back-compat but skips any explicitly-declared name. A capability is **not** a contract and carries no `inputs`: DEFINE (`defineCapability`) / GRANT (`systemPermissions`) / REQUIRE (`requiredPermissions`) stay decoupled. Aligns with ADR-0094 D5 (retire implicit `managed_by`-guessing).
46+
4547
### D2 — Secure-by-default object/field posture [new] (data-model posture, NOT a permission)
4648
Add an object (and field) flag that opts it **out of blanket wildcard grants** — e.g. `access: { default: 'private' }` (vs the implicit `'public'`). A `private` object is **not** covered by `'*': {allowRead:true}`; access requires an **explicit** permission-set grant. Mirrors Salesforce "new object = no access until granted." This is a posture like `tenancy`, declared on the object — it is **not** an assignment and names no principal. `admin_full_access` (the superuser `'*'` grant) still covers private objects unless it too is excluded (rare).
4749

examples/app-showcase/objectstack.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { allApis } from './src/system/apis/index.js';
3333
import {
3434
allPositions,
3535
allPermissionSets,
36+
allCapabilities,
3637
allSharingRules,
3738
} from './src/security/index.js';
3839
import { allThemes } from './src/ui/themes/index.js';
@@ -194,6 +195,9 @@ export default defineStack({
194195
// Security
195196
positions: allPositions,
196197
permissions: allPermissionSets,
198+
// [ADR-0066 D1] Package-declared authorization capabilities — seeded into
199+
// sys_capability with package provenance (managed_by:'package').
200+
capabilities: allCapabilities,
197201
sharingRules: allSharingRules,
198202

199203
// Seed data
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Package-level capability declarations (ADR-0066 D1).
5+
*
6+
* `defineCapability` is the FORMAL, EXPLICIT way for a package to DEFINE an
7+
* authorization capability of its own — the counterpart, on the declaration
8+
* side, of the platform's curated capabilities (`manage_users`, `setup.access`,
9+
* …). Collected on the stack's `capabilities` array, each declaration is seeded
10+
* into `sys_capability` at boot with `managed_by:'package'` + `package_id`
11+
* provenance, so the registry can attribute and uninstall it — instead of the
12+
* old back-door where a capability only existed IMPLICITLY as an untitled
13+
* placeholder derived from whatever a permission set referenced.
14+
*
15+
* The three-way separation (ADR-0066): a capability is NOT a contract.
16+
* • DEFINE it here (`defineCapability`).
17+
* • GRANT it on a permission set (`systemPermissions`) — see
18+
* `OpsPermissionSet` which carries `showcase.export_data`.
19+
* • REQUIRE it on a resource (`requiredPermissions`) — a resource that lists
20+
* the capability is denied unless the caller's granted sets carry it.
21+
*/
22+
23+
import { defineCapability } from '@objectstack/spec';
24+
25+
/**
26+
* Bulk data export. Org-scoped: a power that operates within the caller's
27+
* organization. Granted to Operations (see permission-sets.ts); a future export
28+
* endpoint/action would gate itself with `requiredPermissions:
29+
* ['showcase.export_data']`.
30+
*/
31+
export const ExportDataCapability = defineCapability({
32+
name: 'showcase.export_data',
33+
label: 'Export Showcase Data',
34+
description: 'Bulk-export showcase records (accounts, invoices) to CSV/XLSX.',
35+
scope: 'org',
36+
});
37+
38+
export const allCapabilities = [ExportDataCapability];

examples/app-showcase/src/security/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export {
4545
allPermissionSets,
4646
} from './permission-sets.js';
4747

48+
export {
49+
ExportDataCapability,
50+
allCapabilities,
51+
} from './capabilities.js';
52+
4853
export {
4954
RedProjectSharingRule,
5055
HighValueRedProjectRule,

examples/app-showcase/src/security/permission-sets.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,10 @@ export const OpsPermissionSet = definePermissionSet({
187187
showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' },
188188
showcase_invoice: { allowRead: true },
189189
},
190-
systemPermissions: ['setup.access'],
190+
// `setup.access` is a platform capability; `showcase.export_data` is a
191+
// PACKAGE capability this app DECLARES via defineCapability (see
192+
// capabilities.ts) — the grant side of the ADR-0066 three-way separation.
193+
systemPermissions: ['setup.access', 'showcase.export_data'],
191194
});
192195

193196
// ── The `everyone` baseline suggestion (ADR-0090 D5) ───────────────────────

packages/lint/src/validate-capability-references.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ describe('validateCapabilityReferences (ADR-0066 ⑨)', () => {
1414
expect(findings).toEqual([]);
1515
});
1616

17+
it('passes a reference to a capability the stack DECLARES via defineCapability', () => {
18+
const findings = validateCapabilityReferences({
19+
capabilities: [{ name: 'export_data', label: 'Export Data', scope: 'org' }],
20+
objects: [{ name: 'inv_invoice', requiredPermissions: ['export_data'] }],
21+
});
22+
expect(findings).toEqual([]);
23+
});
24+
1725
it('passes a reference to a capability the stack grants via systemPermissions', () => {
1826
const findings = validateCapabilityReferences({
1927
permissions: [{ name: 'billing_admin', systemPermissions: ['manage_billing'] }],

packages/lint/src/validate-capability-references.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
*
1515
* The author-time "known" set is:
1616
* 1. the built-in platform capabilities (`PLATFORM_CAPABILITY_NAMES`),
17-
* 2. every capability a permission set in this stack GRANTS via
18-
* `systemPermissions` (granting a capability is what declares it — mirrors
17+
* 2. every capability the stack DECLARES via `defineCapability`
18+
* (`stack.capabilities`) — the explicit, package-provenanced declaration
19+
* (ADR-0066 D1), materialized at boot by `bootstrapDeclaredCapabilities`,
20+
* 3. every capability a permission set in this stack GRANTS via
21+
* `systemPermissions` (granting a capability also declares it — mirrors
1922
* the runtime `bootstrapSystemCapabilities` derived-defaults rule), and
20-
* 3. any `sys_capability` row shipped as seed data.
23+
* 4. any `sys_capability` row shipped as seed data.
2124
*
2225
* WARNING, not error: a single package's lint cannot see capabilities declared
2326
* by OTHER installed packages, and the reference fails closed at runtime anyway,
@@ -91,6 +94,10 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin
9194

9295
// ── Build the author-time "known capability" set ──
9396
const known = new Set<string>(PLATFORM_CAPABILITY_NAMES);
97+
// [ADR-0066 D1] Capabilities the stack explicitly DECLARES via defineCapability.
98+
for (const cap of asArray(stack.capabilities)) {
99+
if (typeof cap.name === 'string' && cap.name.length > 0) known.add(cap.name);
100+
}
94101
for (const ps of asArray(stack.permissions)) {
95102
for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
96103
}
@@ -103,9 +110,10 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin
103110
}
104111

105112
const hint =
106-
'Fix the capability name, declare it on a permission set’s systemPermissions, ' +
107-
'ship a sys_capability seed row, or ignore this if the capability is provided by ' +
108-
'another installed package (references fail closed at runtime).';
113+
'Fix the capability name, define it with defineCapability (stack.capabilities), ' +
114+
'declare it on a permission set’s systemPermissions, ship a sys_capability seed row, ' +
115+
'or ignore this if the capability is provided by another installed package ' +
116+
'(references fail closed at runtime).';
109117

110118
const flag = (cap: string, where: string, path: string) => {
111119
if (known.has(cap)) return;

0 commit comments

Comments
 (0)