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
33 changes: 33 additions & 0 deletions .changeset/authz-a5-capability-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-security": minor
"@objectstack/runtime": minor
"@objectstack/lint": minor
---

feat(spec,plugin-security): package-level capability declaration API (ADR-0066 D1)

Packages can now DEFINE their own authorization capabilities explicitly via the
new `defineCapability` factory and a stack's `capabilities` array, instead of
relying on the implicit "derive an untitled capability from whatever a permission
set references in `systemPermissions[]`" back-door.

- `@objectstack/spec`: new `defineCapability` / `CapabilityDeclarationSchema`
(`{ name, label?, description?, scope, packageId? }`) and a `capabilities`
field on the stack definition.
- `@objectstack/plugin-security`: new `bootstrapDeclaredCapabilities` seeds
declared capabilities into `sys_capability` with `managed_by:'package'` +
`package_id` provenance (new `package_id` field on the object). Idempotent,
upgrade-aware; refuses to hijack curated platform capabilities or another
package's rows, never clobbers admin-authored rows, and CLAIMS a pre-existing
derived placeholder (upgrading it to package provenance). The implicit
derive-from-`systemPermissions` path still runs for back-compat but now skips
any explicitly-declared name so it can't clobber authored metadata.
- `@objectstack/runtime`: stack-declared `capabilities` are registered into the
metadata registry (type `capability`) so the boot seeder can read them.
- `@objectstack/lint`: `validateCapabilityReferences` treats
`stack.capabilities` names as a known capability source.

A capability is not a contract: DEFINE it (`defineCapability`), GRANT it
(`systemPermissions`), REQUIRE it (`requiredPermissions`) — no `inputs`.
Aligns with ADR-0094 D5 (retire implicit `managed_by`-guessing back-doors).
55 changes: 54 additions & 1 deletion content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ primitive fails the build.
Authorization splits into three concerns that stay decoupled:

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

### Package capability declaration (ADR-0066 D1)

A package DEFINES its own authorization capabilities with **`defineCapability`**,
collected on the stack's `capabilities` array — the declaration-side counterpart
of the platform's curated `PLATFORM_CAPABILITIES`:

```ts
import { defineCapability, defineStack } from '@objectstack/spec';

export const ExportDataCapability = defineCapability({
name: 'export_data',
label: 'Export Data',
description: 'Bulk-export records to CSV/XLSX.',
scope: 'org', // 'platform' (global) | 'org' (scoped to the caller's org)
});

export default defineStack({
manifest: { namespace: 'billing' },
capabilities: [ExportDataCapability], // ← DEFINE
permissions: [{ name: 'billing_admin', systemPermissions: ['export_data'] }], // ← GRANT
// a resource REQUIRES it: requiredPermissions: ['export_data']
});
```

At boot `bootstrapDeclaredCapabilities`
(`packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`) seeds
each declaration into `sys_capability` with **`managed_by: 'package'` + `package_id`**
provenance — idempotent, re-seeded on upgrade, and it **never clobbers**
admin-authored rows, refuses to hijack a curated platform capability, and refuses
to write into another package's capability.

This **replaces the implicit back-door** where a capability existed only as an
untitled placeholder derived from whatever a permission set happened to reference
in `systemPermissions[]`. That derivation still runs for back-compat (a
reference with no declaration keeps resolving as a `managed_by:'platform'`
placeholder), but an explicit `defineCapability` takes precedence and — for a
pre-existing derived placeholder — **claims** it, upgrading the row to package
provenance with the authored label/description/scope. This is the ADR-0094 D5
direction: retire the implicit `managed_by`-guessing back-doors in favour of
explicit, attributable declarations.

Remember the three-way separation: a capability is **not** a contract. You
**DEFINE** it here, **GRANT** it via a permission set's `systemPermissions`, and
**REQUIRE** it on a resource via `requiredPermissions`. There is no `inputs`.

### Two doors, one metadata (ADR-0086 D6/D7)

The `managedBy` provenance axis is not just descriptive — the platform
Expand Down Expand Up @@ -280,6 +328,11 @@ The complete, prioritized gap map lives in issue **#2561** (the production
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`.
A **package** now DEFINES its own capabilities explicitly via
[`defineCapability` / `stack.capabilities`](#package-capability-declaration-adr-0066-d1),
seeded with `managed_by:'package'` + `package_id` provenance by
`bootstrap-declared-capabilities.ts` — replacing the implicit
derive-from-`systemPermissions` back-door (which stays for back-compat).
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
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0066-unified-authorization-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ The design rule that resolves the recurring confusion: **a resource declares the
### D1 — Capability registry [new]
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.

**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).

### D2 — Secure-by-default object/field posture [new] (data-model posture, NOT a permission)
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).

Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { allApis } from './src/system/apis/index.js';
import {
allPositions,
allPermissionSets,
allCapabilities,
allSharingRules,
} from './src/security/index.js';
import { allThemes } from './src/ui/themes/index.js';
Expand Down Expand Up @@ -194,6 +195,9 @@ export default defineStack({
// Security
positions: allPositions,
permissions: allPermissionSets,
// [ADR-0066 D1] Package-declared authorization capabilities — seeded into
// sys_capability with package provenance (managed_by:'package').
capabilities: allCapabilities,
sharingRules: allSharingRules,

// Seed data
Expand Down
38 changes: 38 additions & 0 deletions examples/app-showcase/src/security/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Package-level capability declarations (ADR-0066 D1).
*
* `defineCapability` is the FORMAL, EXPLICIT way for a package to DEFINE an
* authorization capability of its own — the counterpart, on the declaration
* side, of the platform's curated capabilities (`manage_users`, `setup.access`,
* …). Collected on the stack's `capabilities` array, each declaration is seeded
* into `sys_capability` at boot with `managed_by:'package'` + `package_id`
* provenance, so the registry can attribute and uninstall it — instead of the
* old back-door where a capability only existed IMPLICITLY as an untitled
* placeholder derived from whatever a permission set referenced.
*
* The three-way separation (ADR-0066): a capability is NOT a contract.
* • DEFINE it here (`defineCapability`).
* • GRANT it on a permission set (`systemPermissions`) — see
* `OpsPermissionSet` which carries `showcase.export_data`.
* • REQUIRE it on a resource (`requiredPermissions`) — a resource that lists
* the capability is denied unless the caller's granted sets carry it.
*/

import { defineCapability } from '@objectstack/spec';

/**
* Bulk data export. Org-scoped: a power that operates within the caller's
* organization. Granted to Operations (see permission-sets.ts); a future export
* endpoint/action would gate itself with `requiredPermissions:
* ['showcase.export_data']`.
*/
export const ExportDataCapability = defineCapability({
name: 'showcase.export_data',
label: 'Export Showcase Data',
description: 'Bulk-export showcase records (accounts, invoices) to CSV/XLSX.',
scope: 'org',
});

export const allCapabilities = [ExportDataCapability];
5 changes: 5 additions & 0 deletions examples/app-showcase/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export {
allPermissionSets,
} from './permission-sets.js';

export {
ExportDataCapability,
allCapabilities,
} from './capabilities.js';

export {
RedProjectSharingRule,
HighValueRedProjectRule,
Expand Down
5 changes: 4 additions & 1 deletion examples/app-showcase/src/security/permission-sets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ export const OpsPermissionSet = definePermissionSet({
showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' },
showcase_invoice: { allowRead: true },
},
systemPermissions: ['setup.access'],
// `setup.access` is a platform capability; `showcase.export_data` is a
// PACKAGE capability this app DECLARES via defineCapability (see
// capabilities.ts) — the grant side of the ADR-0066 three-way separation.
systemPermissions: ['setup.access', 'showcase.export_data'],
});

// ── The `everyone` baseline suggestion (ADR-0090 D5) ───────────────────────
Expand Down
8 changes: 8 additions & 0 deletions packages/lint/src/validate-capability-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ describe('validateCapabilityReferences (ADR-0066 ⑨)', () => {
expect(findings).toEqual([]);
});

it('passes a reference to a capability the stack DECLARES via defineCapability', () => {
const findings = validateCapabilityReferences({
capabilities: [{ name: 'export_data', label: 'Export Data', scope: 'org' }],
objects: [{ name: 'inv_invoice', requiredPermissions: ['export_data'] }],
});
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'] }],
Expand Down
20 changes: 14 additions & 6 deletions packages/lint/src/validate-capability-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
*
* 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
* 2. every capability the stack DECLARES via `defineCapability`
* (`stack.capabilities`) — the explicit, package-provenanced declaration
* (ADR-0066 D1), materialized at boot by `bootstrapDeclaredCapabilities`,
* 3. every capability a permission set in this stack GRANTS via
* `systemPermissions` (granting a capability also declares it — mirrors
* the runtime `bootstrapSystemCapabilities` derived-defaults rule), and
* 3. any `sys_capability` row shipped as seed data.
* 4. 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,
Expand Down Expand Up @@ -91,6 +94,10 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin

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

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).';
'Fix the capability name, define it with defineCapability (stack.capabilities), ' +
'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;
Expand Down
Loading