From f99176a2fb319576cac60108afddf3335b7737c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:38:49 +0000 Subject: [PATCH 1/4] wip(spec): retire AdminScope.manageBindings and authorEnvironmentSets (#23488) Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .../permissions/delegated-administration.mdx | 4 +- content/docs/permissions/permission-sets.mdx | 8 +- docs/design/permission-model.md | 20 +-- .../src/docs/showcase_tour_security.md | 10 +- .../src/security/permission-sets.ts | 27 ++-- .../src/meta-write-capability.ts | 9 +- .../src/can-write-object-admission.test.ts | 2 - .../src/delegated-admin-gate-catalog.test.ts | 2 - ...ated-admin-gate-cross-organization.test.ts | 2 - .../src/delegated-admin-gate.test.ts | 55 ++++++- .../src/delegated-admin-gate.ts | 38 +++-- .../src/granted-by-writer-provenance.test.ts | 2 - .../objects/reserved-identity-names.test.ts | 2 - .../objects/sys-user-permission-set.object.ts | 4 +- .../showcase-permission-zoo.dogfood.test.ts | 6 +- packages/spec/liveness/permission.json | 6 +- .../spec/src/contracts/security-service.ts | 7 +- packages/spec/src/conversions/registry.ts | 136 ++++++++++++++++++ ...rity__AdminScope__authorEnvironmentSets.ts | 15 ++ ...18.security__AdminScope__manageBindings.ts | 19 +++ ...n-admin-scope-binding-authoring-retired.ts | 45 ++++++ .../spec/src/migrations/registry.ts.template | 15 ++ packages/spec/src/security/permission.form.ts | 8 +- packages/spec/src/security/permission.test.ts | 7 +- packages/spec/src/security/permission.zod.ts | 83 +++++++++-- .../retired-key-removal-version.pin.test.ts | 5 + 26 files changed, 440 insertions(+), 97 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__authorEnvironmentSets.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__manageBindings.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.permission-admin-scope-binding-authoring-retired.ts diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index c0a18a67ddb..75da60923be 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -56,8 +56,8 @@ set, and the gate resolves the name in the security catalog. The gate's rules: | Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ | | Subtree anchoring | Assignments a delegate creates must be anchored (`sys_user_position.business_unit_id`) inside their subtree, and the target user must sit inside it | | Allowlist, no self-escalation | Every set reached by the write — bound to the assigned position, or granted directly — must be on `assignablePermissionSets`. Granting an un-allowlisted set is refused **for anyone, including the delegate themselves** | -| Action flags | `manageAssignments` gates the assignment rows — `sys_user_position`, and direct `sys_user_permission_set` grants. `manageBindings` and `authorEnvironmentSets` gated writes to the binding rows and set rows of the retired `sys_position_permission_set` and `sys_permission_set` objects; neither object is registered any more, so neither flag admits a write. A position's sets are part of its definition and a permission set is a definition: both are written through the metadata door, which admits a `manage_metadata` holder (the platform operator under `group` or `isolated`) and reads no `adminScope` (ADR-0131 D3). The two flags still count in the strict-containment comparison below | -| Strict containment | Granting or authoring a set that itself carries an `adminScope` requires a held scope that **strictly contains** it — handing your own exact scope to a peer is refused (no lateral propagation) | +| Action flag | `manageAssignments` gates the assignment rows — `sys_user_position`, and direct `sys_user_permission_set` grants — and is the scope's only action right. A position's sets are part of its definition and a permission set is a definition: both are written through the metadata door, which admits a `manage_metadata` holder (the platform operator under `group` or `isolated`) and reads no `adminScope` (ADR-0131 D3). The scope's former `manageBindings` and `authorEnvironmentSets` named the retired `sys_position_permission_set` and `sys_permission_set` rows, admitted no write once those objects were gone, and are retired: the schema refuses both with the fix | +| Strict containment | Granting a set that itself carries an `adminScope` — assigning a position that distributes it, or granting it directly — requires a held scope that **strictly contains** it: the same root, no wider subtree, `manageAssignments` only if yours has it, an allowlist inside yours, and smaller on at least one of those. Handing your own exact scope to a peer is refused (no lateral propagation) | | Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked | | Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically | | Tenant-level assets stay tenant-level | The `everyone`/`guest` audience anchors and security-domain publishes are untouchable from any delegated scope | diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index d2095bf90a6..4f9f8ab23e7 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -336,9 +336,11 @@ export const EastSubsidiaryAdmin = definePermissionSet({ The runtime gate enforces: assignments anchored inside the subtree only, allowlisted sets only (**to others and to themselves** — no self-escalation), single-row writes, `granted_by` audit stamping, and **strict containment** -when granting or authoring a set that itself carries an `adminScope` (handing -out your own exact scope is refused). Anchors and security publishes stay -tenant-level. +when granting a set that itself carries an `adminScope` (handing out your own +exact scope is refused). A delegate's scope is placement, assignment and the +allowlist: writing a set's body or a position's sets is a metadata write, judged +by `manage_metadata`, never by an `adminScope`. Anchors and security publishes +stay tenant-level. ## Provenance — package vs environment sets (ADR-0086) diff --git a/docs/design/permission-model.md b/docs/design/permission-model.md index aeb4cc0d038..c8be128b378 100644 --- a/docs/design/permission-model.md +++ b/docs/design/permission-model.md @@ -294,7 +294,7 @@ itself is a scoped grant*. Example: the 华东 subsidiary's IT admin receives an that says: - *where*: the 华东 business-unit subtree only; -- *what*: assign users to positions, bind sets to positions — inside that subtree; +- *what*: assign users to positions, and grant allowlisted sets directly — inside that subtree; - *which*: an **allowlist** of sets they may hand out (say, the CRM and HR self-service sets). They can onboard their own staff and reshuffle their own positions, but they can never grant @@ -303,19 +303,21 @@ things (the `everyone`/`guest` anchors, security publishes). Headquarters keeps the same explain engine answers "who *could have* granted this", not just "who did". **How it is authored (landed in P3).** An admin scope is a field on an ordinary permission set -(`adminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, -authorEnvironmentSets, assignablePermissionSets[] }`), so it is distributed via positions and -audited like every other grant. The same set should also carry plain CRUD on the RBAC link -tables (`sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`) — the -scope authorizes *what* may be administered, the CRUD bits let the requests through at all. +(`adminScope: { businessUnit, includeSubtree, manageAssignments, assignablePermissionSets[] }`), +so it is distributed via positions and audited like every other grant. The same set should also +carry plain CRUD on the assignment tables (`sys_user_position`, `sys_user_permission_set`) — the +scope authorizes *what* may be administered, the CRUD bits let the requests through at all. A +position's sets and a set's body are environment definitions written through the metadata door by +a `manage_metadata` holder (ADR-0131 D3), so binding and set authoring are not delegable: the +scope's former `manageBindings` and `authorEnvironmentSets` retired with the binding and set rows +they gated. Runtime rules enforced by the `DelegatedAdminGate` (plugin-security): - assignments a delegate creates must be **anchored** (`sys_user_position.business_unit_id`) inside their subtree, and are `granted_by`-stamped automatically; - every set reached by the write — bound to the assigned position, or granted directly — must be - in the allowlist; re-composing a position (bindings) requires every current holder to sit - inside the subtree; -- granting or authoring a set that itself carries an `adminScope` requires a held scope that + in the allowlist; +- granting a set that itself carries an `adminScope` requires a held scope that **strictly contains** it (handing your own exact scope to a peer is refused — no lateral propagation); - delegates write **single rows by id** only (a broad filter-write cannot be boundary-checked); diff --git a/examples/app-showcase/src/docs/showcase_tour_security.md b/examples/app-showcase/src/docs/showcase_tour_security.md index 48d6d16568b..d2436ba8a66 100644 --- a/examples/app-showcase/src/docs/showcase_tour_security.md +++ b/examples/app-showcase/src/docs/showcase_tour_security.md @@ -38,8 +38,9 @@ The other sets each demonstrate one axis: - `showcase_executive` — org-wide read via `readScope: 'org'` (depth, not a bypass). - `showcase_auditor` — **View-All** (`viewAllRecords`): reads every private - record, holds no write bit. High-privilege: the publish linter blocks it - from `everyone`/`guest` bindings. + record, holds no write bit. High-privilege: suggested for the `everyone` + anchor (`isDefault: true`) it would fail the publish linter, and no door + binds a set to `everyone` or `guest` by hand. - `showcase_ops` — **system permissions** (`setup.access` opens the Setup app for non-admins — apps declaring `requiredPermissions` appear in `/me/apps` only when the caller's union carries them) and **Modify-All** @@ -48,8 +49,9 @@ The other sets each demonstrate one axis: suggestion** (ADR-0090 D5): the read-mostly baseline every authenticated member holds *additively* (no fallback cliff). - `showcase_guest_portal` — guest-safe capability (read announcements, - create inquiries) for the built-in **`guest`** position (ADR-0090 D9); - binding it is an admin action in Setup. + create inquiries) shaped for the built-in **`guest`** position (ADR-0090 + D9). No door binds a set to `guest` today (ADR-0138 D3), so the showcase + distributes it through the `client_portal_user` position instead. - `showcase_field_ops_delegate` — **delegated administration** (ADR-0090 D12): an `adminScope` bounded to the Field Operations subtree with an assignable-set allowlist; no self-escalation. diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 8ed7733a60e..5bbdab570e4 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -24,7 +24,8 @@ * DEPTH vs VAMA, in one line: depth widens a grant along the org geometry * (BU tree + manager chain) and still flows through sharing/RLS; VAMA * bypasses record-level checks outright and is therefore high-privilege - * (lint-blocked on `everyone`/`guest` bindings). + * (the publish lint refuses it on a set suggested for the `everyone` anchor, + * `isDefault: true`; no door binds a set to `guest`). */ import { definePermissionSet } from '@objectstack/spec/security'; @@ -163,8 +164,9 @@ export const ExecutivePermissionSet = definePermissionSet({ /** * Compliance read-only: `viewAllRecords` bypasses OWD/sharing/depth on the * named objects — the auditor sees every private note, inquiry, and invoice, - * but holds no write bit anywhere. High-privilege by definition: the D7 lint - * blocks binding this set to the `everyone`/`guest` anchors. + * but holds no write bit anywhere. High-privilege by definition: suggested + * for the `everyone` anchor (`isDefault: true`) it would fail the publish + * lint's anchor rule, and no door binds a set to `everyone` or `guest` by hand. */ export const AuditorPermissionSet = definePermissionSet({ name: 'showcase_auditor', @@ -354,12 +356,14 @@ export const MemberDefaultPermissionSet = definePermissionSet({ /** * Capability shaped for the built-in `guest` position: anonymous visitors * may read announcements and file an inquiry (public form intake) — nothing - * else. Guest bindings face the STRICTEST lint tier: named objects only (no - * wildcard), read-only by default (create case-by-case), never `allowEdit`, - * never VAMA/system permissions. + * else. It is shaped to pass the anchor predicate's `guest` tier, the + * strictest: named objects only (no wildcard), read-only by default (create + * case-by-case), never `allowEdit`, never VAMA/system permissions. * - * Binding is an ADMIN action (Setup → Access Control → bind `guest` → - * `showcase_guest_portal`); the app only ships the capability. + * No door runs that tier yet, because no door binds a set to `guest`: a save + * of the packaged `guest` position answers 403 `NOT_OVERRIDABLE` (ADR-0138 D3 + * item 5), and the tier check belongs to the bind door when it is built (item + * 4). Here the set reaches people through the `client_portal_user` position. */ export const GuestPortalPermissionSet = definePermissionSet({ name: 'showcase_guest_portal', @@ -381,8 +385,9 @@ export const GuestPortalPermissionSet = definePermissionSet({ * The `adminScope` authorizes WHAT may be administered; the plain CRUD bits * on the RBAC link tables below let the requests through at all (both are * required — holders of table CRUD with NO scope are refused). - * `manageBindings` stays false: this delegate re-staffs positions, they do - * not re-compose what a position means. + * This delegate re-staffs positions; what a position distributes is its + * definition, written through the metadata door by a `manage_metadata` + * holder (ADR-0131 D3), which no `adminScope` reaches. */ export const FieldOpsDelegatePermissionSet = definePermissionSet({ name: 'showcase_field_ops_delegate', @@ -397,8 +402,6 @@ export const FieldOpsDelegatePermissionSet = definePermissionSet({ businessUnit: 'Field Operations', includeSubtree: true, manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, assignablePermissionSets: ['showcase_contributor', 'showcase_manager'], }, }); diff --git a/packages/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts index c79fc9c86d9..004176b1016 100644 --- a/packages/metadata-core/src/meta-write-capability.ts +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -181,9 +181,12 @@ export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN'; * `/meta` item write doors on both transports (save, reset, publish, * rollback), `POST /meta/_migrate-stored` on both transports, the `/packages` * write doors, the `/automation` definition writes, - * `install-local` (install, upgrade, uninstall), the permission-set clone - * door and the security plugin's data door on the catalog objects. Under - * `single` it is inert: the organization is the environment, and the + * `install-local` (install, upgrade, uninstall) and the permission-set clone + * door. (The security plugin's data door on the catalog objects asked it too, + * until those objects retired — ADR-0131 D3, C3 stage 8: no `sys_position`, + * `sys_permission_set` or `sys_capability` write reaches the engine, so a + * position, set or capability is written through the `/meta` doors above.) + * Under `single` it is inert: the organization is the environment, and the * capability gate decides alone. * * Admitted under a wall: `isSystem` (the engine's own writes: seeders, diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index 376769615a6..464dee36d34 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -253,8 +253,6 @@ const EAST_SCOPE = { businessUnit: 'east', includeSubtree: true, manageAssignments: true, - manageBindings: true, - authorEnvironmentSets: true, assignablePermissionSets: ['sales_user', 'sub_admin'], }; const DELEGATE_SET: PermissionSet = { diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts index 0e0092aee66..85ac761b28f 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts @@ -30,8 +30,6 @@ const EAST_SCOPE = { businessUnit: 'east', includeSubtree: true, manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, assignablePermissionSets: ['sales_user'], }; diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-cross-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-cross-organization.test.ts index 9f4837eed2a..1d9ba299921 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-cross-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-cross-organization.test.ts @@ -73,8 +73,6 @@ const SALES_SCOPE = { businessUnit: 'sales', includeSubtree: true, manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, assignablePermissionSets: ['sales_user'], }; diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index ecba9982a9e..47eafc885c9 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -24,8 +24,6 @@ const EAST_SCOPE = { businessUnit: 'east', includeSubtree: true, manageAssignments: true, - manageBindings: true, - authorEnvironmentSets: true, assignablePermissionSets: ['sales_user', 'sub_admin'], }; @@ -317,6 +315,55 @@ describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => })).rejects.toThrow(/not strictly contained/); }); + // [#23488] `manageBindings` / `authorEnvironmentSets` are retired: no door + // honoured either, so the containment comparison reads neither. Both scopes + // below are what a stored row the D2 conversion has not yet rewritten could + // hand the gate — the keys are written on purpose, as raw data. + describe('[#23488] the retired binding/authoring flags count on no containment axis', () => { + const RETIRED_FLAGS = { manageBindings: true, authorEnvironmentSets: true } as const; + /** A delegate whose held scope is EAST_SCOPE plus an inert `true` on both retired flags. */ + const gateFor = (adminScope: Record) => new DelegatedAdminGate({ + ql: h.ql, + resolveSets: async () => [{ name: 'east_admin', objects: {}, adminScope }] as any[], + }); + const grantSubAdmin = (gate: DelegatedAdminGate) => gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: 'u_east_1', permission_set: 'sub_admin' }, + context: h.ctxOf('east_delegate', 'u_east_delegate'), + }); + + it('an inert `true` on the delegate\'s side no longer makes an otherwise EQUAL scope strictly contained — 403 PERMISSION_DENIED', async () => { + // Before the retirement the delegate's `true` against sub_admin's + // absent flag counted as the strictly-larger axis, so the equal-scope + // refusal let sub_admin's identical scope through. + const refusal = await grantSubAdmin(gateFor({ ...EAST_SCOPE, ...RETIRED_FLAGS })).then( + () => undefined, + (e: unknown) => e as { code?: string; status?: number; message?: string }, + ); + expect(refusal, 'the grant must be refused').toBeDefined(); + expect(refusal?.code).toBe('PERMISSION_DENIED'); + expect(refusal?.status).toBe(403); + expect(refusal?.message).toMatch(/would hand out an adminScope not strictly contained by yours/); + }); + + it('CONTROL: the same delegate grants a scope narrower on a REAL axis (the subtree)', async () => { + // sub_admin's scope stops covering descendants: strictly contained on + // the tree axis, so the same delegate, retired flags and all, may grant it. + const row = h.tables.sys_permission_set.find((r: any) => r.name === 'sub_admin'); + row.admin_scope = JSON.stringify({ ...EAST_SCOPE, includeSubtree: false }); + await expect(grantSubAdmin(gateFor({ ...EAST_SCOPE, ...RETIRED_FLAGS }))).resolves.toBeUndefined(); + }); + + it('the granted side\'s retired `true` no longer out-reaches a delegate that lacks it', async () => { + // Before the retirement sub_admin's `manageBindings: true` against the + // delegate's absent flag failed the rights axis. Narrower on the tree + // axis and otherwise equal, it is strictly contained now. + const row = h.tables.sys_permission_set.find((r: any) => r.name === 'sub_admin'); + row.admin_scope = JSON.stringify({ ...EAST_SCOPE, includeSubtree: false, ...RETIRED_FLAGS }); + await expect(grantSubAdmin(gateFor({ ...EAST_SCOPE }))).resolves.toBeUndefined(); + }); + }); + // [ADR-0131 D4] A stored grant's pre-image is judged by the set it NAMES // (`permission_set`), read from the catalog by that name; an insert, or an // update that re-points the id, is judged by the id it writes, as before. @@ -749,8 +796,6 @@ describe('DelegatedAdminGate — the anchor is picked by organization, not by ar businessUnit: 'sales', includeSubtree: true, manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, assignablePermissionSets: ['sales_user'], }; const DELEGATE_SETS = [ @@ -861,8 +906,6 @@ describe('DelegatedAdminGate — a position is the catalog definition, not an or businessUnit: 'sales', includeSubtree: true, manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, assignablePermissionSets: ['sales_user'], }; const DELEGATE_SETS = [{ name: 'sales_admin', objects: {}, adminScope: SALES_SCOPE }] as any[]; diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 141920cea03..0ee08f5bdf8 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -210,8 +210,6 @@ export interface DelegableScopeReport { businessUnit: string; includeSubtree: boolean; manageAssignments: boolean; - manageBindings: boolean; - authorEnvironmentSets: boolean; assignablePermissionSets: string[]; businessUnitIds: string[]; }>; @@ -408,8 +406,8 @@ export class DelegatedAdminGate { * the explain API: an administrator who can already manage a user's * assignments inside their delegation boundary may also read WHY that * user's access resolves the way it does — without tenant-level - * `manage_users`. Any scope kind qualifies (assignments, bindings, - * authoring): all of them administer capability inside the subtree, and + * `manage_users`. Any held scope qualifies, whether or not it grants + * `manageAssignments`: it administers capability inside the subtree, and * the report is read-only. Fail-closed: unresolvable scopes/memberships * cover nothing. * @@ -516,14 +514,12 @@ export class DelegatedAdminGate { businessUnit: h.scope.businessUnit, includeSubtree: h.scope.includeSubtree, manageAssignments: h.scope.manageAssignments, - manageBindings: h.scope.manageBindings, - authorEnvironmentSets: h.scope.authorEnvironmentSets, assignablePermissionSets: [...h.scope.assignablePermissionSets], businessUnitIds: [...h.subtree], })); - // Placement needs `manageAssignments` — a bindings-only or authoring-only - // scope administers capability without placing people. + // Placement needs `manageAssignments` — a scope without it is an + // allowlist that places nobody. const placing = held.filter((h) => h.scope.manageAssignments); const placeableBusinessUnitIds = [...new Set(placing.flatMap((h) => [...h.subtree]))]; @@ -873,15 +869,22 @@ export class DelegatedAdminGate { /** Definition-level strict containment (no tree resolution): same-root-or- * narrower BU (equal root only when outer includes subtree and inner is the - * same or a descendant — without the tree we accept equal root + subtree, - * or require the authoring path's resolved check), rights ⊇, allowlist ⊇, - * and NOT identical. Conservative: unknown ⇒ not contained. */ + * same or a descendant — without the tree we accept equal root + subtree), + * rights ⊇, allowlist ⊇, and NOT identical. Conservative: unknown ⇒ not + * contained. + * + * [#23488] The rights axis is `manageAssignments` alone. The retired + * `manageBindings` / `authorEnvironmentSets` granted nothing (no door + * honoured either), so they are not read on EITHER side: a stored scope + * that still carries one — a row the D2 conversion has not yet rewritten — + * neither out-reaches a delegate nor counts as the axis that makes an + * otherwise equal scope "strictly smaller". The second was a live + * loosening: an inert `true` on the delegate's side let it hand out a + * scope equal to its own through the equal-scope refusal. */ private definitionContainsStrictly(outer: HeldScope['scope'], inner: AdminScope): boolean { const innerFull = { includeSubtree: inner.includeSubtree !== false, manageAssignments: inner.manageAssignments === true, - manageBindings: inner.manageBindings === true, - authorEnvironmentSets: inner.authorEnvironmentSets === true, assignable: (inner.assignablePermissionSets ?? []) as string[], }; // BU axis (definition level): identical root, and inner must not cover @@ -890,17 +893,12 @@ export class DelegatedAdminGate { if (innerFull.includeSubtree && outer.includeSubtree === false) return false; // Rights axis: inner ⊆ outer. if (innerFull.manageAssignments && !outer.manageAssignments) return false; - if (innerFull.manageBindings && !outer.manageBindings) return false; - if (innerFull.authorEnvironmentSets && !outer.authorEnvironmentSets) return false; // Allowlist axis: inner ⊆ outer. for (const name of innerFull.assignable) { if (!outer.assignablePermissionSets.includes(name)) return false; } // Strictness: some axis must be strictly smaller. - const equalRights = - innerFull.manageAssignments === (outer.manageAssignments === true) && - innerFull.manageBindings === (outer.manageBindings === true) && - innerFull.authorEnvironmentSets === (outer.authorEnvironmentSets === true); + const equalRights = innerFull.manageAssignments === (outer.manageAssignments === true); const equalTree = innerFull.includeSubtree === (outer.includeSubtree !== false); const equalAllow = innerFull.assignable.length === outer.assignablePermissionSets.length && @@ -922,8 +920,6 @@ export class DelegatedAdminGate { businessUnit: raw.businessUnit, includeSubtree: raw.includeSubtree !== false, manageAssignments: raw.manageAssignments === true, - manageBindings: raw.manageBindings === true, - authorEnvironmentSets: raw.authorEnvironmentSets === true, assignablePermissionSets: Array.isArray(raw.assignablePermissionSets) ? raw.assignablePermissionSets.filter((n: unknown): n is string => typeof n === 'string') : [], diff --git a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts index 2fca4a1fc0c..fb4faaa6c8c 100644 --- a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts +++ b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts @@ -86,8 +86,6 @@ const DELEGATE = PermissionSetSchema.parse({ businessUnit: 'east', includeSubtree: true, manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, assignablePermissionSets: ['qa_plain'], }, }); diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts index a486114be2b..14f576274a2 100644 --- a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts @@ -213,8 +213,6 @@ describe('#15972 the service write door funnels into the same single refusal', ( businessUnit: 'east', includeSubtree: true, manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, assignablePermissionSets: ['sales_user'], }; diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index d0b541fbc4f..4405da69e60 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -31,8 +31,8 @@ export const SysUserPermissionSet = ObjectSchema.create({ icon: 'user-check', isSystem: true, // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: - // delegated `manageBindings` direct grants write this under the caller's - // context. The bucket default is full CRUD, so no `userActions` block is + // delegated direct grants (a scope's `manageAssignments`) write this under + // the caller's context. The bucket default is full CRUD, so no `userActions` block is // needed — the DelegatedAdminGate is the authz. managedBy: 'system-data', description: 'Direct assignment of a permission set to a user (optionally scoped to an organization).', diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index 33910b48b70..09b305c5da3 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -95,7 +95,11 @@ describe('showcase: ADR-0090 permission-model zoo', () => { const scope = def.adminScope ?? null; expect(scope?.businessUnit).toBe('Field Operations'); expect(scope?.manageAssignments).toBe(true); - expect(scope?.manageBindings).toBe(false); + // [#23488] The retired binding/authoring rights are absent from the + // served definition — the showcase no longer writes them and nothing + // materializes a default for a tombstone. + expect(scope).not.toHaveProperty('manageBindings'); + expect(scope).not.toHaveProperty('authorEnvironmentSets'); expect(scope?.assignablePermissionSets).toContain('showcase_contributor'); }); diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 4c19eebd221..13ce1b0b019 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -35,9 +35,9 @@ }, "adminScope": { "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/delegated-admin-gate.ts — non-tenant-admin writes to sys_user_position / sys_position_permission_set / sys_user_permission_set / sys_permission_set require a covering adminScope (BU-subtree boundary + assignable-set allowlist + strict-containment for scope grants).", - "note": "ADR-0090 D12 — delegated administration; proven by packages/plugins/plugin-security/src/delegated-admin-gate.test.ts." + "verifiedAt": "2026-10-15", + "evidence": "packages/plugins/plugin-security/src/delegated-admin-gate.ts — non-tenant-admin writes to sys_user_position / sys_user_permission_set require a covering adminScope (BU-subtree boundary + manageAssignments + assignable-set allowlist + strict containment, over includeSubtree / manageAssignments / the allowlist, for a granted set that carries its own scope).", + "note": "ADR-0090 D12 — delegated administration; proven by packages/plugins/plugin-security/src/delegated-admin-gate.test.ts. RE-VERIFIED 2026-10-15 (#23488): the scope's manageBindings and authorEnvironmentSets were RETIRED (retiredKey tombstones, D2 conversion permission-admin-scope-binding-authoring-removed) — the binding and set rows they gated are retired (ADR-0131 D3), and the metadata door that writes a position's sets and a set's body reads no adminScope, so neither flag granted anything. The scope itself stays live on the two assignment doors." }, "isDefault": { "status": "live", diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 8415c9d123c..7e8ee824be7 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -88,9 +88,12 @@ export interface DelegableAdminScope { /** `sys_business_unit.name` of the delegation root. */ businessUnit: string; includeSubtree: boolean; + /** + * The scope's one action right. `manageBindings` and + * `authorEnvironmentSets` were retired from `AdminScopeSchema` (#23488) and + * left this report with them: neither right had a door to honour it. + */ manageAssignments: boolean; - manageBindings: boolean; - authorEnvironmentSets: boolean; /** Permission-set names the delegate may hand out. */ assignablePermissionSets: string[]; /** Resolved subtree — root plus descendants when `includeSubtree`. */ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index dfc823a26a1..2bff336fa49 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -11032,6 +11032,141 @@ const pageRequiresNonCompiledKindRemoved: MetadataConversion = { }, }; +/** + * The delegated-admin scope's `manageBindings` and `authorEnvironmentSets` + * removed (protocol 18, #23488 — ADR-0049 enforce-or-remove; ruling of record + * 6181988435, letter A). + * + * Both named a write door ADR-0131 D3 retired: `manageBindings` the + * `sys_position_permission_set` binding rows, `authorEnvironmentSets` the + * `sys_permission_set` rows. A position's sets are part of its definition and + * a permission set is a definition; both are written through the metadata + * door, which admits a `manage_metadata` holder and reads no `adminScope`. So + * neither right was honoured anywhere. The tombstones on `AdminScopeSchema` + * refuse both at parse (`ADMIN_SCOPE_MANAGE_BINDINGS_REMOVED` and + * `ADMIN_SCOPE_AUTHOR_ENVIRONMENT_SETS_REMOVED` in + * `security/permission.zod.ts`). + * + * ## Why a D2 strip + * + * Both keys carried a schema DEFAULT (`false`), so a 17.x parse wrote both + * into every `adminScope` it emitted, whether or not the author wrote them: + * a built artifact and a stored `permission` row that carry a scope carry + * both. Setup's nested `adminScope` form and Studio's two switches wrote them + * explicitly too. After the tombstones the boot door and the metadata save + * door refuse such a set, and only the D2 table is replayed at the + * rehydration seams (`applyArtifactForwardConversions`, + * `applyConversionsToStoredItem`), so a built artifact or a stored row loads + * only through this entry. + * + * Each key leaves whatever it holds, `true` included: the tombstone refuses + * every value, so a strip that kept one would leave a set that cannot load. + * The strip is lossless for every door that grants a right — nothing granted + * either. The one decision the keys moved was the delegated-admin gate's + * strict-containment comparison, which no longer reads them; that change is + * the D3 record, the semantic entry + * `permission-admin-scope-binding-authoring-retired`. The emitted path names + * the set, as `email-template-category-is-system-removed` names its template. + */ +const permissionAdminScopeBindingAuthoringRemoved: MetadataConversion = { + id: 'permission-admin-scope-binding-authoring-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'permission.adminScope.manageBindings / permission.adminScope.authorEnvironmentSets', + summary: + "delegated-admin scope keys 'manageBindings' and 'authorEnvironmentSets' removed (ADR-0049 " + + 'enforce-or-remove — no door honoured either: a position\'s sets and a permission set are ' + + 'environment definitions written through the metadata door under manage_metadata, which reads ' + + "no adminScope. Delete both keys; a delegate's scope is placement, assignment and the allowlist)", + apply(stack, emit) { + return mapCollection(stack, 'permissions', (ps, path) => { + const scope = (ps as { adminScope?: unknown }).adminScope; + if (!isDict(scope)) return ps; + // Name the set, not just its index: the notice is the only record an + // upgrading author gets of WHICH set lost the keys. + const where = typeof ps.name === 'string' ? `${path}(${ps.name}).adminScope` : `${path}.adminScope`; + const stripped = stripKeys(scope, ['manageBindings', 'authorEnvironmentSets'], emit, where); + if (stripped === scope) return ps; + return { ...ps, adminScope: stripped }; + }); + }, + fixture: { + before: { + permissions: [ + { + // What Setup's nested form or Studio's switches wrote: both keys + // authored, one of them `true`. + name: 'east_admin', + label: 'East Admin', + adminScope: { + businessUnit: 'east', + manageAssignments: true, + manageBindings: true, + authorEnvironmentSets: false, + assignablePermissionSets: ['sales_user'], + }, + }, + { + // What a 17.x parse emitted for a scope whose author wrote neither + // key: both defaults, materialized. + name: 'west_admin', + label: 'West Admin', + adminScope: { + businessUnit: 'west', + includeSubtree: true, + manageAssignments: true, + manageBindings: false, + authorEnvironmentSets: false, + assignablePermissionSets: [], + }, + }, + { + // Already canonical, and a set with no scope at all — both ride + // through untouched. The fixture's own control: the strip dispatches + // on key presence, and copy-on-write keeps these references. + name: 'north_admin', + label: 'North Admin', + adminScope: { businessUnit: 'north', manageAssignments: true, assignablePermissionSets: ['sales_user'] }, + }, + { name: 'sales_user', label: 'Sales User' }, + ], + }, + after: { + permissions: [ + { + name: 'east_admin', + label: 'East Admin', + adminScope: { + businessUnit: 'east', + manageAssignments: true, + assignablePermissionSets: ['sales_user'], + }, + }, + { + name: 'west_admin', + label: 'West Admin', + adminScope: { + businessUnit: 'west', + includeSubtree: true, + manageAssignments: true, + assignablePermissionSets: [], + }, + }, + { + name: 'north_admin', + label: 'North Admin', + adminScope: { businessUnit: 'north', manageAssignments: true, assignablePermissionSets: ['sales_user'] }, + }, + { name: 'sales_user', label: 'Sales User' }, + ], + }, + // Four notices, one per STRIPPED KEY: both keys on `east_admin` and on + // `west_admin`, none for `north_admin` or `sales_user`. + expectedNotices: 4, + }, +}; + /** * Object-permission lifecycle bits `allowRestore` / `allowPurge` removed * (protocol 18, #12497 — ADR-0049 enforce-or-remove, maintainer ruling @@ -16466,6 +16601,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: pageComponentResponsiveRemoved, order: 13 }, { conversion: pageHeaderBreadcrumbRemoved, order: 49 }, { conversion: pageRequiresNonCompiledKindRemoved, order: 58 }, + { conversion: permissionAdminScopeBindingAuthoringRemoved, order: 65 }, { conversion: permissionAllowRestorePurgeRemoved, order: 16 }, { conversion: permissionRlsTagsRemoved, order: 42 }, { conversion: recordChatterPositionVocabulary, order: 2 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__authorEnvironmentSets.ts b/packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__authorEnvironmentSets.ts new file mode 100644 index 00000000000..b533f9991de --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__authorEnvironmentSets.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #23488 — ADR-0049 enforce-or-remove (ruling of record 6181988435, letter A). +// `AdminScope.authorEnvironmentSets` gated writes to `sys_permission_set` +// rows, which ADR-0131 D3 retired: a permission set is an environment metadata +// definition, written through the metadata door under `manage_metadata`, which +// reads no `adminScope`. Measured before removal: no production author set it +// to `true`, and no door that writes a permission set consulted it; its one +// remaining reader was the delegated-admin gate's strict-containment +// comparison, which drops the axis in the same PR. +// +// Tombstoned and healed exactly as `security/AdminScope:manageBindings` (the +// same D2 conversion, `permission-admin-scope-binding-authoring-removed`, and +// the same D3 record, `permission-admin-scope-binding-authoring-retired`). +export const entry = 'security/AdminScope:authorEnvironmentSets'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__manageBindings.ts b/packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__manageBindings.ts new file mode 100644 index 00000000000..22c90885545 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.security__AdminScope__manageBindings.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #23488 — ADR-0049 enforce-or-remove (ruling of record 6181988435, letter A). +// `AdminScope.manageBindings` gated the `sys_position_permission_set` binding +// rows, which ADR-0131 D3 retired: a position's sets are part of its +// definition, written through the metadata door under `manage_metadata`, which +// reads no `adminScope`. Measured before removal: no production author set it +// to `true` (the showcase wrote the default `false`), and no door that writes +// a position consulted it; its one remaining reader was the delegated-admin +// gate's strict-containment comparison, which drops the axis in the same PR. +// +// `retiredKey()` on a `strictObject`, for the prescription and the `tsc` +// channel. The key was DEFAULTED (`false`), so a 17.x parse wrote it into every +// scope it emitted; the `acceptRetiredDefaultResidue` stage is NOT adopted +// (the `system/EmailTemplateDefinition:isSystem` precedent): built artifacts +// and stored rows are healed by the replayed D2 conversion +// `permission-admin-scope-binding-authoring-removed`. The D3 record is +// `permission-admin-scope-binding-authoring-retired`. +export const entry = 'security/AdminScope:manageBindings'; diff --git a/packages/spec/src/migrations/entries/semantic/18.permission-admin-scope-binding-authoring-retired.ts b/packages/spec/src/migrations/entries/semantic/18.permission-admin-scope-binding-authoring-retired.ts new file mode 100644 index 00000000000..9fda3a734e2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.permission-admin-scope-binding-authoring-retired.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23488 — ADR-0049 enforce-or-remove (ruling of record 6181988435, letter A): +// the D3 entry of the `permission-admin-scope-binding-authoring-removed` family +// (one D3 entry per retirement family, even when D2 is lossless). Registered +// keys: `security/AdminScope:manageBindings` and +// `security/AdminScope:authorEnvironmentSets`. The strip grants and revokes +// nothing; what it changes is one refusal of the delegated-admin gate, and that +// is the judgement this entry hands the upgrader. +export const entry: SemanticMigration = { + id: 'permission-admin-scope-binding-authoring-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'permissions[].adminScope.manageBindings / permissions[].adminScope.authorEnvironmentSets — the delegated ' + + 'administrator\'s right to bind sets to positions and to author environment permission sets', + replacement: + 'Nothing: delete both keys. A delegate\'s scope is placement, assignment and the allowlist — businessUnit ' + + 'with includeSubtree, manageAssignments, and assignablePermissionSets. What a position distributes and what ' + + 'a permission set grants are environment definitions, written through the metadata door by a ' + + 'manage_metadata holder (ADR-0131 D3).', + reason: + 'The D2 conversion `permission-admin-scope-binding-authoring-removed` deletes both keys from every ' + + 'permission set\'s adminScope. No right is lost: the binding rows `manageBindings` gated and the set rows ' + + '`authorEnvironmentSets` gated were retired by ADR-0131 D3, and the metadata door that now writes a ' + + 'position\'s sets and a set\'s body admits a manage_metadata holder and reads no adminScope, so neither ' + + 'flag let a delegate do anything. One decision DID read them, and it changes: the delegated-admin ' + + 'gate\'s strict-containment comparison, which judges a delegate handing out a set that itself carries ' + + 'an adminScope (a sys_user_position assignment of a position that distributes it, or a direct ' + + 'sys_user_permission_set grant). The comparison now reads includeSubtree, manageAssignments and the ' + + 'allowlist only. Two consequences: a granted scope that set either flag `true` is no longer refused for ' + + 'out-reaching a delegate that did not; and a delegate whose scope set either flag `true` can no longer ' + + 'hand out a scope that is otherwise EQUAL to its own — the inert flag used to count as the "strictly ' + + 'larger" axis and let that lateral copy through the equal-scope refusal. That second case is now refused ' + + 'with 403 PERMISSION_DENIED (not strictly contained).', + acceptanceCriteria: + 'No permission set carries `manageBindings` or `authorEnvironmentSets` in its adminScope, and the parse ' + + 'refuses either with the prescription. Every delegate that relied on handing out a set whose adminScope ' + + 'equals its own (differing only in either retired flag) now narrows the handed-out scope on a real axis ' + + '— a smaller allowlist, includeSubtree false, or manageAssignments false — or has a tenant admin make ' + + 'the assignment. Nothing the author maintains reads either key from a set or from the ' + + 'my-delegable-scope report, which no longer carries them.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 4fc139a2b23..bba8ff71aec 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2526,6 +2526,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'and no D2 conversion exists: which tab carries the body is the author\'s decision. Its D3 ' + 'record is the semantic entry `page-slots-details-beside-tabs-refused`.', }, + { + id: 'permission-admin-scope-binding-authoring-retired', + order: 97, + text: + 'It also retires the delegated-admin scope\'s `manageBindings` and `authorEnvironmentSets` ' + + '(ADR-0049 enforce-or-remove; ADR-0090 D12): the rights to bind sets to positions and to author ' + + 'environment permission sets, whose binding and set rows ADR-0131 D3 retired. A position\'s sets ' + + 'and a set\'s body are environment definitions written through the metadata door by a ' + + '`manage_metadata` holder, and that door reads no adminScope, so neither flag granted anything. ' + + 'Both are retiredKey tombstones; the D2 conversion `permission-admin-scope-binding-authoring-removed` ' + + 'strips them from authored sets, stored rows and built artifacts, retired from the load path; and ' + + 'the delegated-admin gate\'s strict-containment comparison stops reading them, so an inert `true` ' + + 'no longer lets a delegate hand out a scope equal to its own. Its D3 record is the semantic entry ' + + '`permission-admin-scope-binding-authoring-retired`.', + }, { id: 'permission-restore-purge-bits-retired', order: 18, diff --git a/packages/spec/src/security/permission.form.ts b/packages/spec/src/security/permission.form.ts index 4fd006c5280..fd54be6b8e5 100644 --- a/packages/spec/src/security/permission.form.ts +++ b/packages/spec/src/security/permission.form.ts @@ -56,10 +56,12 @@ export const permissionForm = defineForm({ // administration could only be written in source. `json`, the hint every // structured row on this form carries. `json` is not a registered // widget, so the renderer derives the face from the node: this one is an - // object with properties, so it renders its six keys as a nested form + // object with properties, so it renders its keys as a nested form // whose edits merge into the stored scope — no key an author did not - // touch is rewritten. - { field: 'adminScope', widget: 'json', helpText: 'Delegated administration (ADR-0090 D12): lets holders of this set administer one business-unit subtree. businessUnit is the subtree root (sys_business_unit.name) and is required, non-blank; includeSubtree defaults to true; manageAssignments, manageBindings and authorEnvironmentSets default to false; a delegate may hand out only the sets named in assignablePermissionSets. Leave empty for a set that delegates nothing.' }, + // touch is rewritten. #23488: the help text names the scope's four + // live keys only; `manageBindings` and `authorEnvironmentSets` are + // retired tombstones, not rights a set can grant. + { field: 'adminScope', widget: 'json', helpText: 'Delegated administration (ADR-0090 D12): lets holders of this set administer one business-unit subtree. businessUnit is the subtree root (sys_business_unit.name) and is required, non-blank; includeSubtree defaults to true; manageAssignments defaults to false and lets the holder assign people to positions, and grant sets directly, inside that subtree; a delegate may hand out only the sets named in assignablePermissionSets. Leave empty for a set that delegates nothing.' }, ], }, { diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 00f7d01ef9a..41dc7ec9cc7 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -21,8 +21,11 @@ describe('AdminScopeSchema (ADR-0090 D12)', () => { expect(scope.businessUnit).toBe('east'); expect(scope.includeSubtree).toBe(true); // default: whole subtree expect(scope.manageAssignments).toBe(false); - expect(scope.manageBindings).toBe(false); - expect(scope.authorEnvironmentSets).toBe(false); + // #23488: the two retired rights materialize no default any more — the + // parsed scope carries neither key (their refusal is pinned in + // `admin-scope-binding-authoring-retirement.test.ts`). + expect(scope).not.toHaveProperty('manageBindings'); + expect(scope).not.toHaveProperty('authorEnvironmentSets'); expect(scope.assignablePermissionSets).toEqual([]); }); diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 8a7b5c014ec..dbb898c02e4 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -634,22 +634,65 @@ const ADMIN_SCOPE_BUSINESS_UNIT_BLANK = + 'unit\'s machine name, e.g. businessUnit: \'north_america\', or remove adminScope if this ' + 'permission set should not delegate administration.'; +/** + * [#23488] The prescriptions the two retired delegation rights are refused + * with — `manageBindings` and `authorEnvironmentSets` (ADR-0049 + * enforce-or-remove; ruling of record 6181988435, letter A). + * + * Both named a door that no longer exists. `manageBindings` gated the + * `sys_position_permission_set` binding rows and `authorEnvironmentSets` the + * `sys_permission_set` rows; ADR-0131 D3 retired both objects. A position's + * sets are part of its definition and a permission set is a definition, and + * both are written through the metadata door, which admits a + * `manage_metadata` holder (the platform operator under a wall) and reads no + * `adminScope`. So a delegate who "may bind" or "may author" was granted + * nothing — while the flag still counted in the delegated-admin gate's + * strict-containment comparison, where an inert `true` on the delegate's + * side let an otherwise EQUAL scope through the equal-scope refusal. + * + * Module-private for the reason {@link ADMIN_SCOPE_BUSINESS_UNIT_BLANK} gives. + */ +const ADMIN_SCOPE_MANAGE_BINDINGS_REMOVED = + '`permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — ' + + 'nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position\'s sets are ' + + 'part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a ' + + '`manage_metadata` holder and reads no adminScope. Delete the key; a delegate\'s scope is placement, assignment and ' + + 'the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + +const ADMIN_SCOPE_AUTHOR_ENVIRONMENT_SETS_REMOVED = + '`permission.adminScope.authorEnvironmentSets` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — ' + + 'nothing honoured it once permission sets stopped being rows: a permission set is an environment metadata ' + + 'definition (ADR-0131 D3), written through the metadata door, which admits a `manage_metadata` holder and reads no ' + + 'adminScope. Delete the key; a delegate\'s scope is placement, assignment and the allowlist — `businessUnit` with ' + + '`includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + /** * [ADR-0090 D12] Delegated-administration scope. * * Attaches to a permission set (and is therefore distributed via positions, * audited in the same tables, and explained by the same engine as every other * grant). Declares WHERE a delegate may administer (a business-unit subtree), - * WHAT they may do there (manage user↔position assignments, position↔set - * bindings, author environment-owned sets), and WHICH permission sets they may - * hand out (the anti-self-escalation allowlist — a delegate can never assign, - * to others or themselves, a set outside it). + * WHAT they may do there (manage user↔position assignments, and direct + * user↔set grants), and WHICH permission sets they may hand out (the + * anti-self-escalation allowlist — a delegate can never assign, to others or + * themselves, a set outside it). * * Runtime enforcement lives in `@objectstack/plugin-security`'s delegated-admin - * write gate on `sys_user_position` / `sys_position_permission_set` / - * `sys_user_permission_set` / `sys_permission_set`. Tenant-level admins - * (ADR-0066 superuser wildcard) are exempt; the `everyone`/`guest` audience - * anchors stay tenant-level only — no delegated scope can touch them. + * write gate on `sys_user_position` / `sys_user_permission_set`. Tenant-level + * admins (ADR-0066 superuser wildcard) are exempt; the `everyone`/`guest` + * audience anchors stay tenant-level only — no delegated scope can touch them. + * + * [#23488] It no longer declares position↔set bindings or the authoring of + * environment sets: `manageBindings` and `authorEnvironmentSets` are + * `retiredKey()` tombstones below. A position's sets and a permission set's + * body are environment definitions (ADR-0131 D3), written through the + * metadata door under `manage_metadata`, so neither right had a door to + * honour it. The ADR-0087 D2 conversion + * `permission-admin-scope-binding-authoring-removed` strips both from stored + * rows and built artifacts; the D3 record is the semantic entry + * `permission-admin-scope-binding-authoring-retired`. */ export const AdminScopeSchema = lazySchema(() => strictObject( { @@ -688,13 +731,25 @@ export const AdminScopeSchema = lazySchema(() => strictObject( includeSubtree: z.boolean().default(true).describe('Cover descendant business units too (default true)'), /** May create/update/delete `sys_user_position` assignments (and direct `sys_user_permission_set` grants) within the boundary. */ manageAssignments: z.boolean().default(false).describe('Manage user↔position assignments within the subtree'), - /** May create/delete `sys_position_permission_set` bindings whose blast radius lies within the boundary. */ - manageBindings: z.boolean().default(false).describe('Manage position↔permission-set bindings within the subtree'), - /** May author environment-owned permission sets (package-managed rows stay publish-only per ADR-0086). */ - authorEnvironmentSets: z.boolean().default(false).describe('Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3)'), + /** + * REMOVED (#23488): the binding rows it gated are retired, and a position's + * sets are written through the metadata door, which reads no adminScope. + * The key was DEFAULTED (`false`), so a 17.x parse wrote it into every scope + * it emitted; the `acceptRetiredDefaultResidue` stage is NOT adopted (the + * `email_template.isSystem` precedent): stored rows and built artifacts are + * healed by the replayed D2 conversion, and an authored source meets the + * prescription. + */ + manageBindings: retiredKey(ADMIN_SCOPE_MANAGE_BINDINGS_REMOVED), + /** + * REMOVED (#23488): a permission set is an environment definition written + * through the metadata door, which reads no adminScope. Defaulted like + * `manageBindings`, and healed the same way. + */ + authorEnvironmentSets: retiredKey(ADMIN_SCOPE_AUTHOR_ENVIRONMENT_SETS_REMOVED), /** * The anti-self-escalation core: permission-set NAMES the delegate may - * assign/bind. Every set reached by a delegated write must be in this list; + * assign. Every set reached by a delegated write must be in this list; * granting a set that itself carries an adminScope additionally requires the * grantor's scope to STRICTLY contain the granted one. */ @@ -946,7 +1001,7 @@ export const PermissionSetSchema = lazySchema(() => strictObject( /** * [ADR-0090 D12] Delegated-administration scope carried by this set. * Holding the set makes the user a SCOPED administrator: they may manage - * assignments/bindings (and optionally author environment sets) within the + * assignments (user↔position, and direct user↔set grants) within the * declared business-unit subtree, handing out ONLY the allowlisted sets. * See {@link AdminScopeSchema} for the full contract. */ diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 5a3062c01af..32564e3ebdf 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -86,6 +86,7 @@ import { } from '../automation/builtin-node-config.zod'; import { NotifyConfigSchema } from '../automation/io-node-config.zod'; import { ScriptConfigSchema, SubflowConfigSchema } from '../automation/schemaless-node-config.zod'; +import { AdminScopeSchema } from '../security/permission.zod'; import { SharingLevel } from '../security/sharing.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; @@ -228,6 +229,10 @@ const JOIN: Readonly> = { tombstone(EmailTemplateDefinitionSchema, 'email_template', 'category'), tombstone(EmailTemplateDefinitionSchema, 'email_template', 'isSystem'), ], + 'permission-admin-scope-binding-authoring-removed': [ + tombstone(AdminScopeSchema, 'permission.adminScope', 'manageBindings'), + tombstone(AdminScopeSchema, 'permission.adminScope', 'authorEnvironmentSets'), + ], 'manifest-permissions-string-list-removed': [ refusal('manifest.permissions as a list of strings', () => ManifestPermissionsSchema.safeParse(['system.user.read'])), ], From 43c50c843f8547d3b544bfff9533895702aac10b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:59:10 +0000 Subject: [PATCH 2/4] wip: retirement pin, generated surfaces, legacy-row mapper drops the retired keys (#23488) Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .../docs/references/security/permission.mdx | 10 +- .../permission-set-row-definition.test.ts | 28 ++ .../security/permission-set-row-definition.ts | 38 +- .../spec/authorable-defaults/security.json | 2 - .../spec/authorable-surface/security.json | 4 +- ...scope-binding-authoring-retirement.test.ts | 449 ++++++++++++++++++ packages/spec/vitest.repo-tests.json | 1 + 7 files changed, 521 insertions(+), 11 deletions(-) create mode 100644 packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 729e76591c1..1f33443fcf9 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -31,8 +31,8 @@ const result = AdminScopeSchema.parse(data); | **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root. Required and non-blank: an empty or whitespace-only value names no business unit and is refused. | | **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) | | **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree | -| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree | -| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) | +| **manageBindings** | `never` | optional | [REMOVED] `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **authorEnvironmentSets** | `never` | optional | [REMOVED] `permission.adminScope.authorEnvironmentSets` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once permission sets stopped being rows: a permission set is an environment metadata definition (ADR-0131 D3), written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out | @@ -125,7 +125,7 @@ const result = AdminScopeSchema.parse(data); | **systemPermissions** | `string[]` | optional | System level capabilities | | **tabPermissions** | `Record>` | optional | App/tab visibility: visible, hidden, default_on (shown by default), default_off (available but hidden initially) | | **rowLevelSecurity** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Row-level security policies (see rls.zod.ts for full spec) | -| **adminScope** | `{ businessUnit: string; includeSubtree?: boolean; manageAssignments?: boolean; manageBindings?: boolean; … }` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) | +| **adminScope** | `{ businessUnit: string; includeSubtree?: boolean; manageAssignments?: boolean; assignablePermissionSets?: string[] }` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this permission set. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -182,8 +182,8 @@ const result = AdminScopeSchema.parse(data); | **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root. Required and non-blank: an empty or whitespace-only value names no business unit and is refused. | | **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) | | **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree | -| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree | -| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) | +| **manageBindings** | `never` | optional | [REMOVED] `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **authorEnvironmentSets** | `never` | optional | [REMOVED] `permission.adminScope.authorEnvironmentSets` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once permission sets stopped being rows: a permission set is an environment metadata definition (ADR-0131 D3), written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out | ### Nested Shape: `PermissionSet.protection` diff --git a/packages/core/src/security/permission-set-row-definition.test.ts b/packages/core/src/security/permission-set-row-definition.test.ts index 9e86412cfa5..0e247d8a19a 100644 --- a/packages/core/src/security/permission-set-row-definition.test.ts +++ b/packages/core/src/security/permission-set-row-definition.test.ts @@ -137,6 +137,34 @@ describe('permissionSetDefinitionFromRow', () => { expect(permissionSetDefinitionFromRow({ name: 'n', admin_scope: '{"businessUnit":"emea"}' }).adminScope).toEqual({ businessUnit: 'emea' }); }); + // [#23488] The ceremony saves through `saveMetaItem`, which replays no + // conversion and validates against the current schema; spec 18 tombstones + // `manageBindings` / `authorEnvironmentSets`, so a body still carrying either + // is refused there (`422 INVALID_METADATA`, measured on the PR). A 17.x + // projection carries both as the materialized default `false`. + it('drops the two retired scope keys whatever they hold, so the body is one the door accepts', () => { + for (const retired of [ + { manageBindings: false, authorEnvironmentSets: false }, + { manageBindings: true, authorEnvironmentSets: true }, + { manageBindings: 'yes' }, + ]) { + const scope = { businessUnit: 'emea', includeSubtree: true, manageAssignments: true, assignablePermissionSets: ['sales_ops'] }; + const body = permissionSetDefinitionFromRow({ name: 'emea_admin', admin_scope: JSON.stringify({ ...scope, ...retired }) }); + // Every live key survives byte-for-byte; only the retired ones go. + expect(body.adminScope, JSON.stringify(retired)).toStrictEqual(scope); + expect(PermissionSetSchema.safeParse(body).success, JSON.stringify(retired)).toBe(true); + } + // CONTROL: the identical scope with the keys left in is refused by the + // door's schema at the retired key — so the drop above is what makes the + // body acceptable, not something else in it. + const kept = { name: 'emea_admin', adminScope: { businessUnit: 'emea', manageBindings: false } }; + const refused = PermissionSetSchema.safeParse(kept); + expect(refused.success).toBe(false); + expect(refused.error?.issues.map((i) => i.path)).toEqual([['adminScope', 'manageBindings']]); + // A scope with neither key is handed on as read, and a driver-parsed one too. + expect(permissionSetDefinitionFromRow({ name: 'n', admin_scope: { businessUnit: 'emea' } }).adminScope).toEqual({ businessUnit: 'emea' }); + }); + it('a null or absent row answers the body of a row with every column absent', () => { const empty = { name: undefined, diff --git a/packages/core/src/security/permission-set-row-definition.ts b/packages/core/src/security/permission-set-row-definition.ts index bd421425cc8..03f32ba6f61 100644 --- a/packages/core/src/security/permission-set-row-definition.ts +++ b/packages/core/src/security/permission-set-row-definition.ts @@ -32,7 +32,9 @@ * empty string, JSON `null`, or text that does not parse answers the * default; * - `admin_scope` → `adminScope` (ADR-0090 D12), only when the column is - * truthy and reads to a truthy value. + * truthy and reads to a truthy value, minus the scope's two RETIRED keys + * (`manageBindings`, `authorEnvironmentSets` — see + * {@link RETIRED_ADMIN_SCOPE_KEYS}). * * The body is built from those columns alone, so a row's state and provenance * columns (`active`, `managed_by`, `customized`, the timestamps) are not a @@ -66,6 +68,36 @@ export interface PermissionSetRowForDefinition { readonly [column: string]: unknown; } +/** + * [#23488] The `adminScope` keys `@objectstack/spec` 18 retired (ADR-0049 + * enforce-or-remove): the binding and set rows they gated are the very + * `sys_position_permission_set` / `sys_permission_set` rows this ceremony + * converts, so a Setup-authored row's `admin_scope` can carry either — and + * every row the security plugin projected from a parsed 17.x definition + * carries both, as the materialized default `false`. + * + * Dropped here, whatever they hold, because the door the ceremony saves + * through replays no conversion: `saveMetaItem` validates the body against + * the current schema, whose tombstones refuse both keys (measured: such a + * body answers `422 INVALID_METADATA` at `adminScope.manageBindings`, and the + * same body without the keys saves). The drop is the spec's own D2 strip, + * `permission-admin-scope-binding-authoring-removed`, applied at the one seam + * that seam table does not reach — lossless, since neither key granted + * anything. Two names, written down rather than derived: the mapping is a + * fixed historical shape, and a key a future major retires is that + * retirement's to add. + */ +const RETIRED_ADMIN_SCOPE_KEYS: readonly string[] = ['manageBindings', 'authorEnvironmentSets']; + +/** The stored scope without {@link RETIRED_ADMIN_SCOPE_KEYS}; anything but a plain object is passed on as read. */ +const withoutRetiredAdminScopeKeys = (scope: unknown): unknown => { + if (typeof scope !== 'object' || scope === null || Array.isArray(scope)) return scope; + if (!RETIRED_ADMIN_SCOPE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(scope, key))) return scope; + const next: Record = { ...(scope as Record) }; + for (const key of RETIRED_ADMIN_SCOPE_KEYS) delete next[key]; + return next; +}; + /** A facet column as stored: JSON text is parsed, a parsed value is kept, anything unreadable is the default. */ const parseMaybeJson = (value: unknown, fallback: unknown): unknown => { if (typeof value !== 'string') return value ?? fallback; @@ -98,7 +130,9 @@ function permissionSpecBodyKeys(): ReadonlySet { export function permissionSetDefinitionFromRow( row: PermissionSetRowForDefinition | null | undefined, ): Record { - const adminScope = row?.admin_scope ? parseMaybeJson(row.admin_scope, undefined) : undefined; + const adminScope = row?.admin_scope + ? withoutRetiredAdminScopeKeys(parseMaybeJson(row.admin_scope, undefined)) + : undefined; const candidate: Record = { name: row?.name, label: row?.label ?? row?.name, diff --git a/packages/spec/authorable-defaults/security.json b/packages/spec/authorable-defaults/security.json index 5ede0be6cd4..b0e3f80f575 100644 --- a/packages/spec/authorable-defaults/security.json +++ b/packages/spec/authorable-defaults/security.json @@ -5,10 +5,8 @@ "security/AccessMatrix:entries = []", "security/AccessMatrix:version = 1", "security/AdminScope:assignablePermissionSets = []", - "security/AdminScope:authorEnvironmentSets = false", "security/AdminScope:includeSubtree = true", "security/AdminScope:manageAssignments = false", - "security/AdminScope:manageBindings = false", "security/CapabilityDeclaration:scope = \"platform\"", "security/CriteriaSharingRule:accessLevel = \"read\"", "security/CriteriaSharingRule:active = true", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 4823721cbf7..f4303f3ffdc 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -16,11 +16,11 @@ "security/AccessMatrixEntry:viewAllRecords", "security/AccessMatrixEntry:writeScope", "security/AdminScope:assignablePermissionSets", - "security/AdminScope:authorEnvironmentSets", + "security/AdminScope:authorEnvironmentSets [RETIRED]", "security/AdminScope:businessUnit", "security/AdminScope:includeSubtree", "security/AdminScope:manageAssignments", - "security/AdminScope:manageBindings", + "security/AdminScope:manageBindings [RETIRED]", "security/CapabilityDeclaration:_lock", "security/CapabilityDeclaration:_lockDocsUrl", "security/CapabilityDeclaration:_lockReason", diff --git a/packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts b/packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts new file mode 100644 index 00000000000..515ab27f03f --- /dev/null +++ b/packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts @@ -0,0 +1,449 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The delegated-admin scope's `manageBindings` and `authorEnvironmentSets` + * RETIRED (#23488) — ADR-0049 enforce-or-remove, ruling of record 6181988435, + * letter A. + * + * Both named a write door ADR-0131 D3 retired: the `sys_position_permission_set` + * binding rows and the `sys_permission_set` rows. A position's sets and a + * permission set's body are environment definitions written through the + * metadata door, which admits a `manage_metadata` holder and reads no + * `adminScope`. Measured before removal: no production author set either key + * to `true` (the showcase wrote the default `false`; the control key + * `manageAssignments: true` had four production authors), and no door that + * writes a position or a set consulted either key. + * + * Bookkeeping shapes, pinned below: + * 1. Two `retiredKey()` tombstones on `AdminScopeSchema`, a `strictObject`, + * so each refusal carries its prescription and each key's input type is + * the retired mark for `tsc`. + * 2. D2 conversion `permission-admin-scope-binding-authoring-removed` + * (step 18), a delete of both keys from every `permissions[].adminScope`, + * retired from the load path: a live author is refused, a stored row and + * a built artifact replay clean. The keys were DEFAULTED, so a 17.x parse + * materialized both wherever a scope was declared. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries both keys; the family's D3 entry is + * `permission-admin-scope-binding-authoring-retired`. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status`; that envelope belongs to + * the authoring door, `defineStack`, pinned with its `code` and `status`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import { AdminScopeSchema, PermissionSetSchema, type AdminScope } from './permission.zod'; + +const KEYS = ['manageBindings', 'authorEnvironmentSets'] as const; +const CONVERSION_ID = 'permission-admin-scope-binding-authoring-removed'; +const D3_ID = 'permission-admin-scope-binding-authoring-retired'; + +/** A well-formed scope — the keys an author commonly writes, not the retired ones. */ +const SCOPE = { + businessUnit: 'east', + includeSubtree: true, + manageAssignments: true, + assignablePermissionSets: ['sales_user'], +} as const; + +/** A well-formed permission set carrying {@link SCOPE}. */ +const SET = { name: 'east_admin', label: 'East Admin', adminScope: SCOPE } as const; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself. +const PRESCRIPTION: Record<(typeof KEYS)[number], RegExp> = { + manageBindings: + /`permission\.adminScope\.manageBindings` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove, ADR-0090 D12\) — nothing honoured it.*Delete the key; a delegate's scope is placement, assignment and the allowlist.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s, + authorEnvironmentSets: + /`permission\.adminScope\.authorEnvironmentSets` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove, ADR-0090 D12\) — nothing honoured it.*Delete the key; a delegate's scope is placement, assignment and the allowlist.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s, +}; + +describe('adminScope binding/authoring retirement — the tombstones, at every door that carries a scope', () => { + for (const key of KEYS) { + it(`the scope refuses \`${key}\` at its path, with the prescription`, () => { + const r = AdminScopeSchema.safeParse({ ...SCOPE, [key]: true }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([key]); + expect(issue.message).toMatch(PRESCRIPTION[key]); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith(`\`permission.adminScope.${key}\` was removed`)).toBe(true); + }); + + it(`refuses EVERY value of \`${key}\` — the retired default \`false\` included`, () => { + // `false` is what a 17.x parse materialized; it is healed by the D2 + // conversion on the rehydration seams, never accepted by the parse. + for (const value of [true, false, 'x', 0, null, {}]) { + const r = AdminScopeSchema.safeParse({ ...SCOPE, [key]: value }); + expect(r.success, JSON.stringify(value)).toBe(false); + if (r.success) continue; + expect(r.error.issues.map((i) => i.path)).toEqual([[key]]); + expect(r.error.issues[0]!.message).toMatch(PRESCRIPTION[key]); + } + }); + } + + it('the `permission` write door (the registry binding) refuses both, each at its own path', () => { + // `getMetadataTypeSchema('permission')` is what `saveMetaItem` validates a + // `PUT /api/v1/meta/permission` body against; a rebinding to another shape + // would pass the pins above and still accept the keys. + const door = getMetadataTypeSchema('permission'); + expect(door, 'no schema bound for `permission`').toBeDefined(); + expect(door).toBe(PermissionSetSchema); + const r = door!.safeParse({ ...SET, adminScope: { ...SCOPE, manageBindings: false, authorEnvironmentSets: false } }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path)).toEqual([ + ['adminScope', 'manageBindings'], + ['adminScope', 'authorEnvironmentSets'], + ]); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never strips it', () => { + const stack = (set: Record) => ({ + manifest: { id: 'com.example.admin-scope-retired', name: 'admin_scope_retired', version: '1.0.0', type: 'app' }, + permissions: [set], + }); + let thrown: unknown; + try { + defineStack(stack({ ...SET, adminScope: { ...SCOPE, manageBindings: true } }) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues?.map((i) => i.path)).toEqual([['permissions', 0, 'adminScope', 'manageBindings']]); + expect(refusal.issues?.[0]?.message).toMatch(PRESCRIPTION.manageBindings); + // CONTROL: the same stack without the key is accepted by the same door. + expect(() => defineStack(stack({ ...SET }) as never)).not.toThrow(); + }); + + it('CONTROL: the same scope without the keys passes, keeps its live keys, and grows neither key', () => { + const r = AdminScopeSchema.safeParse(SCOPE); + expect(r.success).toBe(true); + if (!r.success) return; + for (const key of KEYS) expect(r.data).not.toHaveProperty(key); + expect(r.data.manageAssignments).toBe(true); + expect(r.data.assignablePermissionSets).toEqual(['sales_user']); + }); + + it('the walked shape keeps both keys — the authorable-surface rows stay reachable as RETIRED', () => { + const shape = (AdminScopeSchema as unknown as { shape?: Record }).shape; + expect(shape, 'AdminScopeSchema must expose a shape').toBeDefined(); + for (const key of KEYS) expect(Object.keys(shape!)).toContain(key); + expect(Object.keys(shape!), 'CONTROL: a live neighbour').toContain('manageAssignments'); + }); + + it('fails tsc at the authoring site: the input type of each key is the retired mark', () => { + const bindings: AdminScope = { + ...SCOPE, + // @ts-expect-error — `manageBindings` is a retiredKey() tombstone: no value is assignable. + manageBindings: false, + }; + const authoring: AdminScope = { + ...SCOPE, + // @ts-expect-error — `authorEnvironmentSets` is a retiredKey() tombstone: no value is assignable. + authorEnvironmentSets: true, + }; + // The parse channel agrees with the type channel on the same literals. + expect(() => AdminScopeSchema.parse(bindings)).toThrow(PRESCRIPTION.manageBindings); + expect(() => AdminScopeSchema.parse(authoring)).toThrow(PRESCRIPTION.authorEnvironmentSets); + }); +}); + +describe('adminScope binding/authoring retirement — the D2 conversion', () => { + it('a STORED `permission` row carrying both keys replays clean through the rehydration seam', () => { + // The seam wraps a `permission` row as `{ permissions: [row] }` and + // replays the full chain, retired entries included. + const notices: { conversionId?: string; path?: string; from?: string; to?: string }[] = []; + const stored = { ...SET, adminScope: { ...SCOPE, manageBindings: false, authorEnvironmentSets: false } }; + const rehydrated = applyConversionsToStoredItem('permission', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), + }) as Record; + + expect(notices.map((n) => [n.conversionId, n.from, n.to])).toEqual([ + [CONVERSION_ID, 'manageBindings', '(removed)'], + [CONVERSION_ID, 'authorEnvironmentSets', '(removed)'], + ]); + // The notice names the set, not just its index. + expect(notices[0]!.path).toContain('(east_admin).adminScope.manageBindings'); + // CONTROL: every live key on the same row survives byte-for-byte. + expect(rehydrated).toEqual(SET); + // And the rehydrated row is exactly what the write door accepts now. + expect(PermissionSetSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + // The 17.x shape: both defaults materialized beside the live keys. + const artifact = () => ({ permissions: [{ ...SET, adminScope: { ...SCOPE, manageBindings: false, authorEnvironmentSets: false } }] }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('`permission.adminScope.manageBindings` was removed'); + + const healed = applyConversions(artifact(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect(after.success, `expected the converted artifact to parse; got ${JSON.stringify(after.error?.issues ?? [])}`).toBe(true); + + // LIT CONTROL: a scope that was always wrong is refused on BOTH sides, + // so the leg above is a reading and not a tautology. + const wrong = () => ({ permissions: [{ ...SET, adminScope: { ...SCOPE, manageBindings: true, manageBindingz: true } }] }); + expect(ObjectStackDefinitionSchema.safeParse(applyConversions(wrong(), { includeRetired: true })).success).toBe(false); + }); + + it('strips both keys whatever they hold — one notice per key — leaves the rest, and is idempotent', () => { + const input = { + permissions: [ + { ...SET, name: 'a_one', adminScope: { ...SCOPE, manageBindings: true, authorEnvironmentSets: 'yes' } }, + { ...SET, name: 'b_two', adminScope: { ...SCOPE, authorEnvironmentSets: 7 } }, + // Already canonical, and a set with no scope: no notice, handed back by reference. + { ...SET, name: 'c_three' }, + { name: 'd_four', label: 'Four' }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + const mine = notices.filter((n) => n.conversionId === CONVERSION_ID); + expect(mine).toHaveLength(3); + expect(mine.every((n) => typeof n.path === 'string' && /\.adminScope\.(manageBindings|authorEnvironmentSets)$/.test(n.path))).toBe(true); + const permissions = stack.permissions as Array<{ adminScope?: Record }>; + for (const set of permissions) for (const key of KEYS) expect(set.adminScope ?? {}).not.toHaveProperty(key); + expect(permissions[2]).toBe(input.permissions[2]); + expect(permissions[3]).toBe(input.permissions[3]); + + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { permissions: [{ ...SET, adminScope: { ...SCOPE, manageBindings: true } }] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('adminScope binding/authoring retirement — ADR-0087 registration', () => { + it('declares both keys under major 18, once each', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + for (const key of KEYS) { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(`security/AdminScope:${key}`); + expect(all.filter((k) => k === `security/AdminScope:${key}`)).toHaveLength(1); + } + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('permission.adminScope.manageBindings / permission.adminScope.authorEnvironmentSets'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion and the containment change', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.reason).toContain('strict-containment comparison'); + expect(entry!.replacement).toContain('delete both keys'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper: `retiredKey()` types each key as the retired +// mark on `AdminScopeSchema`'s input, so every TYPED authoring site fails to +// compile. The residue is what `tsc` never judges: JSON, YAML, MD/MDX code +// fences, untyped `.js`, and TS literals typed `any` / `unknown` / a record. +// This walk covers that residue across the five repo roots +// `scripts/cross-package-test-inputs.mjs` already declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees, declared there as `examples/*/src/**/*.ts`. +// +// Both key names are distinctive — no other shape declares either — so the +// matcher judges them in KEY POSITION anywhere, never a mention: in code, +// string and comment contents are masked first (a quoted key is kept, for +// JSON); in YAML, a mapping key; in MD/MDX, each fenced block by its language. +// The bound, stated: a key under a computed name or assembled by spread, and +// `docs/**`, `.claude/**`, `.github/**`, `.changeset/**` and the repo-root +// files are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors manageBindings / authorEnvironmentSets', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** A retired key in key position — never the tombstone declaration itself. */ + const RETIRED_KEY = /(^|[\s{,(])["']?(?:manageBindings|authorEnvironmentSets)["']?\s*:(?!\s*retiredKey\()/m; + const YAML_KEY = /^[ \t]*(?:-[ \t]+)?(?:manageBindings|authorEnvironmentSets)[ \t]*:/m; + + /** + * Code with string and comment CONTENTS blanked (same length, so offsets + * hold). A quoted string followed by `:` is a key and is kept — that is how + * JSON and quoted TS keys stay visible. A template literal is blanked whole. + */ + const maskCode = (src: string): string => { + const out = src.split(''); + const blank = (from: number, to: number) => { + for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' '; + }; + let i = 0; + while (i < src.length) { + const c = src[i]!; + const next = src[i + 1]; + if (c === '/' && next === '/') { + const end = src.indexOf('\n', i); + const stop = end === -1 ? src.length : end; + blank(i, stop); + i = stop; + } else if (c === '/' && next === '*') { + const end = src.indexOf('*/', i + 2); + const stop = end === -1 ? src.length : end + 2; + blank(i, stop); + i = stop; + } else if (c === '"' || c === "'" || c === '`') { + let j = i + 1; + while (j < src.length && src[j] !== c && (c === '`' || src[j] !== '\n')) j += src[j] === '\\' ? 2 : 1; + const close = Math.min(j, src.length - 1); + const isKey = c !== '`' && /^\s*:/.test(src.slice(close + 1, close + 40)); + if (!isKey) blank(i + 1, close); + i = close + 1; + } else { + i += 1; + } + } + return out.join(''); + }; + + const FENCE = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + const judge = (text: string, ext: string): boolean => { + if (ext === '.yaml' || ext === '.yml') return YAML_KEY.test(text); + if (ext === '.md' || ext === '.mdx') { + for (const m of text.matchAll(FENCE)) { + const lang = m[1]!.toLowerCase(); + if (lang === 'yaml' || lang === 'yml' ? YAML_KEY.test(m[2]!) : RETIRED_KEY.test(maskCode(m[2]!))) return true; + } + return false; + } + return RETIRED_KEY.test(maskCode(text)); + }; + + /** + * Structural exclusions, each with its reason. ⛔ NOT an allowlist file + * (`spec-property-retirement` §4): every entry's JOB is to spell a key. + */ + const EXCLUDED = new Set([ + // This pin authors both keys to assert their refusal and their conversion. + THIS_FILE, + // The declaration: the two tombstones. + 'packages/spec/src/security/permission.zod.ts', + // The delegated-admin gate's pin that the containment comparison reads + // neither key: it hands the gate a stored scope still carrying them. + 'packages/plugins/plugin-security/src/delegated-admin-gate.test.ts', + // The legacy-row mapper's pin: a pre-retirement `admin_scope` column + // carries both keys, and the mapper must drop them. + 'packages/core/src/security/permission-set-row-definition.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement scope on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output, reached only because this is a FILESYSTEM walk. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a mention and the tombstone (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("definePermissionSet({ name: 'a', adminScope: { businessUnit: 'east', manageBindings: false } })", '.ts')).toBe(true); + expect(judge('{ "adminScope": { "businessUnit": "east", "authorEnvironmentSets": true } }', '.json')).toBe(true); + expect(judge('adminScope:\n businessUnit: east\n manageBindings: true\n', '.yaml')).toBe(true); + expect(judge("Prose.\n\n```ts\nauthorEnvironmentSets: false,\n```\n", '.mdx')).toBe(true); + expect(judge('Prose.\n\n```yaml\nmanageBindings: false\n```\n', '.md')).toBe(true); + // Neighbours that must NOT match: the tombstone, comments, strings, prose. + expect(judge('manageBindings: retiredKey(ADMIN_SCOPE_MANAGE_BINDINGS_REMOVED),', '.ts')).toBe(false); + expect(judge("// it once wrote { manageBindings: false }\nconst x = 1;", '.ts')).toBe(false); + expect(judge("const s = \"{ authorEnvironmentSets: false }\";", '.ts')).toBe(false); + expect(judge('Delete `manageBindings` from every scope.', '.mdx')).toBe(false); + expect(judge('"security/AdminScope:manageBindings [RETIRED]",', '.json')).toBe(false); + expect(judge("expect(scope).not.toHaveProperty('authorEnvironmentSets');", '.ts')).toBe(false); + }); + + it('no manageBindings / authorEnvironmentSets authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + let securitySources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + if (rel.startsWith('packages/plugins/plugin-security/src/')) securitySources += 1; + const text = readIfPresent(full); + if (text === undefined || !KEYS.some((key) => text.includes(key))) continue; + if (judge(text, ext)) offenders.push(rel); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, the example apps' + // sources were really read (the showcase's delegate set among them), and + // the delegated-admin gate's own package was really reached. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(securitySources).toBeGreaterThan(50); + expect(offenders, 'a manageBindings / authorEnvironmentSets authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index caf671b02a5..f3f110ccbdc 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -44,6 +44,7 @@ "src/integration/connector-triggers-retirement.test.ts", "src/kernel/metadata-type-registry-allow-org-override-absence.test.ts", "src/qa/requires-plugins-retirement.test.ts", + "src/security/admin-scope-binding-authoring-retirement.test.ts", "src/security/rls-tags-retirement.test.ts", "src/shared/retired-key-migrate-sentence.test.ts", "src/system/compliance-families-retirement.test.ts", From 1361ff6668243f7a5d3a87c018cf7f1ab4762390 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:12:24 +0000 Subject: [PATCH 3/4] wip: changeset, translations, retirement pin fixture (#23488) Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- ...8-admin-scope-binding-authoring-retired.md | 51 +++++++++++++++++++ .../en.metadata-forms.generated.ts | 2 +- .../es-ES.metadata-forms.generated.ts | 2 +- .../ja-JP.metadata-forms.generated.ts | 2 +- .../zh-CN.metadata-forms.generated.ts | 2 +- ...scope-binding-authoring-retirement.test.ts | 6 +-- 6 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 .changeset/23488-admin-scope-binding-authoring-retired.md diff --git a/.changeset/23488-admin-scope-binding-authoring-retired.md b/.changeset/23488-admin-scope-binding-authoring-retired.md new file mode 100644 index 00000000000..e07cc298ef5 --- /dev/null +++ b/.changeset/23488-admin-scope-binding-authoring-retired.md @@ -0,0 +1,51 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': minor +'@objectstack/core': patch +'@objectstack/platform-objects': patch +--- + +feat(spec)!: retire the delegated-admin scope's `manageBindings` and `authorEnvironmentSets` — rights no door honoured (#23488) + +**BREAKING** — `manageBindings` and `authorEnvironmentSets` on a permission set's `adminScope` (`AdminScopeSchema`, ADR-0090 D12) are now refused at parse. They named the right to bind sets to positions and the right to author environment permission sets, and they gated the `sys_position_permission_set` and `sys_permission_set` rows, which ADR-0131 D3 retired. A position's sets are part of its definition and a permission set is a definition; both are written through the metadata door, which admits a `manage_metadata` holder and reads no `adminScope`. So a delegate who "may bind" or "may author" was granted nothing. Delete both keys: a delegate's scope is placement, assignment and the allowlist. + +Clause-②: yes (narrowing) + +Measured before removal, at `cdc42eb49`: no production author set either key to `true` (the showcase wrote the default `false`; the control key `manageAssignments: true` has four production authors), and no door that writes a position or a permission set consulted either key. Their one reader was the delegated-admin gate's strict-containment comparison, which changes below. + +## FROM → TO + +| you wrote (17.7 and earlier) | write instead | +| --- | --- | +| `adminScope: { …, manageBindings: true \| false }` | nothing — delete the key | +| `adminScope: { …, authorEnvironmentSets: true \| false }` | nothing — delete the key | +| a delegate who re-composes what a position distributes, or authors a set's body | a `manage_metadata` holder writes the position or set definition through the metadata door (ADR-0131 D3); the delegate assigns people to positions and grants allowlisted sets inside its subtree | +| reading `scopes[].manageBindings` / `scopes[].authorEnvironmentSets` from `GET /api/v1/security/my-delegable-scope` (`DelegableAdminScope`) | nothing — the report carries `manageAssignments` as the scope's one action right | + +**The one-line fix:** delete `manageBindings` and `authorEnvironmentSets` from every `adminScope`. + +**What an author who still writes either sees.** `tsc` fails at the authoring site (`AdminScope` types both keys as retired), and the parse — `definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name` — refuses it at its own path with the prescription: + +> `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + +## The containment change (`@objectstack/plugin-security`) + +The delegated-admin gate judges a delegate who hands out a set that itself carries an `adminScope` — a `sys_user_position` assignment of a position that distributes it, or a direct `sys_user_permission_set` grant — by **strict containment**. That comparison read both retired flags. It now reads `includeSubtree`, `manageAssignments` and the allowlist only: + +- a delegate whose own scope set either flag `true` can **no longer** hand out a scope otherwise EQUAL to its own — the inert flag used to count as the strictly-larger axis and let that lateral copy through the equal-scope refusal. It is refused `403 PERMISSION_DENIED` (`… would hand out an adminScope not strictly contained by yours`); +- a granted scope that set either flag `true` no longer counts as out-reaching a delegate that did not. + +`DelegatedAdminGate.describeDelegableScope()` (and so `GET /api/v1/security/my-delegable-scope`) no longer reports either flag. + +## The retirement kit + +- **Two `retiredKey()` tombstones** on `AdminScopeSchema`, a `strictObject` — so each refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/AdminScope:manageBindings`, `security/AdminScope:authorEnvironmentSets`. `DelegableAdminScope` (`@objectstack/spec/contracts`) drops both fields. +- **The D2 conversion `permission-admin-scope-binding-authoring-removed`** (protocol 18, retired from the load path) deletes both keys from every `permissions[].adminScope`, one notice per key, whatever they hold. Both keys carried the default `false`, so a 17.x parse materialized them in every declared scope; the rehydration seams replay this conversion over stored `permission` rows and built artifacts, and `os migrate meta --stored` lists the rows that carry them before the upgrade. +- **The D3 entry `permission-admin-scope-binding-authoring-retired`** records the containment change above: the judgement the strip cannot make for the upgrader. +- **`@objectstack/core`:** `permissionSetDefinitionFromRow` — the mapping `os migrate`'s legacy-row ceremony saves a `sys_permission_set` row through — drops both keys from the row's `admin_scope`. That save goes through `saveMetaItem`, which replays no conversion: measured, a 17.x row's body answered `422 INVALID_METADATA` at `adminScope.manageBindings`, and the same body without the keys saved. +- **`@objectstack/platform-objects`:** the permission-set form's `adminScope` help text (all four locales) names the scope's live keys only. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's two switches for these keys retire in an objectui follow-up. + + diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index a631f185217..9d42e654a84 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2323,7 +2323,7 @@ export const enMetadataForms: NonNullable = { }, adminScope: { label: "Admin Scope", - helpText: "Delegated administration (ADR-0090 D12): lets holders of this set administer one business-unit subtree. businessUnit is the subtree root (sys_business_unit.name) and is required, non-blank; includeSubtree defaults to true; manageAssignments, manageBindings and authorEnvironmentSets default to false; a delegate may hand out only the sets named in assignablePermissionSets. Leave empty for a set that delegates nothing." + helpText: "Delegated administration (ADR-0090 D12): lets holders of this set administer one business-unit subtree. businessUnit is the subtree root (sys_business_unit.name) and is required, non-blank; includeSubtree defaults to true; manageAssignments defaults to false and lets the holder assign people to positions, and grant sets directly, inside that subtree; a delegate may hand out only the sets named in assignablePermissionSets. Leave empty for a set that delegates nothing." }, objects: { label: "Objects", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 69a00e76c01..0881e68d300 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2323,7 +2323,7 @@ export const esESMetadataForms: NonNullable = }, adminScope: { label: "Ámbito de administración delegada", - helpText: "Administración delegada (ADR-0090 D12): permite a quienes tienen este conjunto administrar un subárbol de unidades de negocio. businessUnit es la raíz del subárbol (sys_business_unit.name), es obligatorio y no puede estar en blanco; includeSubtree vale true por defecto; manageAssignments, manageBindings y authorEnvironmentSets valen false por defecto; un delegado solo puede asignar los conjuntos nombrados en assignablePermissionSets. Déjalo vacío en un conjunto que no delega nada." + helpText: "Administración delegada (ADR-0090 D12): permite a quienes tienen este conjunto administrar un subárbol de unidades de negocio. businessUnit es la raíz del subárbol (sys_business_unit.name), es obligatorio y no puede estar en blanco; includeSubtree vale true por defecto; manageAssignments vale false por defecto y permite al titular asignar personas a puestos, y conceder conjuntos directamente, dentro de ese subárbol; un delegado solo puede asignar los conjuntos nombrados en assignablePermissionSets. Déjalo vacío en un conjunto que no delega nada." }, objects: { label: "Permisos de objeto", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index be3ce88e1db..504f453f9ac 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2323,7 +2323,7 @@ export const jaJPMetadataForms: NonNullable = }, adminScope: { label: "委任管理スコープ", - helpText: "委任管理(ADR-0090 D12): この権限セットの保持者が 1 つのビジネスユニットのサブツリーを管理できるようにします。businessUnit はサブツリーのルート(sys_business_unit.name)で、必須かつ空白不可です。includeSubtree の既定値は true、manageAssignments・manageBindings・authorEnvironmentSets の既定値は false です。委任先が付与できるのは assignablePermissionSets に挙げた権限セットだけです。何も委任しない権限セットでは空のままにしてください。" + helpText: "委任管理(ADR-0090 D12): この権限セットの保持者が 1 つのビジネスユニットのサブツリーを管理できるようにします。businessUnit はサブツリーのルート(sys_business_unit.name)で、必須かつ空白不可です。includeSubtree の既定値は true です。manageAssignments の既定値は false で、有効にすると保持者はそのサブツリー内でユーザーをポジションに割り当て、権限セットを直接付与できます。委任先が付与できるのは assignablePermissionSets に挙げた権限セットだけです。何も委任しない権限セットでは空のままにしてください。" }, objects: { label: "オブジェクト権限", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 819e8008915..3d9c6e6daa7 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2323,7 +2323,7 @@ export const zhCNMetadataForms: NonNullable = }, adminScope: { label: "委派管理范围", - helpText: "委派管理(ADR-0090 D12):让持有本权限集的人管理一个业务单元子树。businessUnit 是子树的根(sys_business_unit.name),必填且不能为空白;includeSubtree 默认为 true;manageAssignments、manageBindings 和 authorEnvironmentSets 默认为 false;被委派者只能分配 assignablePermissionSets 中列出的权限集。不委派任何管理权的权限集请留空。" + helpText: "委派管理(ADR-0090 D12):让持有本权限集的人管理一个业务单元子树。businessUnit 是子树的根(sys_business_unit.name),必填且不能为空白;includeSubtree 默认为 true;manageAssignments 默认为 false,开启后持有人可在该子树内把人员分配到岗位、并直接授予权限集;被委派者只能分配 assignablePermissionSets 中列出的权限集。不委派任何管理权的权限集请留空。" }, objects: { label: "对象权限", diff --git a/packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts b/packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts index 515ab27f03f..d97746699fe 100644 --- a/packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts +++ b/packages/spec/src/security/admin-scope-binding-authoring-retirement.test.ts @@ -57,8 +57,8 @@ const SCOPE = { assignablePermissionSets: ['sales_user'], } as const; -/** A well-formed permission set carrying {@link SCOPE}. */ -const SET = { name: 'east_admin', label: 'East Admin', adminScope: SCOPE } as const; +/** A well-formed permission set carrying {@link SCOPE} (`objects` is the set's one required key). */ +const SET = { name: 'east_admin', label: 'East Admin', objects: {}, adminScope: SCOPE } as const; // Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; // the key-first house convention is asserted on the issue message itself. @@ -212,7 +212,7 @@ describe('adminScope binding/authoring retirement — the D2 conversion', () => { ...SET, name: 'b_two', adminScope: { ...SCOPE, authorEnvironmentSets: 7 } }, // Already canonical, and a set with no scope: no notice, handed back by reference. { ...SET, name: 'c_three' }, - { name: 'd_four', label: 'Four' }, + { name: 'd_four', label: 'Four', objects: {} }, ], }; const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); From 0221b165f0a4da45d641cdabc9147ac007102753 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:13:27 +0000 Subject: [PATCH 4/4] test(core): mapper pin control carries the body's required keys (#23488) Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .../core/src/security/permission-set-row-definition.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/security/permission-set-row-definition.test.ts b/packages/core/src/security/permission-set-row-definition.test.ts index 0e247d8a19a..0c3f0914523 100644 --- a/packages/core/src/security/permission-set-row-definition.test.ts +++ b/packages/core/src/security/permission-set-row-definition.test.ts @@ -157,7 +157,7 @@ describe('permissionSetDefinitionFromRow', () => { // CONTROL: the identical scope with the keys left in is refused by the // door's schema at the retired key — so the drop above is what makes the // body acceptable, not something else in it. - const kept = { name: 'emea_admin', adminScope: { businessUnit: 'emea', manageBindings: false } }; + const kept = { ...permissionSetDefinitionFromRow({ name: 'emea_admin' }), adminScope: { businessUnit: 'emea', manageBindings: false } }; const refused = PermissionSetSchema.safeParse(kept); expect(refused.success).toBe(false); expect(refused.error?.issues.map((i) => i.path)).toEqual([['adminScope', 'manageBindings']]);