Repository navigation
feat(spec,core)!: positions declare their permissionSets; the authorization resolver reads the security catalog and the activation ledger - #22723
Conversation
…d bodies from the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…eline Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…ecorded Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…declared at kernel:ready Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…ledger regenerated Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…ssionSets through the write-through Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…are the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…-position-permission-sets
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…c entry Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude <noreply@anthropic.com>
…activation ledger The resolver honours ADR-0049 deactivation through sys_metadata_activation (types position and permission) and no longer reads the catalog row's active column. convertDeactivatedCatalogRows turns existing active=false rows into ledger rows for the upgrade ceremony; it is not wired into boot. A composition whose registry has no ledger object issues no ledger read. The admin-standing surface now derives from the ledger; the last-admin guard refuses a ledger write that switches admin_full_access off. The name-fold warning names permissionSets as the governed remedy. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
The resolver reads deactivation from sys_metadata_activation; the install fixtures' grant maps answer it empty, as they answer the other authz tables. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…-position-permission-sets # Conflicts: # packages/plugins/plugin-security/src/security-plugin.ts
The merged capability declarations are told apart by _packageId; the test registry now stamps it as the engine registry does. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…Sets; the ledger-guard test double holds the caller's bound Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
… zh-CN, ja-JP and es-ES Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…-position-permission-sets
… nothing The permissions docs, the delegated-admin TSDoc and the page audience refusal point at the position definition's permissionSets; the suggestion Confirm paragraph says the row it writes does not grant; the grants-cache watch list names sys_metadata_activation. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…-position-permission-sets
…-position-permission-sets # Conflicts: # packages/spec/src/migrations/registry.ts
|
|
…migration registry Byte-identical to that PR's head 2a4ccb4 for render-projection-diff.ts and its test; a no-op once main carries it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…ctive no longer decides a grant Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Contract reviewServed-tier: Third record on this PR; it supersedes 6102570656 (PASS on Gate verdicts on the head, read at posting time. All seven required contexts are ① Derived judgmentsThe delta since
② Semver levelUnchanged in substance from 6102570656 ② and still right: Clause-②: yes ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Part of #15204
Clause-②: yes
Size: 3,776 changed lines (115 files, +2,997 / −779 against
origin/mainatc63028e5bf), over the 3,000 target, delivered as the dispatch allows. About 650 of them are nine copies of a 72-line test helper (security-catalog.testkit.ts, one per package). One shared helper, for example a./testingsubpath on@objectstack/core, would bring this under 3,000. That adds a published package surface, so it is left to the PM rather than done here (additions plus deletions, generated files and test deletions included).Stage 1 of the ADR-0131 cutover batch (stage plan 6094501866; #22601 → B, #22006 → B). This PR lands the half that stage 1 owns:
PositionSchema.permissionSetsreplaces the closed-shape refusal, andresolve-authz-contextreads positions, the sets they name and the set bodies from the security catalog (createSecurityCatalogReader). The junction read and the id bridge are gone. Stages 2 to 8 stay on #15204, which stays open: the services readers, the boot report, verify, objectui C9, the seeder deletions, the projector and write-through retirement, and the table retirement. #15204 remains open after this merges.What changes
@objectstack/spec.PositionSchema.permissionSetsis a list of permission-set names (snake_case). Theguidance.permissionSetsrefusal atposition.zod.tsis gone, and thepermissionsrefusal now points at the new key. The generated reference page, thepackage-api-assembledpage and the liveness ledger row (position/permissionSets, live, evidenceresolve-authz-context.ts#positionSetNamesOf) come with it. A new ADR-0087 semantic entry,position-permission-sets-declared, carries the migration prescription.@objectstack/core, the resolver.resolveUserAuthzGrantstakes the catalog thatsecurityCatalogReaderOf(ql)answers. The security plugin binds it withbindSecurityCatalogReader.permissionSetsnames are what the position grants. Thesys_position_permission_setread and thesys_permission_setread by the junction's ids are deleted.systemPermissions,tabPermissions) is its catalog definition. A name the catalog does not hold confers nothing.sys_metadata_activationread of typespositionandpermission, keyed by every name the resolution may grant, limit two rows a name (the ledger is unique on type and name). A row whoseactiveis false switches the name off, deployment-wide. ⛔ Thesys_positionandsys_permission_setrows are not read at all, theiractivecolumn included: no row fallback. A composition whose registry has no ledger object (noPlatformObjectsPlugin) issues no ledger read; its engine would answer "object not found", whichtryFindreports as a 503 (pinned).sys_metadata_activationjoins the grants-cache watch set.admin_full_accessuser grant, the set resolved in the catalog, and not switched off in the activation ledger.The conversion function for C7b (#15211) and C7a (#22617, PR #22643). It is exported from
@objectstack/core, sopackages/cliimports it with no cycle:Both functions are pure. The deactivation conversion turns the rows' ADR-0049
active: falseflags into ledger rows; a name some organizations switched off and others kept on isconflicting(the ledger is deployment-wide), and the applier supplies thepackage_idthe ledger object requires.convertedis the definition's list followed by the rows' sets: a binding only adds. When organizations holding rows of one name bind different sets (a row with no binding binds the empty list), the name isconflictingand is never merged (D10 fate 4). A binding to a missing position or set row isdangling. ⛔ Neither is wired into boot. The pin isposition-binding-conversion.test.ts(7 tests), and the batch-equivalence testkit drives every fixture's junction rows and deactivated rows through them.@objectstack/plugin-security.start()binds the catalog read (engine registry and metadata service) to the engine, and says so atwarnif it cannot.kernel:ready, the built-ineveryonedeclares the deployment's baseline in itspermissionSets: the app'sisDefaultset andmember_default. That is wherebindBaselineToEveryoneran, with its own high-privilege check and the stack's declared-capability context (A declared app capability token is still refused at theeveryoneanchor:describeHighPrivilegeBits's consumers pass nodeclaredCapabilities(PR #17811 landed the spec predicate only) #18535). A new pin shows the definition and the boot binding agree.reportNameFoldCollisions) named a junction row as the remedy, which is now false; it names the position definition'spermissionSets.position-write-through: a Setup row edit carries the stored definition'spermissionSetsinto the definition it saves (the row has no column for it). Before this change no definition could carry the key. Without the carry, a label edit would silently clear a binding the metadata door declared. Proven by ablation.@objectstack/plugin-auth(a deviation, stated). The admin-standing surface core measures is nowsys_metadata_activation(metadata_type,name,active) where it wassys_permission_set, and plugin-auth's correspondence gate (last-admin-standing-keys.test.ts) demands a standing-key list, and so a hook, for every table the resolver derives standing from. SoSTANDING_KEYS_BY_TABLEcarries the ledger in place ofsys_permission_set, and a new hook refuses a ledger write that switchesadmin_full_accessoff (refuseLedgerSwitchingAdminOff, fail-closed when the target row cannot be read; pinned inlast-admin-guard.activation-ledger.test.ts). It refuses outright, with no enumeration. The guard's own enumeration, itssys_permission_sethooks and the deactivated-admin_full_accessdiagnostic are untouched (measured below).Examples. The eight showcase personas and the three CRM personas declare
permissionSets. Bothbind-position-sets.tsbinders are kept, with a pin that the junction pairs equal the declarations (inert-wirings.test.ts, the newtest/position-bindings.test.ts). The reason is measured: the stage-2 readers still read the junction (below). Deleting the binders here would take those positions' sets away from the delegated-admin gate. The stage that removes the last junction reader deletes them.Equivalence (the stage's acceptance test)
resolve-authz-context.batch-equivalence.test.ts: all 11 fixture principals resolve a byte-identicalgrantsenvelope to the recorded capture. The recording double now carries the catalog the fixture's own rows convert to (convertPositionBindingRows). They cover the platform administrator (permission-set-derived-grants), the organization administrator (tenant-admin-via-position), members (multi-org-membership,position-derived-grants, …), a custom-set holder (position-derived-grants), the seeded API-key principal and the empty principal.The recorded moves are written in the file header. In the two fixtures whose positions bind sets, the junction read and the read by the junction's ids are gone. In every fixture the
sys_positionread and the by-namesys_permission_setreads become onesys_metadata_activationread; the recording double serves the ledger rows the fixture's ownactive: falserows convert to (convertDeactivatedCatalogRows), so the deactivatedretiredposition anddead_setset still grant nothing. Every fixture resolves in 2 legs. Ablation: with the converted ledger rows withheld,position-derived-grantsandpermission-set-derived-grantsgo red on the grants envelope.The delegated administrator and the guest have no separate resolver path. A delegated admin's
adminScopeis not part of the envelope; it is a set like any other here. The guest is assembled outsideresolveUserAuthzGrants. So neither adds a fixture shape the 11 do not cover.Booted goldens (
plugin-security):grant-permission-set-name.equivalence,resolve-authz-grant-set-by-name.golden,grant-readers-by-name.golden, backfill: unchanged.builtin-positions.boot(a booted kernel) records two moves, both written in its header:everyonenow also names the organization-lessmember_default. The per-organization binder could never bind it, because there was no own-organization row.member_defaultcarries no system or tab permission, and enforcement already applied it to every human as the additive baseline. So the envelope gains a name and nothing it grants.everyonedefinition shadowing the built-in (bare-slot precedence), that definition's empty list is what the catalog serves. A v18 boot refuses such a stored row (os migrate security-catalog-overlays).Junction writers (enumerated; positive control: the resolver's own former junction read counted 1 at base
86da194, 0 now)bindBaselineToEveryonesecurity-plugin.tsclosureeveryone's declared baseline (same check). The closure itself is untouched: only delete, never fix; stage 6a deletes itbootstrap-builtin-positions.ts,bootstrap-declared-positions.tsexamples/*/src/security/bind-position-sets.tsplatform-objects/src/pages/sys-position.page.tsrelated list, through the generic data doorsuggested-audience-bindings.ts:990objectui
main(023f00d) reads the junction inAssignedUsersSection.tsxand writes it nowhere; the Setup write goes through the objectstack page's related list.Junction readers left for stage 2 (measured)
delegated-admin-gate.ts(setsBoundToPosition:1248, used by the assignable-positions listing,assertAssignmentWriteand the D12 step-6 containment check),suggested-audience-bindings.ts:468(bindingExists) andcleanup-package-permissions.ts:137. The explainer moved with this PR: it builds onresolveUserAuthzGrants. See open question 1.Open questions (in the report on #15204)
sys_position_permission_set. A position whosepermissionSetsnames a set that no junction row carries looks, to the gate, like a position that distributes nothing. ⛔ Not fenced here: the exclusions put this file in stage 2. The example personas keep their rows and are unaffected. Stage 2 must land before any release cut that contains this PR.active) grants again until the upgrade ceremony appliesconvertDeactivatedCatalogRows. And no door writes the ledger for these types (measured below), so Setup's Deactivate action switches nothing off from here on. C7b and a ledger door forpermission/positionmust land before a release cut that contains this PR.explain-engine.ts, stage 2). After this PR the resolver and the explainer agree only when the rows have been converted to the ledger; the explain fixtures carry the converted ledger rows. Stage 2 moves the label onto the ledger.active(last-admin-guard.ts:1214–1242, the deactivated-admin_full_accessdiagnostic, and the enumeration it guards). Measured, not changed: it now refuses MORE than it must (a row the resolver ignores still counts as off there), never less, and the ledger half of the risk is refused by the new hook. Stage 2's.Measurements the PM asked for
flow(service-automation/src/flow-activation-store.ts, doorPOST /automation/:name/toggle) andaction(objectql/src/action-activation.ts, doorPOST /actions/_activation/:object/:action, gated byruntime/src/domains/activation-gate.ts). The ledger object's data API isget/listonly (sys-metadata-activation.object.ts,enable.apiMethods). No door acceptspermissionorposition. ⛔ Not widened.sys_capability.active. No authorization reader. It is a catalogue flag (sys-capability.object.ts: the Deactivate dialog states authorization is not affected; ruling 2026-08-13). It is written by the boot seeders (active: trueon insert) and the Setup activate/deactivate actions, and read by Setup's list views only. Losing it costs a Setup filter and review marker, no enforcement. Nothing changed.Q3 A promotion path (measured)
An existing write path serves it: the metadata door saves an environment position under the name of an organization's row-only position under
isolated, and the catalog then serves it (position-write-through.test.ts, "promoted by an environment definition saved at the metadata door"). No command promotes a row in place. The changeset names the loss and this path.Coupling with #22628 (C5)
PR #22628 (the metadata read scope
createSecurityCatalogReaderconsumes) merged at 14:03Z and is in this branch through bothmainmerges (the latest atc63028e5bf). The golden and the core suite ran green on top of it (below).Verification (head
83b54d84f1)Each heavy run went through
scripts/pm/os-verify-lock.sh. Exit codes were captured before any pipe.@objectstack/core(after the secondmainmerge)@objectstack/plugin-security(after the second merge)@objectstack/plugin-auth(after the second merge)@objectstack/spec(after the first merge)@objectstack/platform-objects@objectstack/runtime@objectstack/rest@objectstack/objectql@objectstack/cloud-connectionplugin-hono-server·plugin-sharing·plugin-approvalsservice-automation·service-settings·service-datasource·service-storageorganizations·example-crm·example-showcaseThe other rows ran on this branch before the second
mainmerge (c63028e5bf, stage 6c-prep plus unrelated main work). That merge's changes to those packages aremain's own and were not re-run here; CI runs them on the head.Gates.
dispatch-gates.mjs --commandsderived 126 families at83b54d84f1; all 126 ran on that tree.--ran: 126 derived, 126 run, 0 NOT-MEASURED, 0 UNRUN. One family exits 1:check:platform-checklist, with 7 findings (absent symbols inmetadata-protocol/src/protocol.tsandservice-storage/src/attachment-access-hooks.ts, and the anchor floor). It exits 1 identically onorigin/mainatd8830c28(measured in a separate worktree), and this branch touches none of those files.check-plugin-teardown-shape --self-testandcheck:skill-examplesrefused at first for a shallow clone and missing client builds; both measured green once satisfied.check:objectql-double-limitcaught this PR's own new ledger-guard double, which is fixed. Repo-widepnpm lintwas not run locally.Ablations. Withholding the converted ledger rows from the recording double reds
position-derived-grantsandpermission-set-derived-grantson the grants envelope; restored byte-identical. Theposition-write-throughcarry was ablated earlier, also red then restored.Acceptance notes
cloud-connection,plugin-approvals,service-datasource,service-settings,rest,runtime,plugin-auth,plugin-hono-server,plugin-sharingandservice-automationsuites had fake engines that granted sets from rows alone. Each now binds the catalog its own rows convert to, through a small per-packagesecurity-catalog.testkit.ts. The core one is the reference; ⛔ one is copied per package and never imported across packages.activeflag (resolve-authz-context.grant-set-by-name.test.ts, the#20515per-organization binding cases, the#8613ADR-0049 cases) are rewritten to the stage-1 rule: body and existence from the catalog, deactivation from the ledger. Each rewritten case names the rule it now pins, and a new case pins that a row'sactive: falseis not read.cloud-connection's install fixtures answer the authz tables from a grant map; they now answersys_metadata_activationempty too (5 files), as they answer the others.permissionSetsrow, so the metadata-form bundles were regenerated and the row is translated in zh-CN, ja-JP and es-ES. The echo-decision catalog count moves 668 → 669 with its note.mainmerge (stage 6b-1a's registry-declared capabilities), the#18535test registry stamps_packageIdthe way the engine registry does, so the declared-capability reader can tell the platform's own items apart.security-plugin.test.tsexposed no engine registry, so the catalog never bound (its own warn said so) and the org-scoped explain case was red on this branch. It now carries a registry over its definitions.🤖 Generated with Claude Code
https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Generated by Claude Code