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..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 @@ -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. 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 +> `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/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.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 b042c660f9c..1ba8865fd8b 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). @@ -2519,14 +2518,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 @@ -6155,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 @@ -6811,83 +6803,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; 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) ──────── //