Skip to content

Commit 2ff0825

Browse files
feat(plugin-security)!: the security readers read the catalog and the activation ledger (ADR-0131 cutover stage 2a) (#22751)
Part of #15204 Clause-②: yes **Stacked on stage 1.** The base is `claude/issue-15204-s1-position-permission-sets` (PR #22723). GitHub retargets this PR to `main` when stage 1 merges, because merged head branches are deleted. Stage 1's branch was merged in at `d0cab9f8` (its patch round 1 and its `main` merges); the diff below is against that head. Size: **2,694 changed lines** (+1,189 / −1,505, 35 files, against `d0cab9f8`), under the 3,000 line. Test deletions are included. **Stage 2a of the ADR-0131 cutover batch** (stage plan 6094501866 row 2, split by claim amendment 6101897723). The plugin-security readers move onto the security catalog and the activation ledger. Stages 2b (plugin-auth, plugin-sharing) and 2c (the activation door for `permission` and `position`) are separate PRs. #15204 remains open after this merges. ## What changes **`resolvePermissionSets`' row fallback goes** (`security-plugin.ts`). - The plugin's third set source was a per-organization `sys_permission_set` row loader (`dbLoaderFor`, using `resolveOwnOrganizationRow`). It is replaced by a catalog loader: the set the security catalog (`securityCatalogReaderOf`) resolves by name, returned whole. - A set only a row carries now resolves nothing here too. The resolver already granted nothing through it (#15196 Q3 = A). - No `catch` in the loader: a catalog read that did not answer throws `AuthzStoreUnavailableError`, and `PermissionEvaluator.resolvePermissionSets` handles it as it handled a failed row read (fail closed, the #2565 warn). - Deactivation for the position-name fold. A position name folded into the request that resolves a set of the same name is dropped when the activation ledger switched that set off (`dropDeactivatedFoldedSets`). This replaces the row loader's `isRowActive`. The ledger read happens only when a folded name actually resolved, so a request with no collision issues none. - Dead with the loader, and deleted: `permissionSetPageOrRefuse`, `PermissionSetReadUnansweredError` (internal, not exported), `callerOrganizationId`. **The delegated-administration gate** (`delegated-admin-gate.ts`, ADR-0090 D12). This answers stage 1's Q1. - `setsBoundToPosition` reads the position's definition `permissionSets` and each set's `adminScope` from the catalog. The junction rows are not read. So the D12 containment check and the assignable-positions listing judge what an assignment actually grants, a binding declared only on the definition included. - A name the definition carries that the catalog does not hold is still judged: it must be on the delegate's allowlist. It starts granting as soon as a set of that name is authored. - A catalog read failure propagates and refuses the delegate's write. Before, a failure returned `[]`, which approved the allowlist check. - `positionIsDelegatable` reads the definition's `delegatable`. `describeDelegableScope` lists the catalog's positions (anchors excluded), so a registry-only position is offered. - `resolveOwnPosition` is deleted: the catalog is one per deployment (#15196 Q4 = A). - A direct grant's set is the catalog's definition of the name the grant carries. That was `readGrantSetRows`. A re-pointed grant reads its `permission_set_id` to a name (the grant id lookup), and then the catalog body. - Kept for stage 8-pre: the write gates on the `sys_position_permission_set` and `sys_permission_set` tables themselves (`assertBindingWrite`, `assertSetAuthoring`, `positionById`, `loadSetRowById`). They govern writes to tables that still exist; they are not readers of authority. **The explainer** (`explain-engine.ts`). - "deactivated" is read through core's `readDisabledCatalogNames`, the resolver's own ledger read, so explain and enforce agree. - A grant's set is the catalog definition. A name the catalog does not hold is reported neither as expired nor as deactivated. - `readGrantSetRows` and `isRowActive` are no longer imported. **`readGrantSetRows`** (`grant-permission-set-name.ts`) has no reader left and is deleted. What remains in the file is the name-stamping hook pair, which derives `permission_set` from `permission_set_id`, plus `grantSetNameOf`. That half goes with the id column (stage 8 or C7b). **The position-assignment refusal** (`position-catalog-refusal.ts`). - A `sys_user_position.position` value must name a position the security catalog holds. Before, it had to name a `sys_position` row. - A registry-only position is now accepted. That is the data door's accept set widening, hence `Clause-②: yes`. Pinned by `builtin-positions.boot` (a stack-declared position with no row) and `position-catalog-refusal`. - A name only a row carries is now refused, because it grants nothing. A deactivated position is still accepted (#16712's predicate). - The id-spelling hint still reads a `sys_position` row id, under the writer's context, and names it only when the catalog resolves that name. **Q (a): one holder of a position name per deployment** (`position-write-through.ts`). Measured on this base: - S10 (#22582) refuses a Setup create or rename into a name a package or a built-in holds. - The registry's one-holder rule refuses a second package. - The metadata door holds one definition per name. - **The gap:** under `single`, a Setup create, or a rename into a name, that an *environment* definition already held (another organization's Setup row, a metadata-door save, an app stack's declaration) went through. The write-through then overwrote that definition with the new row's label and description and an empty `permissionSets`. - **The fix:** that gap is now refused `409 UNIQUE_VIOLATION`, the wire answer a second row of the name in one organization already gets, and the row write is undone. The error is a new in-package `PositionNameConflictError`, which stamps the same registered constants as `PermissionSetNameConflictError`. - The module is deleted in stage 7b. This executes the Q (a) ruling ("the write door refuses a second holder") on the door that exists until then. **`@objectstack/core`.** - `readDisabledCatalogNames` is exported, so the explainer and the plugin share the resolver's one ledger read. - The catalog read's module doc and the index comment said deactivation lives on the catalog row, which stage 1 already changed. They now name the ledger. This is a comment-only correction. ## Equivalence - `grant-readers-by-name.golden.test.ts`: the recorded golden is **unchanged** in all three postures. Its member now holds a set declared in the catalog and switched off in the ledger, instead of a row with `active: false`. - `explain-enforce-parity`: green, 211 tests together with `explain-engine`. - The delegated-admin pins (delegatable, `adminScope` containment, anchors, holdings per organization) are green on catalog-bound harnesses. - The resolver's `resolve-authz-context.batch-equivalence.golden.json` is untouched: core's resolver is unchanged apart from the export. ## Tests The fake engines and harnesses that bound no catalog now declare one. Most use the plugin-security testkit (`bindCatalogFromTables`), which now also carries `adminScope` and `delegatable`; the rest register items on the real registry. Deactivation fixtures use the ledger. Deleted, because their premise retired with the per-organization catalog: - `delegated-admin-gate-position-organization.test.ts`: positions resolved per organization row. Its rule is replaced by `delegated-admin-gate-catalog.test.ts` and a rewritten block in `delegated-admin-gate.test.ts`. - The row-loader block of `engine-find-bare-array.pin.test.ts`. - The #11121 per-organization row block of `resolve-permission-sets-for-context.pin.test.ts`. Rewritten: `permission-set-active.test.ts` (the ledger is the switch, and the row is not read). REVERSED pin in `orgless-position-name-fold.test.ts`: an organization's row-only set resolves for nobody, its own active members included (#15196 Q3 = A). Two dogfood fixtures authored their position or set as a row, and this PR is what stops rows from counting. They now declare it through the metadata door (`PUT /meta/position/NAME`, `PUT /meta/permission/NAME`): - `delegation-of-duty`: the gate read the row's `delegatable`. - `showcase-scope-depth-fallback`: the fallback set is requested by name, so the row loader used to serve it. The `/me/apps` and FLS dogfood probes failed for the same reason on stage 1's own head; stage 1's patch round 1 moved them, and they are not touched here. `scripts/objectql-double-limit.baseline.json` loses the `permission-set-active.test.ts` entry: the rewritten file's doubles are clean (ratchet down). Ablations (`scripts/ablation-replace.mjs`, each restored byte-identical with `git diff HEAD` empty): 1. Delete the `dropDeactivatedFoldedSets` call: `permission-set-active` 2 failed / 5 passed. 2. Make `setsBoundToPosition` ignore `permissionSets`: `delegated-admin-gate-catalog` 3 failed / 5 passed. 3. Remove the Q (a) insert refusal: `position-write-through` 3 failed / 48 passed. ## Acceptance notes - Release-cut condition (6101876814) is unchanged by this PR. Setup's Deactivate still writes the row, and nothing writes the ledger for `permission` or `position` until stage 2c. - `auto-org-admin-grant.ts` reads `sys_permission_set` only to find the row id a grant's `permission_set_id` foreign key must name (`required: true` on that column), and the ids a revoke must reach. That is the grant id lookup that order correction 4 assigns to stage 8 or C7b, not an authority read. It is left as is. - `security-plugin.ts` still imports `resolveOwnOrganizationRow` and `seedCtx`, but only for `bindBaselineToEveryone` (retired by 6a, "only delete"). - `PermissionEvaluator.resolvePermissionSets` reads the metadata list first and the catalog loader third. A name that both the metadata service and an ADR-0005 registry overlay answer takes the metadata body, while the resolver's catalog read takes the overlay. No such pair was measured in the repository's fixtures. This is recorded for stage 8, which retires the bootstrap list. - `GRANTS_CACHE_WATCHED_OBJECTS` still watches the three retired tables (stage 1's note; harmless over-invalidation). ## Verification All runs are local. Heavy runs ran under `os-verify-lock`, and each exit code was captured before any pipe. On the final head `6d6c6543` (stage 1 at `d0cab9f8`): - plugin-security: 198 files, 4,129 passed / 45 skipped. - plugin-security `typecheck` green (test layer included). - The four dogfood files below: 23 passed. On `4c9f967be` (stage 1 at `bf6bdf07`; the merge since brought only stage 1's `main` merges): - core `typecheck` green. - mcp: 390 passed. - metadata-protocol: 28,101 passed. - dogfood: 239 files, 1,925 passed. Before the first stage 1 merge (`5a960f8ae`, stage 1 at `83b54d84`), these suites were green: plugin-auth, rest, runtime, organizations, service-automation, verify, cli (unit), plugin-approvals, hono, example-crm, example-showcase and cloud-connection. The core golden (`resolve-authz-context.batch-equivalence` + `security-catalog`) was also green, 50 passed. Gates, on `6d6c6543`: `dispatch-gates --commands` derived 134 families, and `--ran` with recorded exit codes reports 134 run, 0 NOT-MEASURED. One family exits 1: `check:platform-checklist`, with 4 ABSENT SYMBOL anchors in `metadata-protocol/src/protocol.ts` and `service-storage/src/attachment-access-hooks.ts`. Stage 1 measured the identical findings on `main`, and this diff touches neither file. Fixed while sweeping, each re-run green: - `check:undeclared-dep-imports`: an `@objectstack/objectql` import, a devDependency here, replaced by the in-package error. - `check:objectql-double-limit` and `check:where-matcher`: the test doubles. The teardown-shape self-test needed its pinned fixture commit, fetched by sha (`git fetch origin 621a487…`). `check:skill-examples` and `check:dual-build-cjs-loads` needed their packages built first. All three were re-run green. The repo-wide `pnpm lint` was not run locally. --- _Generated by [Claude Code](https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7098aca commit 2ff0825

41 files changed

Lines changed: 1384 additions & 1518 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4)
7+
8+
Clause-②: yes
9+
10+
<!-- adr-0087: not-required (already-registered position-permission-sets-declared) stage 1 registered this cutover: row-only catalog items confer nothing, and this changeset carries that rule to the security plugin's own readers -->
11+
12+
**BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog.
13+
14+
**Permission-set resolution (`@objectstack/plugin-security`).**
15+
16+
- FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false.
17+
- TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too.
18+
- Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package.
19+
- A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read.
20+
21+
**Delegated administration (ADR-0090 D12).**
22+
23+
- FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row.
24+
- TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged).
25+
- A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored.
26+
- A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing".
27+
28+
**The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose.
29+
30+
**Position assignments (`sys_user_position.position`).**
31+
32+
- FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused.
33+
- TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted.
34+
35+
**One position name per deployment under `single`.**
36+
37+
- FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`).
38+
- TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`.
39+
40+
**`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,8 @@ an expiry: deactivation is an incident-response control, installation-wide,
430430
and there is no date on the user's own grant row to notice — without the
431431
state, the admin diagnosing "why did access disappear" would get the same
432432
answer as for a user who never held the grant, with no pointer to the
433-
catalogue row somebody switched off. Reported for the grant rows explain
433+
`sys_metadata_activation` row somebody switched off — the explainer reads the
434+
same ledger the resolver does, so explain and enforce agree. Reported for the grant rows explain
434435
already walks: `sys_user_position` rows and direct `sys_user_permission_set`
435436
grants (a deactivated set reached only through an active position's linkage is
436437
not re-derived — that would replicate the resolver's aggregation).
@@ -455,9 +456,12 @@ Deactivation is an incident-response control, so what it does **not** touch is
455456
deliberate. Administration surfaces keep listing and editing a deactivated
456457
position — an admin must still be able to unbind and clean up what they just
457458
switched off — and the write gates that judge audience-anchor bindings and a
458-
delegated administrator's blast radius keep reading every row, deactivated
459-
included: dropping rows there would make a refused binding *permitted* and a
460-
delegate's boundary *narrower*, which is the opposite of switching access off.
459+
delegated administrator's blast radius do not consult the activation ledger:
460+
they judge every binding and assignment in front of them — including the
461+
permission sets a position's definition names — whether or not the ledger
462+
switched it off, because skipping a switched-off one would make a refused
463+
binding *permitted* and a delegate's boundary *narrower*, which is the
464+
opposite of switching access off.
461465

462466
## Governance: how "declared = enforced" is kept true
463467

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ that silently does not happen.
129129
| 21b | **`readonly` strip bypassed — the write PREVIEW (`ObjectQL.validate`)** | objectql | Same as 19–21, in the preview: it runs the write's caller-write strips under the write's own gate, so a system preview reports no `readonly` drop and judges the value a system write would store. The `computed` strip of a `formula` value is NOT gated here or on either write verb — no driver has a column for it, so it runs for every caller | `packages/objectql/src/engine.ts#validate` |
130130
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing. One exception: a `formula` value, which the `computed` strip takes from every caller, so strict still refuses it | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
131131
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
132-
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no `sys_position` row is not refused | plugin-security | Get: a system writer can store an assignment naming no catalog row — the seed loader writes `stack.data` from `AppPlugin.start()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when no catalog row the writer's context reaches carries the name: its organization's rows plus the organization-less ones, read as `{ ...context, isSystem: true }`, never a bare `{ isSystem: true }`, so a name only another organization carries is refused like one nobody carries. It is the same stand-down, and the same tenant scope, as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
132+
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
133133
| 23c | **Grant-name check relaxed for an unresolvable id** — a `sys_user_permission_set` write that supplies `permission_set` beside a `permission_set_id` naming no set the writer's context reaches keeps the supplied name | plugin-security | Get: a system writer can store a grant naming a set whose row does not exist yet — seed replay and boot provisioning may write the grant before the set row, the ordering the engine's referential-integrity check also stands down for. Lose: such a row's name is unchecked until a backfill names it. Everything else is judged for every caller, system included: the name is derived from the id on every write that carries one, and a supplied name that names any other set is refused `400 VALIDATION_FAILED` / `invalid_value`; a non-system caller's name beside an unresolvable id is refused the same way, read as `{ ...context, isSystem: true }` through the hook's `ctx.api.sudo()`, never a bare `{ isSystem: true }` (ADR-0131 D4) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#settle` |
134134
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is a global grant and is not judged | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` |
135135
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |

‎packages/core/src/security/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ export {
156156
// [#20580] The session arm's membership check (#15409 ruling B), so the
157157
// permission explainer resolves the user it explains through the same one.
158158
vetOrganizationClaim,
159+
// [ADR-0049, ADR-0131 D3, ADR-0126 §4] The one deactivation read of the
160+
// security catalog — the `sys_metadata_activation` ledger, never a catalog
161+
// row's `active` column — shared with the explainer and the security plugin.
162+
readDisabledCatalogNames,
159163
resolveLocalizationContext,
160164
type ResolvedAuthzContext,
161165
type ResolveAuthzInput,
@@ -232,7 +236,8 @@ export { isRowActive, type ActivatableRow } from './row-active.js';
232236
// ADR-0131 D2–D4 — the ONE by-name read of the security catalog (positions,
233237
// permission sets, capabilities) over the engine registry and the metadata
234238
// service. It says a definition EXISTS under a name, never that it is in
235-
// effect: the row `active` flag above stays the authority for that.
239+
// effect: the activation ledger is the authority for that
240+
// (`readDisabledCatalogNames`, exported with the resolver below).
236241
export {
237242
createSecurityCatalogReader,
238243
bindSecurityCatalogReader,

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,8 +771,13 @@ function ledgerUnregistered(ql: any): boolean {
771771
* engine answers "object not found" rather than a missing table — so the
772772
* registry is asked first, as `ObjectQLPlugin`'s own ledger hydration does,
773773
* and the read is not issued. An engine with no registry to ask is read.
774+
*
775+
* Exported so every other reader of "is this switched off" — the permission
776+
* explainer's dropped-grant report, the security plugin's position-name fold —
777+
* asks this one read instead of a second copy, and explain and enforce cannot
778+
* disagree about deactivation.
774779
*/
775-
async function readDisabledCatalogNames(
780+
export async function readDisabledCatalogNames(
776781
ql: any,
777782
positions: readonly string[],
778783
sets: readonly string[],

‎packages/core/src/security/security-catalog.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,14 @@
6767
*
6868
* ## What this read does NOT answer
6969
*
70-
* - ⛔ **Whether an item is in effect.** Deactivation lives on the catalog
71-
* ROW (`sys_position.active`, `sys_permission_set.active`; standing ruling:
72-
* the row flag stays authoritative), and no definition carries it. An entry
73-
* here says a definition EXISTS under that name — never that it grants. A
74-
* caller that drops the row flag because it now reads definitions would let
75-
* a deactivated set grant again: the one widening this card must never
76-
* open. Read the flag with `isRowActive` (`row-active.ts`) as before.
70+
* - ⛔ **Whether an item is in effect.** No definition carries the switch:
71+
* deactivation is the activation ledger's (`sys_metadata_activation`, one
72+
* row per `(metadata_type, name)`; ADR-0126 §3 regime C as ADR-0131 D6
73+
* amends it), read through `readDisabledCatalogNames`
74+
* (`resolve-authz-context.ts`) — never a catalog row's `active` column. An
75+
* entry here says a definition EXISTS under that name — never that it
76+
* grants. A caller that reads a definition and skips the ledger would let a
77+
* deactivated set grant again: the one widening this read must never open.
7778
* - **The position → permission-set binding as a separate fact.** It is part
7879
* of the position definition (`PositionSchema.permissionSets`, ADR-0131
7980
* D3/D4); the resolver reads it from the entry this module answers, and no

‎packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function catalogFromTables(tables: Record<string, readonly unknown[] | un
5454
for (const row of setRows) {
5555
if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue;
5656
const definition: Definition = { name: row.name };
57-
for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) {
57+
for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects'], ['admin_scope', 'adminScope']] as const) {
5858
const value = parsed(row[column] ?? row[key]);
5959
if (value !== undefined && value !== null) definition[key] = value;
6060
}
@@ -65,9 +65,16 @@ export function catalogFromTables(tables: Record<string, readonly unknown[] | un
6565
permissionSets: setRows,
6666
bindings: (tables.sys_position_permission_set ?? []) as Definition[],
6767
});
68+
// A position definition also carries the row's `delegatable` (ADR-0091 D3),
69+
// which the delegated-administration gate reads from the definition.
70+
const positionRows = (tables.sys_position ?? []) as Definition[];
71+
const delegatable = (name: string): boolean =>
72+
positionRows.some((r) => r.name === name && (r.delegatable === true || r.delegatable === 1 || r.delegatable === '1'));
6873
return {
6974
permissions,
70-
positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])),
75+
positions: positions.flatMap((f) => (f.fate === 'conflicting'
76+
? []
77+
: [{ name: f.name, permissionSets: [...f.permissionSets], ...(delegatable(f.name) ? { delegatable: true } : {}) }])),
7178
};
7279
}
7380

0 commit comments

Comments
 (0)