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
44 changes: 32 additions & 12 deletions docs/qa/platform-checklist/areas/access-security.json
Original file line number Diff line number Diff line change
Expand Up @@ -1809,10 +1809,10 @@
},
{
"id": "access-security.capability-declaration-lifecycle",
"title": "A package-declared capability bootstraps into sys_capability and resolves across the grant/require three-way (ADR-0066); platform names cannot be shadowed",
"title": "A package-declared capability bootstraps into sys_capability and resolves across the grant/require three-way (ADR-0066) on the PLATFORM action path; platform names cannot be shadowed",
"since": "v17",
"status": "active",
"revision": 1,
"revision": 2,
"priority": "P1",
"surface": "mixed",
"personas": [
Expand All @@ -1824,22 +1824,27 @@
"app": "showcase",
"requires": [
"the shipped capability declaration examples/app-showcase/src/security/capabilities.ts (showcase.export_data, org-scoped, defineCapability) — the DEFINE side of ADR-0066",
"OpsPermissionSet granting it via systemPermissions (examples/app-showcase/src/security/permission-sets.ts) — the GRANT side"
"OpsPermissionSet granting it via systemPermissions (examples/app-showcase/src/security/permission-sets.ts) — the GRANT side",
"the shipped REQUIRE-side specimens examples/app-showcase/src/ui/actions/predicate-matrix.action.ts: showcase_zoo_perm_held (requires showcase.export_data — the GRANTED capability), showcase_zoo_perm_missing (requires showcase.restricted_ops — DEFINED but granted to nobody), showcase_zoo_perm_and (requires BOTH — the gate is an AND), showcase_zoo_perm_empty (requiredPermissions:[] — always passes). All four are type:'script' on the `zoo` object, so the platform action path can invoke them"
],
"knownGaps": [
"no shipped resource REQUIREs showcase.export_data yet (the source note calls it a 'future export endpoint/action') — the require-side enforcement clause needs a scratch resource carrying requiredPermissions:['showcase.export_data'], or is recorded as a knownGap on stock seeds"
"the require side is exercised on the PLATFORM action path only — there is NO REST action-invoke route on this build and its absence is deliberate (#7680, ruling 2026-08-12): see the `data-actions` block comment in packages/rest/src/rest-route-ledger.ts. Which platform surface is reachable depends on the HTTP front the build under test boots (@objectstack/rest mounts neither /actions nor /mcp); the runner picks whichever of the dispatcher /actions domain, the MCP run_action bridge, or in-process ql.executeAction/invokeBusinessAction this build exposes, and RECORDS which — all three funnel through the single-sourced actionPermissionError"
]
},
"variants": [
"DEFINE — capability metadata authored via defineCapability, bootstrapped to a sys_capability row",
"GRANT — a permission set's systemPermissions carries the capability name",
"REQUIRE — a resource's requiredPermissions lists it; denied unless a granted set carries it",
"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"
],
"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)",
"as the GRANTED member (holding OpsPermissionSet) and as a NON-granted member, exercise a resource that REQUIREs the capability (author a scratch resource with requiredPermissions:['showcase.export_data'] if none ships — see knownGaps): granted passes, non-granted is denied 403",
"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",
"invoke showcase_zoo_perm_missing (requires showcase.restricted_ops, granted to nobody) as EVERY persona incl. the OpsPermissionSet holder → refused for all; and showcase_zoo_perm_empty (requiredPermissions:[]) → executes for all, since 'declares nothing' is not 'denies everything'",
"author a scratch package declaring a capability whose name is in PLATFORM_CAPABILITY_NAMES; boot/validate",
"author a scratch capability with a malformed/unvalidated shape and confirm the write door validates it before it reaches the sys_capability upsert (the #5961 concern: an unvalidated row lands directly in the authorization namespace)"
],
Expand All @@ -1851,10 +1856,10 @@
"evidence": "the row read + the declaration source"
},
{
"clause": "the three-way resolves by NAME: a resource requiring the capability is denied for a member whose granted permission sets do not carry it, and permitted for one that does — both sides",
"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",
"oracle": "api",
"verify": "403 for the non-granted member, 2xx for the OpsPermissionSet holder, on a resource carrying requiredPermissions:['showcase.export_data']",
"evidence": "the two responses (scratch resource if none ships — record which)"
"verify": "on the platform surface this build exposes (recorded per steps): showcase_zoo_perm_held executes for the OpsPermissionSet holder and is refused for a non-granted member with the actionPermissionError string naming the action and the missing capability; after withdrawing showcase.export_data from OpsPermissionSet the SAME holder is refused, and after restoring it executes again. Cross-checks on the same surface: showcase_zoo_perm_missing (showcase.restricted_ops, granted to nobody) is refused for every persona, and showcase_zoo_perm_empty (requiredPermissions:[]) executes for every persona. This clause is NOT scored against @objectstack/rest — it mounts no action-invoke route by design (#7680)",
"evidence": "the invocation results for all three legs (grant → deny → regrant) plus the two cross-checks, each with the refusal text or the executed result, and a note of which platform surface was driven"
},
{
"clause": "a declaration whose name shadows a curated PLATFORM_CAPABILITY_NAME is refused LOUDLY — a package cannot hijack a platform-owned capability name",
Expand All @@ -1870,24 +1875,39 @@
}
],
"negative": [
"a shadow declaration silently accepted (overwriting a platform capability) is a FAIL; an unvalidated capability row reaching sys_capability is the #5961 authz-namespace-injection FAIL"
"a shadow declaration silently accepted (overwriting a platform capability) is a FAIL; an unvalidated capability row reaching sys_capability is the #5961 authz-namespace-injection FAIL",
"REQUIRE-side fail-open: a NON-granted member whose platform-path invocation of showcase_zoo_perm_held SUCCEEDS is the ADR-0066 D4 gate failing open — FAIL, and the server-side shape of the client-side fail-open #3923 reported. Likewise showcase_zoo_perm_missing executing for anyone (nobody is granted showcase.restricted_ops), or showcase_zoo_perm_and executing for a caller holding only showcase.export_data (the gate is an AND, not an OR)",
"stale-grant: after showcase.export_data is withdrawn from OpsPermissionSet, the former holder still executing showcase_zoo_perm_held is a FAIL — the gate is reading a cached grant, not the current permission set",
"over-block: the OpsPermissionSet holder REFUSED on showcase_zoo_perm_held, or showcase_zoo_perm_empty refused for anyone, is a FAIL in the other direction — 'declares nothing' is not 'denies everything'",
"a 404 from a REST action-invoke probe is NEITHER pass nor fail — that surface does not exist on this build by design (#7680). Scoring this clause off a REST 404, in either direction, is a mis-run: re-run it on the platform surface"
],
"traps": [
"wrong-persona",
"stale-dist"
"stale-dist",
"dispatcher-vs-hono-route"
],
"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, capabilityRowFields, PLATFORM_CAPABILITY_NAMES guard)",
"packages/lint/src/validate-capability-references.ts (authoring lint known-name set)",
"examples/app-showcase/src/security/{capabilities.ts,permission-sets.ts} (DEFINE + GRANT fixtures)"
"examples/app-showcase/src/security/{capabilities.ts,permission-sets.ts} (DEFINE + GRANT fixtures)",
"packages/runtime/src/action-execution.ts (actionPermissionError — ADR-0066 D4, the single-sourced REQUIRE-side gate every invoke surface calls)",
"packages/runtime/src/domains/{actions.ts,mcp.ts} + packages/runtime/src/route-ledger.ts (the platform action-invoke surfaces that reach it)",
"examples/app-showcase/src/ui/actions/predicate-matrix.action.ts (the four REQUIRE-side specimens: held / missing / and / empty)",
"packages/rest/src/rest-route-ledger.ts `data-actions` block (why there is no REST action-invoke route, and the day-one enforcement requirement if one is ever added — #7680)"
],
"history": [
{
"revision": 1,
"date": "2026-08-08",
"change": "initial — the coverage ratchet caught `capability` as a NEW metadata kind (#5961 landed on main); authored the declaration→bootstrap→grant/require lifecycle + shadow-refusal + the #5961 authz-namespace validation concern",
"ref": "claude/platform-test-checklist-ocwugl"
},
{
"revision": 2,
"date": "2026-08-12",
"change": "re-pointed the REQUIRE clause at the surface that enforces it (#7680, maintainer ruling 2026-08-12). The clause asked for a 403 from a REST resource carrying requiredPermissions; no such surface exists — @objectstack/rest mounts no action-invoke route, deliberately — so the clause was unrunnable as written and the run fell back to a platform capability. Now: grant/deny/regrant driven through the platform action path (actionPermissionError via the dispatcher /actions domain, MCP run_action, or ql.executeAction) against the four shipped predicate-matrix specimens, with the fail-open / stale-grant / over-block / AND-vs-OR failure modes spelled out in `negative`, and REST 404s explicitly scoring nothing. The knownGap moved from 'no resource REQUIREs it' (false — four actions do) to 'which platform surface the build exposes, recorded per run'",
"ref": "claude/issue-7680-package-capability-doctrine"
}
]
}
Expand Down
36 changes: 36 additions & 0 deletions packages/rest/src/rest-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,42 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
{ route: 'DELETE /api/v1/data/:object/:id', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.delete' },

// ── data actions (clone / import / import jobs / export) ──────────────────
//
// THERE IS NO ACTION-INVOKE ROUTE IN THIS FAMILY, AND ITS ABSENCE IS
// DELIBERATE (#7680). This family is the built-in DATA operations only. No
// route on this server invokes a declared object/record action: not
// `POST /api/v1/data/:object/actions/:name`, nor `/api/v1/actions/:name`,
// `/api/v1/action/:name`, `/api/v1/objects/:object/actions/:name`. A QA probe
// (#7637) measured all four spellings 404 against a booted showcase, which is
// the state this table records — a missing row here, not a missing entry.
//
// WHERE `requiredPermissions` IS ACTUALLY ENFORCED. ADR-0066 D4's capability
// gate is `actionPermissionError` (packages/runtime/src/action-execution.ts),
// and every caller that reaches it does so on a PLATFORM path: the runtime
// dispatcher's `/actions` domain (runtime/src/domains/actions.ts, which
// dispatches through `ql.executeAction`) and the MCP `run_action` bridge
// (runtime/src/domains/mcp.ts). Those routes are ledgered in
// `packages/runtime/src/route-ledger.ts`, not here. Read
// `actionPermissionError`'s own docstring with that split in mind: the "REST
// `/actions/...` route" it names is the DISPATCHER's HTTP surface, not a
// route `@objectstack/rest` mounts. So "`requiredPermissions` is not enforced
// over REST" is not a defect on this build — it is a surface that does not
// exist, and a 404 from the probes above is evidence of nothing else.
//
// IF YOU ARE THE AUTHOR ADDING AN ACTION-INVOKE ROUTE HERE: server-side
// `requiredPermissions` enforcement is a DAY-ONE requirement of that route,
// not a follow-up you file behind it. `Action.requiredPermissions` is
// authored metadata the Console ALSO gates on client-side; a REST invoke door
// that ships without calling the same gate resurrects precisely the
// client-side fail-open #3923 reported — the action greyed out in the UI and
// wide open on the wire, which is the worst of both, because the UI's refusal
// reads as proof the rule is being kept. Call `actionPermissionError` rather
// than re-deriving the check: it is single-sourced so that every invoke
// surface enforces the SAME declaration, and a second implementation is a
// second thing to drift. Then ledger the new route with that gate named in
// its `note`, and re-point the platform-checklist item that this comment's
// counterpart clause sends to the platform path
// (`access-security.capability-declaration-lifecycle`).
{ route: 'POST /api/v1/data/:object/:id/clone', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.clone' },
{ route: 'POST /api/v1/data/:object/import', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.import' },
{ route: 'POST /api/v1/data/:object/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.createImportJob' },
Expand Down
Loading