From 59761d4a712a02dba7a7d3302e05b5e035705750 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:11:53 +0000 Subject: [PATCH 1/3] docs(adr),refactor(plugin-security): restate the ADR text that names C3-retired security machinery; delete the unreachable audience-anchor binding gate SecurityPlugin.assertAudienceAnchorBindingGate guarded writes to sys_position_permission_set alone, and C3 stage 8 unregistered that object, so no write reached it. The gate, its middleware call and its three unit cases are deleted, with the two imports only it used. The ADRs that still named the retired machinery are restated to what main does: - ADR-0138: the anchor binding tier runs for everyone on confirm and on every derivation, and no door binds a set to guest until the bind door; the anchor to the deleted gate is dropped. - ADR-0094: the projector's module and suite are recorded as deleted by stage 7a. - ADR-0126: a path note on the deleted projection module. - ADR-0131: notes on the consumer and code lists the C3 stages emptied. - ADR-0103: the provenance note says which gate guarded which object, and that no write reaches it after stage 8. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...03-audience-anchor-binding-gate-retired.md | 11 +++ ...0094-sys-permission-set-pure-projection.md | 15 ++-- ...dby-write-policy-and-engine-write-guard.md | 11 ++- ...6-packaged-metadata-customization-model.md | 2 +- ...ation-ownership-no-null-organization-id.md | 10 +++ ...principal-doors-grants-and-organization.md | 22 +++-- .../src/security-plugin.test.ts | 37 -------- .../plugin-security/src/security-plugin.ts | 88 +------------------ 8 files changed, 54 insertions(+), 142 deletions(-) create mode 100644 .changeset/23403-audience-anchor-binding-gate-retired.md diff --git a/.changeset/23403-audience-anchor-binding-gate-retired.md b/.changeset/23403-audience-anchor-binding-gate-retired.md new file mode 100644 index 00000000000..1353c9f46f8 --- /dev/null +++ b/.changeset/23403-audience-anchor-binding-gate-retired.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-security': patch +--- + +refactor(plugin-security): the audience-anchor binding gate on `sys_position_permission_set` is deleted + +Clause-②: no + +`SecurityPlugin`'s private engine-middleware gate that refused binding a high-privilege permission set to the `everyone` or `guest` position guarded writes to `sys_position_permission_set` only. C3 stage 8 (#23222) unregistered that object, so no write has reached the gate since. The gate, its call in the middleware and its three unit cases are removed. + +No behaviour changes, and nothing exported changes. The anchor tier itself (`describeAnchorForbiddenBits` from `@objectstack/spec/security`) still applies wherever a binding is made: when an `everyone` suggestion is confirmed (`confirmAudienceBindingSuggestion`), and on every derivation of the `everyone` anchor. No door binds a set to `guest`. diff --git a/docs/adr/0094-sys-permission-set-pure-projection.md b/docs/adr/0094-sys-permission-set-pure-projection.md index e4c2618c6e6..8b116fcec79 100644 --- a/docs/adr/0094-sys-permission-set-pure-projection.md +++ b/docs/adr/0094-sys-permission-set-pure-projection.md @@ -16,7 +16,7 @@ ADR-0005 rollback landed by #6483 / PR #6608) > **RETIRED 2026-10-13 (#15204 stage 8, `66949b7729`, by execution of [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D2/D3/D13).** > The `sys_permission_set` row object is no longer registered, and the machinery that kept it a > projection — the write-through (D3), the `permission` projector (D2) and the boot reconcile and -> backfill (D4) — is unwired; its module stays in the tree until #15204 stage 7a deletes it. D1 +> backfill (D4) — is unwired, and #15204 stage 7a (#23368) deleted its module (`permission-set-projection.ts`, with the seed-name lookup and the refusal sink). D1 > stands. Read the rest of this summary, D2–D4 and the Consequences in the past tense. A permission-set **definition** (label, description, the six facet groups, `adminScope`, @@ -184,9 +184,10 @@ is `managed_by:'admin'` while its definition ships as a platform artifact, and i data-door edit is **refused** — the record's provenance column was never what the gate read. The mirror case is `twodoors_pkgset`, whose row is `managed_by:'package'` while its definition lives in `sys_metadata` (that materialization is pinned in the same file); -that an edit of such a row still **lands** is pinned as a unit case in +that an edit of such a row still **lands** was pinned as a unit case in `packages/plugins/plugin-security/src/permission-set-projection.test.ts`, whose protocol -stub models this gate. +stub modelled this gate, until #15204 stage 7a (#23368) deleted that suite with the +projection it pinned. The refusal is left to the producer deliberately: `plugin-security` does not re-derive artifact-backing to pre-empt it. That rule belongs to the metadata protocol @@ -395,9 +396,11 @@ other write. `packages/metadata-protocol/src/protocol.ts` (`saveMetaItem` / `deleteMetaItem` tier gates) and `packages/metadata-protocol/src/sys-metadata-repository.ts` (`assertAllowed`). -- Implementation: `packages/plugins/plugin-security/src/permission-set-projection.ts`, - `packages/metadata-protocol/src/protocol.ts` (`registerMutationProjector`), - `packages/plugins/plugin-security/src/security-plugin.ts` (wiring). +- Implementation: `packages/plugins/plugin-security/src/permission-set-projection.ts` + (deleted by #15204 stage 7a, #23368, after stage 8 unwired it), + `packages/metadata-protocol/src/protocol.ts` (`registerMutationProjector`, the seam that + stays), `packages/plugins/plugin-security/src/security-plugin.ts` (the wiring, removed by + stage 8). --- diff --git a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md index 6d27e67a08a..d2d6aca269a 100644 --- a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md +++ b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md @@ -49,9 +49,14 @@ write policy **expressible and enforced** without breaking the writable set. > **Not to be confused with** the row-level `managed_by` *provenance* column > (`'platform' | 'package' | 'admin'`) on `sys_sharing_rule` / `sys_position` / -> `sys_capability`, guarded by `assertSystemRowWriteGate` (ADR-0066). That is a -> per-row provenance axis; this ADR is about the per-object schema bucket. The -> shared word is a coincidence. +> `sys_capability` (ADR-0066). That is a per-row provenance axis; this ADR is about +> the per-object schema bucket. The shared word is a coincidence. On `main` today +> only `sys_sharing_rule` carries that column on a registered object, and its admin +> edits are stamped `customized` by plugin-sharing's provenance hook +> (`packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts`), not refused. +> `SecurityPlugin.assertSystemRowWriteGate` guarded `sys_position` and +> `sys_capability` (never `sys_sharing_rule`), and no write reaches it since +> #15204 stage 8 unregistered both. > **Note (2026-10-14) — three objects named above are retired.** `sys_position_permission_set` > (item 2), `sys_position` and `sys_capability` are no longer registered since #15204 stage 8 diff --git a/docs/adr/0126-packaged-metadata-customization-model.md b/docs/adr/0126-packaged-metadata-customization-model.md index f822b261d98..f2a1e14e920 100644 --- a/docs/adr/0126-packaged-metadata-customization-model.md +++ b/docs/adr/0126-packaged-metadata-customization-model.md @@ -94,7 +94,7 @@ Three real mechanisms operate across those tiers, each invented separately: - **Clone-to-customize** (#11513, permission sets only): server-side lock on the packaged base (`packaged-permission-set-lock.ts`), a clone action demanding a new name, and — the piece this ADR generalizes — the ruling that **switching a packaged artifact off is row state, not a - definition write** (`packages/plugins/plugin-security/src/permission-set-projection.ts`, #4669). + definition write** (`packages/plugins/plugin-security/src/permission-set-projection.ts`, #4669; *Path note, 2026-10: #15204 stage 8 retired the `sys_permission_set` row whose `active` column carried that row state, and stage 7a (#23368) deleted the module; the path is kept as the record of what this section measured*). - **Extend** (`objectExtensions`, `packages/spec/src/data/object.zod.ts`; navigation contributions, ADR-0029 D7): a package adds fields/validations/indexes to another package's object, or nav items to another package's app, merged at boot. Package-grain, additive, diff --git a/docs/adr/0131-total-organization-ownership-no-null-organization-id.md b/docs/adr/0131-total-organization-ownership-no-null-organization-id.md index ef20c974501..aa669f8a873 100644 --- a/docs/adr/0131-total-organization-ownership-no-null-organization-id.md +++ b/docs/adr/0131-total-organization-ownership-no-null-organization-id.md @@ -63,6 +63,12 @@ seeders, `bootstrap-platform-admin.ts`, `permission-set-projection.ts`), `plugin `plugin-audit`, `service-storage`, `plugin-approvals`, `service-automation`, `objectui` (Setup / Studio / pickers — D7), and in their own repository `cloud`'s control plane and `objectos-ee`. +> **Note (2026-10-14) — C3 retired the `plugin-security` consumers it names.** The five bootstrap +> seeders went with #15204 stages 6a and 6b (ADR-0131 D2), and `permission-set-projection.ts` with +> stage 7a (#23368), after stage 8 (`66949b7729`) unwired it. `tenant-layer.ts`, +> `per-organization-catalog.ts` and `bootstrap-platform-admin.ts` remain. The list above is the +> consumer set as this record was written. + --- ## TL;DR @@ -999,6 +1005,10 @@ fate 2 after a verified rewrite; no surface builds a merged server-paged list of `packages/plugins/plugin-email/src/template-loader.ts`; `packages/metadata-core/src/meta-write-org-scope.ts`; `packages/spec/src/data/injected-system-columns.ts`; `scripts/check-tenant-chokepoint.mjs`. + *Note (2026-10-14):* of the `plugin-security` files above, `bootstrap-declared-permissions.ts`, + `bootstrap-declared-positions.ts`, `bootstrap-builtin-positions.ts` and + `bootstrap-system-capabilities.ts` were deleted by #15204 stages 6a and 6b, and + `permission-set-projection.ts` by stage 7a (#23368). The list is kept as measured at `2514d49f3`. - Code (cloud, `main` `3856fbf7`): `packages/service-cloud/src/control-plane-platform-global.ts`; `packages/service-cloud/src/control-plane-org-scope-plugin.ts`; `packages/service-cloud/src/migrations/org-id-backfill.ts`; diff --git a/docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md b/docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md index c26c0454f84..4008d452a76 100644 --- a/docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md +++ b/docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md @@ -167,8 +167,10 @@ re-checked at the same head, shape the decisions: enforcing reader keys on the string `'guest'`: the envelope (`packages/core/src/security/assemble-execution-context.ts#entryFields`), explain's `EXTERNAL` floor (`packages/plugins/plugin-security/src/explain-engine.ts#derivePosture`), the anchor binding tier - (`packages/spec/src/security/high-privilege.ts#describeAnchorForbiddenBits`, called from - `packages/plugins/plugin-security/src/security-plugin.ts#SecurityPlugin.assertAudienceAnchorBindingGate`), + (`packages/spec/src/security/high-privilege.ts#describeAnchorForbiddenBits`; at `7b926f7600` an + engine write gate on `sys_position_permission_set` called it, and that table was retired by #15204 + stage 8 and the gate deleted by #23403, so on `main` its `guest` tier has no caller until item 5's + bind door), and delegated administration (`packages/plugins/plugin-security/src/delegated-admin-gate.ts#ANCHOR_POSITIONS`). - **Two guest values are declared with nothing behind them.** A package may suggest a `guest` binding (`packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts#anchor`), @@ -367,8 +369,12 @@ unchanged. **Card**: E2. three. 4. **The binding tier stands.** A set bound to `guest` must pass the strictest anchor tier: explicit objects only, no wildcard, read-mostly with create case by case, no view-all or - modify-all, no system permissions (ADR-0090 D9; `describeAnchorForbiddenBits`, enforced by - `SecurityPlugin.assertAudienceAnchorBindingGate`). A delegated administrator never binds an anchor + modify-all, no system permissions (ADR-0090 D9; `describeAnchorForbiddenBits`). The predicate + runs where a binding is made: for `everyone`, when a suggestion is confirmed + (`confirmAudienceBindingSuggestion`) and on every derivation of the anchor + (`readAcceptedEveryoneSets`); for `guest`, no door binds a set today (item 5), and the bind door + runs it when built. The engine gate that once applied it to `sys_position_permission_set` rows + retired with that table (#15204 stage 8; the gate was deleted by #23403). A delegated administrator never binds an anchor (ADR-0090 D12; `ANCHOR_POSITIONS`). Unchanged. 5. **How a deployment grants: implementation-first.** The bind door is built when a named deployment needs to grant the guest on a public surface (a site or portal) and the class-1 form @@ -391,9 +397,9 @@ unchanged. **Card**: E2. so ⛔ opening that door (`PUT /meta/position/guest`) is refused: it reverses the seal and misses environment kernels. The row half stays item 6: share links and, when built, an explicit guest sharing rule (read-only, criteria-based, the strictest lint tier), the only shape in which D8's - retired `guest` recipient returns. **Re-read when the door is built:** item 4's guest tier of - `assertAudienceAnchorBindingGate`, which guards `sys_position_permission_set` rows that no - binding reader reads, and objectui's pin that a guest row offers no Accept + retired `guest` recipient returns. **Re-read when the door is built:** item 4's guest tier, which + no door runs today (the engine gate that carried it guarded `sys_position_permission_set` rows, + retired by #15204 stage 8, and was deleted by #23403), and objectui's pin that a guest row offers no Accept (`objectui:packages/app-shell/src/components/SuggestedBindingsPanel.tsx`). Amended by ruling C on [#23267](https://github.com/objectstack-ai/objectstack/issues/23267) ([`6159279205`](https://github.com/objectstack-ai/objectstack/issues/23267#issuecomment-6159279205)). @@ -575,7 +581,7 @@ that card lands; no row is declared without an enforcer. | no `system` flow behind an anonymous flow door | D2b | the publish path, in both directions | card E2 | | the guest's grants, and deny-all when none | D3 | the anonymous branch of `resolveAuthzContext` | card E1 | | no door binds a set to `guest` until the bind door is built | D3 item 5 (amended) | the anchors' seal (`NOT_OVERRIDABLE`; the package door's built-in names) and `confirmAudienceBindingSuggestion`'s refusal of a `guest` row | enforced; the door is follow-up F4 | -| the anchor binding tier | ADR-0090 D9; D3 | `describeAnchorForbiddenBits`, through `assertAudienceAnchorBindingGate` | enforced | +| the anchor binding tier | ADR-0090 D9; D3 | `describeAnchorForbiddenBits`: for `everyone` through `confirmAudienceBindingSuggestion` and `readAcceptedEveryoneSets`; for `guest`, through the bind door when it is built (no door binds a set to `guest`, the row above) | enforced for `everyone`; `guest`: follow-up F4 | | no delegated binding of an anchor | ADR-0090 D12 | `ANCHOR_POSITIONS` | enforced | | the guest's organization | D4 | the guest entry's organization step, with `resolveSystemWriteOrganization`'s predicate | card E1 | | the form doors' organization | D4, clarification (1) | `anonymousFormIntakeUnavailability`; the engine's write derivation | enforced | diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index d008fbeab95..9640b9d67fb 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -4694,41 +4694,4 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 expect(bindings).toEqual([]); } }); - - // ── The engine-side write gate (`sys_position_permission_set` insert) ── - // - // Same three cases at the door an ADMIN comes through. It must agree with the - // boot binding above and with `confirmAudienceBindingSuggestion`'s early - // rendition — all three now read one source, so a set that binds at boot - // cannot be refused when an admin re-binds it by hand. - const anchorInsert = { - object: 'sys_position_permission_set', - operation: 'insert', - data: { position_id: 'pos_everyone', permission_set_id: 'ps_app' }, - }; - - it('write gate: admits a binding whose token the stack declares', async () => { - const { plugin } = await boot('crm.export_pipeline', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); - await expect((plugin as any).assertAudienceAnchorBindingGate(anchorInsert)).resolves.toBeUndefined(); - }); - - it('write gate: still refuses an undeclared token, with the ADR-0112 envelope', async () => { - const { plugin } = await boot('crm.settle_ledger', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); - const err = await (plugin as any) - .assertAudienceAnchorBindingGate(anchorInsert) - .then(() => null, (e: any) => e); - expect(err).toBeTruthy(); - expect(err.code).toBe('PERMISSION_DENIED'); - expect(err.statusCode).toBe(403); - expect(err.message).toContain('system permissions'); - }); - - it('write gate: still refuses a platform capability of that name', async () => { - const { plugin } = await boot('manage_users', [{ name: 'manage_users', label: 'Not Yours' }]); - const err = await (plugin as any) - .assertAudienceAnchorBindingGate(anchorInsert) - .then(() => null, (e: any) => e); - expect(err?.code).toBe('PERMISSION_DENIED'); - expect(err?.statusCode).toBe(403); - }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 12b7f5f1533..8955f20b599 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4,8 +4,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { declaredHttpStatus } from '@objectstack/types'; import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError, withoutOperationPrivateKeys } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; -import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; -import type { AnchorBindingContext } from '@objectstack/spec/security'; +import { describeHighPrivilegeBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below). @@ -2527,14 +2526,6 @@ export class SecurityPlugin implements Plugin { // short-circuited above, so the curated seeder itself is unaffected. await this.assertCuratedCapabilityNameGate(opCtx); - // [ADR-0090 D5/D9] Audience-anchor binding guard — like the package - // gate above, an unconditional data-layer boundary: a permission set - // carrying high-privilege bits must never be bound to the `everyone` - // or `guest` positions, no matter who asks. (Boot/system writes carry - // `isSystem` and short-circuited above; the dev-mode default binding - // is validated at seed time by the same predicate.) - await this.assertAudienceAnchorBindingGate(opCtx); - // [ADR-0103] Engine-owned write guard. Fail-closed on a user-context // generic write to a `system`/`append-only` object whose resolved // affordances forbid the verb (the engine-owned default). Keyed off @@ -6839,83 +6830,6 @@ export class SecurityPlugin implements Plugin { * System/boot writes never reach here (the middleware short-circuits on * `isSystem`), so system writes are unaffected. */ - /** - * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an - * audience anchor (`everyone` / `guest`). The anchors are implicit for - * whole principal classes, so a dangerous binding here is an instant - * tenant-wide (or anonymous-wide) grant — the one shape the model must - * make unrepresentable rather than merely discouraged. - */ - private async assertAudienceAnchorBindingGate(opCtx: any): Promise { - if (opCtx?.object !== 'sys_position_permission_set') return; - if (!['insert', 'update'].includes(opCtx.operation)) return; - const rows = Array.isArray(opCtx.data) - ? opCtx.data - : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); - if (rows.length === 0) return; - - const ql = this.ql; - // [#18535, ADR-0090 D5] The stack's declared `capabilities:`, read at most - // ONCE per gate pass and only once an anchor row is actually in play — the - // common write on this table names an ordinary position and must not pay - // for a read it does not use. Same source as the boot binding and as - // `confirmAudienceBindingSuggestion`, which is this gate's friendly early - // rendition: a second source there would let a confirm pass its own check - // and then be refused by the insert it performs. - let anchorContext: AnchorBindingContext | undefined; - let anchorContextLoaded = false; - const declaredCapabilityContext = async (): Promise => { - if (!anchorContextLoaded) { - anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); - anchorContextLoaded = true; - } - return anchorContext; - }; - for (const row of rows) { - const positionId = (row as any)?.position_id; - if (!positionId || !ql?.find) continue; - let positionName = ''; - try { - const posRows = await ql.find('sys_position', { where: { id: positionId }, limit: 1, context: { isSystem: true } }); - positionName = String((Array.isArray(posRows) && posRows[0] ? (posRows[0] as any).name : '') ?? ''); - } catch { positionName = ''; } - if (positionName !== 'everyone' && positionName !== 'guest') continue; - - // Resolve the target set definition (bootstrap sets by name, else the - // sys_permission_set row itself carries the authored definition). - const setId = (row as any)?.permission_set_id; - let setName = ''; - let setDef: any = null; - try { - const setRows = await ql.find('sys_permission_set', { where: { id: setId }, limit: 1, context: { isSystem: true } }); - const sr: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null; - if (sr) { - setName = String(sr.name ?? ''); - setDef = sr; - } - } catch { /* fall through to bootstrap lookup below */ } - const boot = this.bootstrapPermissionSets.find((p) => p.name === setName); - // [ADR-0090 D9] Anchor-tier predicate: `guest` faces the strictest tier - // (additionally no edit bit — read-only by default, create is the single - // case-by-case write); `everyone` uses the high-privilege predicate. - // [#18535] `guest` faces the strictest tier and the predicate DROPS the - // context for it (D5's app-token excusal is the `everyone` tier's alone), - // so the same call serves both anchors. - const offending = describeAnchorForbiddenBits( - boot ?? setDef, - positionName as 'everyone' | 'guest', - await declaredCapabilityContext(), - ); - if (offending) { - throw new PermissionDeniedError( - `[Security] Access denied: permission set '${setName || setId}' cannot be bound to the '${positionName}' audience anchor — it carries ${offending} (ADR-0090 D5/D9). ` + - `Audience anchors accept low-privilege sets only; grant powerful sets through ordinary positions instead.`, - { operation: opCtx.operation, object: opCtx.object, position: positionName, permissionSet: setName || setId }, - ); - } - } - } - private async assertPackageManagedWriteGate(opCtx: any): Promise { if (opCtx?.object !== 'sys_permission_set') return; const op = opCtx.operation; From 0f2d82aebdc78811b39b099d947ff204851324a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:14:40 +0000 Subject: [PATCH 2/3] docs(adr): ADR-0103's provenance note says which of its three named objects is still registered, not which objects carry the column sys_email_template and sys_webhook also carry managed_by; the sentence claimed sys_sharing_rule alone did. It now says only that, of the three objects the passage names, sys_sharing_rule is the one still registered. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- .../adr/0103-managedby-write-policy-and-engine-write-guard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md index d2d6aca269a..2848b316c4b 100644 --- a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md +++ b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md @@ -50,8 +50,8 @@ write policy **expressible and enforced** without breaking the writable set. > **Not to be confused with** the row-level `managed_by` *provenance* column > (`'platform' | 'package' | 'admin'`) on `sys_sharing_rule` / `sys_position` / > `sys_capability` (ADR-0066). That is a per-row provenance axis; this ADR is about -> the per-object schema bucket. The shared word is a coincidence. On `main` today -> only `sys_sharing_rule` carries that column on a registered object, and its admin +> the per-object schema bucket. The shared word is a coincidence. Of the three +> objects named here, only `sys_sharing_rule` is still registered on `main`, and its admin > edits are stamped `customized` by plugin-sharing's provenance hook > (`packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts`), not refused. > `SecurityPlugin.assertSystemRowWriteGate` guarded `sys_position` and From 64c2e410492c2d3037c4eabf8cf901ab71352e64 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:51:02 +0000 Subject: [PATCH 3/3] docs(objectql,plugin-security,lint): comments no longer describe the deleted audience-anchor binding gate as live The security-plugin and engine preview docblocks listed it among the pre-resolution gates; the engine's and the dangling-reference audit's comments (and their two tests' comments) said it has to resolve a bound permission set; the lint rule said the runtime gate holds a guest binding; the authz conformance matrix's position-active note named it among the unfiltered write gates. Each now says the gate retired with sys_position_permission_set in C3 stage 8, or drops it. Comments and one note string only. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- packages/lint/src/validate-security-posture.ts | 5 +++-- .../engine-lookup-referential-integrity.test.ts | 5 +++-- packages/objectql/src/engine.ts | 17 ++++++++++------- .../integrity/dangling-reference-audit.test.ts | 3 +-- .../src/integrity/dangling-reference-audit.ts | 6 +++--- .../plugin-security/src/security-plugin.ts | 7 ++++--- .../qa/dogfood/test/authz-conformance.matrix.ts | 2 +- 7 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 67a21b8d54e..95a4321e5f4 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -33,8 +33,9 @@ * an operator binds to `guest` at install time is a decision ADR-0090 D9 * ("a package may suggest bindings … the admin confirms each individually") * puts past authoring, so this rule is not — and cannot be — coverage for an - * app-authored anchor set bound to `guest` (#16110). That binding is held by - * the runtime's own `describeAnchorForbiddenBits(set, 'guest')` gate instead. + * app-authored anchor set bound to `guest` (#16110). No runtime door binds a + * set to `guest` today; ADR-0138 D3 item 5's bind door runs + * `describeAnchorForbiddenBits(set, 'guest')` when it is built. * * `security-cbp-ambiguous-relation` used to be the one exception: the runtime * did not refuse two candidate masters, it silently PICKED one by field diff --git a/packages/objectql/src/engine-lookup-referential-integrity.test.ts b/packages/objectql/src/engine-lookup-referential-integrity.test.ts index 044911f0561..c51624bf8ce 100644 --- a/packages/objectql/src/engine-lookup-referential-integrity.test.ts +++ b/packages/objectql/src/engine-lookup-referential-integrity.test.ts @@ -16,8 +16,9 @@ * {"title":"…","project":"proj_does_not_exist","status":"backlog"} → 200 * * On the RBAC link tables a dangling row is a security-surface record that - * resolves to nothing: the audience-anchor gate has to resolve that permission - * set to evaluate the grant, so the binding is an unevaluable gate input. + * resolves to nothing: a grant naming a permission set nothing serves. (The + * audience-anchor gate that once had to resolve such a set retired with + * `sys_position_permission_set`, C3 stage 8.) */ import { describe, it, expect, beforeEach } from 'vitest'; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a8fde20ca13..188f542b3da 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -5514,9 +5514,10 @@ export class ObjectQL implements IObjectQLEngine { * - **The remaining PRE-RESOLUTION gates, which run beside the two named * above.** Two judge a row's PROVENANCE, which the preview holds no row to * carry: the ADR-0086/0094 package-managed write gate and the ADR-0066 - * system-row write gate. Two judge a payload VALUE: the ADR-0066 D1 - * curated-capability-name refusal and the ADR-0090 D5/D9 audience-anchor - * binding guard. And the ADR-0056 `publicFormGrant` scope, which admits + * system-row write gate. One judges a payload VALUE: the ADR-0066 D1 + * curated-capability-name refusal (the ADR-0090 D5/D9 audience-anchor + * binding guard retired with `sys_position_permission_set`, C3 stage 8). + * And the ADR-0056 `publicFormGrant` scope, which admits * create plus read-back on exactly the granted object and refuses * everything else — not asked because no wire caller and no constructor * in the tree presents that grant here (only the public form-submit route @@ -8856,9 +8857,10 @@ export class ObjectQL implements IObjectQLEngine { * * On the RBAC link tables that is a security-surface record that resolves to * nothing: an administrator auditing permissions sees a binding whose target - * cannot be inspected, and the audience-anchor gate has to resolve that very - * set to evaluate the grant — so a dangling row is an unevaluable gate input, - * not merely an untidy one. + * cannot be inspected, and the audience-anchor gate had to resolve that very + * set to evaluate the grant — so a dangling row was an unevaluable gate input, + * not merely an untidy one. (That gate and `sys_position_permission_set` are + * retired since C3 stage 8; the check stands for the link tables that remain.) * * ## Scope, deliberately narrow * @@ -20839,7 +20841,8 @@ export class ObjectRepository implements IScopedObjectRepository { * middleware (`:1616`, `canExport` at `:4573`); the ADR-0103 engine-owned/ * append-only write guard (`system-write-guard.ts:96,120`, called at * `security-plugin.ts:1736`); the package-managed / system-row / - * curated-capability-name / audience-anchor write gates + * curated-capability-name write gates (and, until C3 stage 8, the + * audience-anchor gate) * (`security-plugin.ts:1690-1724`); the referential-integrity check * (`:5892` in this file) and the tenant-audit mute (`:3773`) — in addition * to the static `readonly`/runtime-owned strip on BOTH update paths diff --git a/packages/objectql/src/integrity/dangling-reference-audit.test.ts b/packages/objectql/src/integrity/dangling-reference-audit.test.ts index 356e409bfaa..6736294caae 100644 --- a/packages/objectql/src/integrity/dangling-reference-audit.test.ts +++ b/packages/objectql/src/integrity/dangling-reference-audit.test.ts @@ -122,8 +122,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () it('finds a dangling reference and reports its FULL location', async () => { // The residual #4441 left: a system write put a permission-set id here that // names no row. On an RBAC link table that is a security-surface record - // resolving to nothing — the audience-anchor gate has to resolve exactly - // that set to evaluate the grant. + // resolving to nothing: a grant naming a permission set nothing serves. const port = makePort({ objects: [binding], rows: { diff --git a/packages/objectql/src/integrity/dangling-reference-audit.ts b/packages/objectql/src/integrity/dangling-reference-audit.ts index deab1c943dc..008c8a92016 100644 --- a/packages/objectql/src/integrity/dangling-reference-audit.ts +++ b/packages/objectql/src/integrity/dangling-reference-audit.ts @@ -371,9 +371,9 @@ export const DEFAULT_MAX_ROWS = 5_000; /** * Objects scanned FIRST when the budget is finite (#4551): the ADR-0090 * permission model's own tables. A dangling row there is not untidy, it is a - * **security-surface record that resolves to nothing** — and the - * audience-anchor gate has to resolve exactly that permission set to evaluate - * the grant, so the binding is an unevaluable gate input. + * **security-surface record that resolves to nothing**: a grant that names a + * permission set nothing serves. (The audience-anchor gate that once had to + * resolve such a set retired with `sys_position_permission_set`, C3 stage 8.) * * Derived from `PLATFORM_OBJECTS_BY_PACKAGE` rather than hand-listed, so a * table added to plugin-security is prioritised without anyone remembering to diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 795552a1069..1ba8865fd8b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -6146,9 +6146,10 @@ export class SecurityPlugin implements Plugin { * - **The remaining PRE-RESOLUTION gates, which run beside arms 2 and 3 and * are not asked here.** Two judge a row's PROVENANCE, which a preview * holds no row to carry: the ADR-0086/0094 package-managed write gate and - * the ADR-0066 system-row write gate. Two judge a payload VALUE: the - * ADR-0066 D1 curated-capability-name refusal and the ADR-0090 D5/D9 - * audience-anchor binding guard. And the ADR-0056 `publicFormGrant` scope, + * the ADR-0066 system-row write gate. One judges a payload VALUE: the + * ADR-0066 D1 curated-capability-name refusal. (The ADR-0090 D5/D9 + * audience-anchor binding guard is gone: it judged `sys_position_permission_set` + * writes, an object C3 stage 8 retired.) And the ADR-0056 `publicFormGrant` scope, * which admits create plus read-back on exactly the granted object and * refuses everything else pre-resolution — not asked because no wire * caller and no constructor in the tree presents the grant here (it is diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 85d0aa4b16a..542f46c6157 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -575,7 +575,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ note: 'Unit-proven; an e2e dogfood proof is a follow-on, the same disposition as the ADR-0105 block above and for the same reason — the flag is a predicate inside the grant resolver, not an HTTP surface, so there is no route for a dogfood boot to drive at it directly. core/security/resolve-authz-context.test.ts "the `active` flag on the grant catalogues (ADR-0049)" covers the derivation half, including THE HIGH-BLAST-RADIUS CASE (a deactivated admin_full_access confers no PLATFORM_ADMIN) and that deactivating ONE set leaves the others granting; plugin-security/permission-set-active.test.ts covers the loader half, including THE REACHABILITY CASE (a position name reaching a deactivated set of the same name) plus its non-vacuous twin (the same request with the set ACTIVE does resolve); core/security/row-active.test.ts pins the predicate itself (absent grants, junk does not revoke, 0/1 deactivates). Deliberately NOT in HIGH_RISK: that list marks primitives guarding object data through a sibling HTTP entry point, and this one guards grant DERIVATION. The honest upgrade path is a real proof (seed a deactivated set, drive REST as its holder, observe the refusal), not re-citing a neighbouring file.' }, { id: 'position-active', summary: '`sys_position.active` — a deactivated position grants nothing, and its name cannot resolve the grant one layer down (ADR-0049)', state: 'enforced', enforcement: 'core/security/resolve-authz-context.ts step 6a — isRowActive gates BOTH halves, and only both hold it: (i) only ACTIVE position ids collect their `sys_position_permission_set` linkage, so a deactivated position carries no bound set; (ii) the deactivated NAME is dropped from `grants.positions`, because resolvePermissionSetsForContext requests positions as permission-set NAMES and a name left standing resolves the same grant one layer down', - note: 'Only a name whose `sys_position` row is EXPLICITLY deactivated is dropped — a name with no row at all (`org_owner`, a membership-derived role, the built-in `everyone` audience anchor) has no flag to read and is untouched. Deliberately NOT a blanket revocation of the sets themselves: a set held via BOTH a deactivated position AND a direct user grant still resolves, since the direct grant is a different grant (resolve-authz-context.test.ts pins exactly that case). Symmetrically, the WRITE gates and blast-radius reads in plugin-security (assertAudienceAnchorBindingGate, setsBoundToPosition, the delegated-admin surfaces) stay UNFILTERED on purpose — dropping a deactivated row there would make a refused binding permitted, narrow a delegate\'s boundary, and make a deactivated position unmanageable. Unit-proven in core/security/resolve-authz-context.test.ts (a deactivated position stops granting its sets; an active one still grants; an absent column grants; the 0/1 shape deactivates; deactivating ONE position leaves the others granting) + core/security/row-active.test.ts. Not HIGH_RISK for the same reason as `permission-set-active`.' }, + note: 'Only a name whose `sys_position` row is EXPLICITLY deactivated is dropped — a name with no row at all (`org_owner`, a membership-derived role, the built-in `everyone` audience anchor) has no flag to read and is untouched. Deliberately NOT a blanket revocation of the sets themselves: a set held via BOTH a deactivated position AND a direct user grant still resolves, since the direct grant is a different grant (resolve-authz-context.test.ts pins exactly that case). Symmetrically, the WRITE gates and blast-radius reads in plugin-security (setsBoundToPosition, the delegated-admin surfaces; the audience-anchor binding gate retired with sys_position_permission_set in C3 stage 8) stay UNFILTERED on purpose — dropping a deactivated row there would make a refused binding permitted, narrow a delegate\'s boundary, and make a deactivated position unmanageable. Unit-proven in core/security/resolve-authz-context.test.ts (a deactivated position stops granting its sets; an active one still grants; an absent column grants; the 0/1 shape deactivates; deactivating ONE position leaves the others granting) + core/security/row-active.test.ts. Not HIGH_RISK for the same reason as `permission-set-active`.' }, // ── ADR-0091 D1/D2 — grant validity windows (commit d6e793507) ──────── //