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
30 changes: 30 additions & 0 deletions .changeset/15204-s6b1c-declared-capability-seeder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/plugin-security': minor
'@objectstack/spec': patch
'@objectstack/objectql': patch
'@objectstack/runtime': patch
'@objectstack/lint': patch
---

feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted

Clause-②: no

`SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served.

A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run.

In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged.

Removed from `@objectstack/plugin-security`'s public exports, with no replacement:

- `CAPABILITY_NAME_COLLISION`
- `capabilityNameCollisionDiagnostic`
- `formatCapabilityNameCollisionDiagnostic`
- `reportCapabilityNameCollisions`
- `CapabilityNameCollisionDiagnostic` (type)

FROM → TO:

- FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`.
- FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place.
21 changes: 10 additions & 11 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -195,20 +195,19 @@ export default defineStack({
});
```

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.
At boot each declaration becomes a registry item owned by its package — the
capability's one home (ADR-0131 D3). No `sys_capability` row is written for it.
The registry's one-holder rule refuses a second holder of a name at boot with a
`422`, so a package can neither take a curated platform capability nor take
over another package's.

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 is retired: a reference with no
declaration still matches by name at runtime but gets no `sys_capability` row.
An explicit `defineCapability` takes precedence and — for a derived placeholder
an older database still holds — **claims** it, upgrading the row to package
provenance with the authored label/description/scope. This is the ADR-0066 D1
An explicit `defineCapability` is the attributable form: its label,
description, scope and owning package live on the registry item, and it gets
no row either. This is the ADR-0066 D1
direction: retire the implicit `managed_by`-guessing back-doors in favour of
explicit, attributable declarations.

Expand Down Expand Up @@ -553,8 +552,8 @@ The complete, prioritized gap map lives in issue **#2561** (the production
`sys_capability` row.
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
served by the registry with owning-package provenance (no row is written for
it) — replacing the implicit
derive-from-`systemPermissions` back-door (now retired).
The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences`
(`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a
Expand Down
63 changes: 29 additions & 34 deletions content/docs/permissions/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ capability name travels through five stations:
② REGISTER AppPlugin → metadata.registerInMemory('capability', name)
│ (also: **/*.capability.ts files)
▼
③ SEED bootstrapDeclaredCapabilities → sys_capability row
│ managed_by: 'package' + package_id provenance
③ SERVE registry item (capability kind) → catalog read, /meta door
│ owning-package provenance, one holder per name
▼
④ GRANT permission set: systemPermissions: ['export_data']
│ assigned to positions / users
Expand All @@ -62,12 +62,13 @@ capability name travels through five stations:
1. **Declare** — `defineCapability(...)` on the stack's `capabilities` array,
or a `*.capability.ts` / `*.capability.yml` file the filesystem loader globs.
2. **Register** — at boot, `AppPlugin` puts each stack-declared capability into
the metadata registry under the `capability` kind, so boot seeders and
runtime resolvers can list it.
3. **Seed** — `bootstrapDeclaredCapabilities` (in `@objectstack/plugin-security`)
reads them back and upserts each one into `sys_capability` with
`managed_by: 'package'` and `package_id` provenance. This is what makes the
capability *attributable* and package uninstall well-defined.
the metadata registry under the `capability` kind, so the security catalog
and runtime resolvers can list it.
3. **Serve** — the registry is the capability's one home. It records the
owning package as provenance, which is what makes the capability
*attributable*. The security catalog read and
`GET /api/v1/meta/capability/:name` answer its definition. No
`sys_capability` row is written for it.
4. **Grant** — an administrator (or a permission set your package ships) lists
the name in [`systemPermissions`](/docs/permissions/permission-metadata#system-permissions).
5. **Check** — a resource lists the name in `requiredPermissions`, and the
Expand Down Expand Up @@ -157,7 +158,8 @@ plain names. Three consequences worth internalising before you pick one:

You cannot take a platform name. A declaration whose `name` collides with a
curated platform capability (`manage_users`, `manage_metadata`,
`setup.access`, …) is refused loudly at boot — those are platform-owned.
`setup.access`, …) is refused at boot by the registry's one-holder rule
(`422`) — those are platform-owned, and the platform declares them itself.

## "Or" is one capability, granted twice

Expand Down Expand Up @@ -193,36 +195,29 @@ the same way; a user who may edit the object still reads all of its list views,
so a save of what they loaded keeps them.
</Callout>

## What the seeder does, and what it refuses
## One holder per name

`bootstrapDeclaredCapabilities` runs on `kernel:ready` and is idempotent: it
re-seeds on every boot, so the row always reflects the shipped declaration. Four
outcomes are refusals rather than writes, and each one leaves the capability
behaving differently:
Each capability name has exactly one holder in the registry. When a package
registers a declaration, the registry records that package as its owner.
Package uninstall then removes it with the rest of the package. Two cases are
refused at boot, with a `422` that names the name and both holders:

| Situation | Outcome |
|:--|:--|
| No resolvable owning package (`_packageId` and `packageId` both absent) | **Refused — no row is written.** The declaration is inert |
| Name collides with a curated platform capability | Refused; the platform keeps its own definition |
| A row already exists owned by a **different** package | Skipped loudly; a package never writes into a foreign record |
| A row was authored by an administrator (`managed_by: 'admin'`) | Never clobbered |

A pre-existing `managed_by: 'platform'` row for a *non-curated* name is a
different case: that is the untitled placeholder the old implicit back-door
derived from whatever a permission set happened to reference, and an explicit
declaration **claims** it — upgrading the row to package provenance with your
authored label, description and scope. The provenance and composition rules
behind that behaviour are set out in
[Package capability declaration](/docs/permissions/authorization#package-capability-declaration-adr-0066-d1).
| The name is a curated platform capability | Refused; the platform holds its own definition |
| Another installed package already declares the name | Refused; a package never takes over another package's capability |

Declaring a capability writes no `sys_capability` row. Its label, description,
scope and provenance live on the registry item, and that is what the catalog
read and the metadata door serve.

<Callout type="info">
**The `sys_capability` row is a catalogue, not a gate.** It carries the label,
the scope and the provenance that make a capability reviewable, attributable and
uninstallable — but no authorization check reads it. Permission-set grants and
resource `requiredPermissions` match capability names **as strings**; the only
production reader of the table is the declared-capability boot seeder. The row's `active`
flag is a catalogue/visibility flag with no authorization effect: clearing it
revokes nothing. Withdraw a capability by withdrawing the **grant**.
**No authorization check reads a `sys_capability` row.** Permission-set grants
and resource `requiredPermissions` match capability names **as strings**. The
boot writes no row for a capability, declared or curated: both are served by
the registry. A row an older database still holds is a catalogue only, and
clearing its `active` flag revokes nothing. Withdraw a capability by
withdrawing the **grant**.
</Callout>

## `capability` is code-only
Expand Down Expand Up @@ -267,7 +262,7 @@ to store arbitrary JSON on the authorization surface.
- [ ] `label` and `description` are written for the administrator who will grant it in Setup
- [ ] `scope` is `org` unless the privilege really is platform-wide
- [ ] The declaration is reachable from the stack (`capabilities: [...]`) or a `*.capability.ts` file
- [ ] The package resolves an owning package id — otherwise the seeder writes no row
- [ ] No other installed package declares the same name — the registry refuses a second holder at boot
- [ ] Something actually **requires** the name (`requiredPermissions`), and something **grants** it (`systemPermissions`)

## See also
Expand Down
6 changes: 3 additions & 3 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@ the anchor decides *which* unit `unit*` means.
- **Capabilities** are named privileges such as `manage_users`,
`manage_metadata`, `setup.access`, `studio.access`. The platform's curated
ones are declared `capability` metadata served by the registry
(`GET /api/v1/meta/capability`) and have no `sys_capability` row; a
capability a package declares with `defineCapability` is also seeded as a
`sys_capability` row.
(`GET /api/v1/meta/capability`); a capability a package declares with
`defineCapability` is served the same way, under its owning package. Neither
has a `sys_capability` row.
Grant them via `systemPermissions`; declare them as prerequisites via an
object/field/app `requiredPermissions` (an **AND**-gate checked before any
CRUD grant).
Expand Down
18 changes: 9 additions & 9 deletions docs/qa/platform-checklist/areas/access-security.json
Original file line number Diff line number Diff line change
Expand Up @@ -2149,15 +2149,15 @@
]
},
"variants": [
"DEFINE — capability metadata authored via defineCapability, bootstrapped to a sys_capability row",
"DEFINE — capability metadata authored via defineCapability, served by the registry (no sys_capability row is written for it — ADR-0131 D3)",
"GRANT — a permission set's systemPermissions carries the capability name",
"REQUIRE — an action's requiredPermissions lists it; invocation on the platform action path is denied unless a granted set carries it (ADR-0066 D4 actionPermissionError)",
"REGRANT — the grant is withdrawn from the permission set and restored; the gate must follow both edits, not cache the first answer",
"shadow-refusal — a declaration whose name collides with PLATFORM_CAPABILITY_NAMES is refused loudly"
"shadow-refusal — a declaration whose name collides with PLATFORM_CAPABILITY_NAMES, or with a name another package declares, is refused loudly by the registry's one-holder rule"
],
"steps": [
"boot showcase isolated; confirm the declared capability bootstrapped: GET the sys_capability row for showcase.export_data (bootstrap-declared-capabilities.ts upsertPackageCapability writes name/label/description/scope)",
"read its fields — label falls back to humanize(name), description to 'Capability <name>.' when absent (capabilityRowFields)",
"boot showcase isolated; confirm the declared capability is served: GET /api/v1/meta/capability/showcase.export_data answers its declared name/label/description/scope, and no sys_capability row names it",
"read its fields — they are the declaration's own; nothing is transcribed into a row",
"identify which platform action-invoke surface this build exposes (dispatcher /actions domain · MCP run_action · in-process ql.executeAction/invokeBusinessAction) and record it; do NOT probe /api/v1/actions/... or /api/v1/data/:object/actions/... — @objectstack/rest mounts no action-invoke route by design (#7680), so a 404 there scores nothing",
"on that surface, INVOKE showcase_zoo_perm_held (requires showcase.export_data) as the GRANTED member holding OpsPermissionSet, then as a NON-granted member: granted executes, non-granted is refused with the actionPermissionError text naming the action and the missing capability",
"REGRANT leg: withdraw showcase.export_data from OpsPermissionSet's systemPermissions, re-invoke as the (formerly) granted member → now refused; restore the grant, re-invoke → executes again",
Expand All @@ -2167,10 +2167,10 @@
],
"acceptance": [
{
"clause": "the package-declared capability materializes as a sys_capability row at boot with its authored identity (name is the upsert key; label/description apply their documented fallbacks)",
"clause": "the package-declared capability is served by the registry with its authored identity, and no sys_capability row is written for it (ADR-0131 D3)",
"oracle": "api",
"verify": "the sys_capability read for showcase.export_data matches the declaration; label/description fallbacks hold when omitted",
"evidence": "the row read + the declaration source"
"verify": "GET /api/v1/meta/capability/showcase.export_data matches the declaration; the sys_capability read finds no row for it",
"evidence": "the metadata read + the empty row read + the declaration source"
},
{
"clause": "the three-way resolves by NAME on the surface that actually enforces it — the PLATFORM action path (ADR-0066 D4 actionPermissionError, reached via the dispatcher /actions domain, the MCP run_action bridge, or ql.executeAction): invoking an action that REQUIREs the capability is refused for a member whose granted sets do not carry it and executes for one that does, the refusal follows a grant WITHDRAWN from the permission set, and execution returns when it is re-granted — grant / deny / regrant, all three legs",
Expand All @@ -2181,7 +2181,7 @@
{
"clause": "a declaration whose name shadows a curated PLATFORM_CAPABILITY_NAME is refused LOUDLY — a package cannot hijack a platform-owned capability name",
"oracle": "build",
"verify": "the shadow declaration is rejected at authoring/boot with a located error naming the collision (bootstrap-declared-capabilities.ts guard)",
"verify": "the shadow declaration is rejected at authoring/boot with a located error naming the collision (the registry's one-holder refusal, SecurityCatalogNameConflictError)",
"evidence": "the refusal text"
},
{
Expand All @@ -2205,7 +2205,7 @@
],
"source": [
"packages/spec/liveness/capability.json (ADR-0066 D1, #5961 — the kind's own liveness ledger)",
"packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#upsertPackageCapability (upsertPackageCapability, capabilityRowFields, PLATFORM_CAPABILITY_NAMES guard)",
"packages/objectql/src/registry.ts#SecurityCatalogNameConflictError (the one-holder refusal: a second holder of a capability name, a curated platform name included)",
"packages/lint/src/validate-capability-references.ts#known (authoring lint known-name set)",
"examples/app-showcase/src/security/{capabilities.ts,permission-sets.ts} (DEFINE + GRANT fixtures)",
"packages/runtime/src/action-execution.ts#actionPermissionError (actionPermissionError — ADR-0066 D4, the single-sourced REQUIRE-side gate every invoke surface calls)",
Expand Down
7 changes: 4 additions & 3 deletions examples/app-showcase/src/security/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ import { defineCapability } from '@objectstack/spec';
* ['showcase.export_data']`.
*
* `packageId` is the ADR-0086 D3 AUTHOR-DECLARED provenance — the documented
* fallback the seeder reads when the registry has not stamped `_packageId`
* (`cap._packageId ?? cap.packageId` in `bootstrapDeclaredCapabilities`).
* Without an owning package the declaration is NOT materialized into
* fallback the declared-capability seeder read when the registry had not
* stamped `_packageId` (`cap._packageId ?? cap.packageId`; that seeder is
* retired by ADR-0131 D3, and no row is written for a declared capability).
* Without an owning package the declaration was NOT materialized into
* `sys_capability` (a `managed_by:'package'` row with no `package_id` would
* make uninstall undefined), which left `OpsPermissionSet` granting a
* capability that never existed — one boot `warn` and an inert security
Expand Down
2 changes: 1 addition & 1 deletion packages/lint/src/validate-capability-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* 1. the built-in platform capabilities (`PLATFORM_CAPABILITY_NAMES`),
* 2. every capability the stack DECLARES via `defineCapability`
* (`stack.capabilities`) — the explicit, package-provenanced declaration
* (ADR-0066 D1), materialized at boot by `bootstrapDeclaredCapabilities`,
* (ADR-0066 D1), served at runtime by the registry (ADR-0131 D3),
* 3. every capability a permission set in this stack GRANTS via
* `systemPermissions` (granting a capability also declares it), and
* 4. any `sys_capability` row shipped as seed data.
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3429,8 +3429,8 @@ const METADATA_ARRAY_KEYS = [
// `permissions` (#5870, #4967 Part 2): the ONLY seam that stamps
// ADR-0010 provenance is `registerItem` → `applyProtection`, so a
// collection missing from this list reaches no registry with a
// `_packageId`. `bootstrapDeclaredCapabilities` resolves the owning
// package as `cap._packageId ?? cap.packageId`; while `capabilities`
// `_packageId`. The declared-capability seeder (retired by ADR-0131 D3)
// resolved the owning package as `cap._packageId ?? cap.packageId`; while `capabilities`
// sat outside this list the first half could never be satisfied and
// `readDeclared(ql, 'capability')` returned nothing, which made the
// author-side `packageId` — documented as the FALLBACK — mandatory,
Expand Down
Loading
Loading