Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/23403-audience-anchor-binding-gate-retired.md
Original file line number Diff line number Diff line change
@@ -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`.
15 changes: 9 additions & 6 deletions docs/adr/0094-sys-permission-set-pure-projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).

---

Expand Down
11 changes: 8 additions & 3 deletions docs/adr/0103-managedby-write-policy-and-engine-write-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0126-packaged-metadata-customization-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down Expand Up @@ -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
Expand All @@ -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)).
Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 3 additions & 2 deletions packages/lint/src/validate-security-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 10 additions & 7 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions packages/objectql/src/integrity/dangling-reference-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 0 additions & 37 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading