diff --git a/.changeset/24757-retire-position-name-fold.md b/.changeset/24757-retire-position-name-fold.md new file mode 100644 index 00000000000..b9181367378 --- /dev/null +++ b/.changeset/24757-retire-position-name-fold.md @@ -0,0 +1,31 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/core': patch +--- + +fix(plugin-security)!: a permission set that shares a held position's name is no longer granted; declare it in the position's `permissionSets` (#24757) + +Clause-②: yes (narrowing: a holder of position `P` is no longer granted the permission set named `P` unless `P`'s definition names it in `permissionSets`; widened: `@objectstack/core`'s `UserAuthzGrants` and `ResolvedAuthzContext` gain the additive field `directPermissions`) +Metadata-protocol: none +Ruling-ref: #13419 ruling B (`5472608110`); 「甲」 as amended (`6303865025`); order and unlock on #24757 (`6290781571`, `6392096931`); #25799 seat ruling A (claims `6414302166`, `6414315157`) + +ADR-0131 D3/D4: a position grants only the permission sets its definition's `permissionSets` names — the binding is written, not inferred. The security plugin's set resolution still requested every held position's NAME as a permission-set name too (the position-name fold), so a set that happened to share a position's name was granted to its holders without anyone writing the binding. #13419 ruling B retires that channel; HotCRM declared its bindings first (objectstack-ai/hotcrm#2200, released in hotcrm `v6.0.0`). + +What changes: + +- `resolvePermissionSetsForContext` requests only the context's `permissions` and the baseline. A held position's name reaches no set. `dropDeactivatedFoldedSets`, the second activation read that only the fold needed, is gone. +- The `position_name_fold_grant` warning keeps its event name and fields and now says the opposite: the same-named set is **not** granted, and how to grant it (`permissionSets: ['P']`) or to rename one of the two. Its payload carries `granted: false`. It is still logged once per position name per process. +- `describePositionGrants` lists only the sets a position's definition names, so it no longer serves `via: 'name'`. The `'name'` member of `PositionGrantVia` (`@objectstack/spec`) and its UI badge are retired separately in #26022; until then the value is published but never served. +- The delegated-administration gate judges an assignment of position `P` on the sets `P`'s definition names only. A set that only shares `P`'s name is no longer walked for the allowlist, the containment check or the organization boundary, because no assignment grants it. +- `explainAccess` attributes a set to `position:P` when `P`'s definition names it, and says `direct grant` otherwise. When the position definitions cannot be read it says so, rather than guessing either. A set held both through the user's own grant row and through a held position's definition reads `direct grant + position:P`, so revoking the position is not mistaken for removing the access (#25799). +- `@objectstack/core`: `UserAuthzGrants` and `ResolvedAuthzContext` gain `directPermissions: string[]`, the names conferred by the user's own `sys_user_permission_set` rows that apply in the active organization (a set a position definition also names included, a set only a definition names excluded). It is additive and grants nothing; the explainer reads it. Code that builds either object by hand must now set it (`directPermissions: []` when there are no grant rows). +- The lint gate `check-position-name-fold-loaders` and the census script `measure-position-name-fold-census` are deleted. They guarded the fold's only route, which no longer exists. + +**BREAKING** for an environment that relied on a shared name to grant a set. + +- **Before.** A user holding position `sales_manager` was granted the permission set `sales_manager` whether or not the position's definition named it. +- **Now.** That user is granted only the sets the `sales_manager` definition lists in `permissionSets`. The first resolution that would have folded the set logs `position_name_fold_grant` with `granted: false`, naming the position and the fix. +- **Fix.** Name the set in the position: `permissionSets: ['sales_manager']`. If the shared name is a coincidence, nothing needs to change. +- **Cloud ordering.** An environment running HotCRM must be on the HotCRM release that declares its bindings (`v6.0.0` or later) before it takes this release. + + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 272e424827b..bf340c1fd6e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -947,29 +947,6 @@ jobs: continue-on-error: true run: pnpm check:stack-collection-maps - # The vendored HotCRM permissions artifact has NO non-test loader (#13419). - # `security-plugin.ts` folds a POSITION name into the permission-set - # request, so a permission set named after a position is granted with no - # `sys_position_permission_set` row behind it. The #13419 census reports - # exactly two such folds — `sales_rep` and `sales_manager` — and both take - # their permission-set half from a fixture that only one TEST reads. That - # makes them a collision between an example app's position names and a - # test fixture's set names rather than grants any deployment holds, which - # is why the ruling's 物化 worklist is empty in this repo. The empty - # worklist is the thing being pinned: wire that artifact into a real - # composition and this reddens, instead of two live authorization grants - # appearing with nothing said. Its `--self-test` supplies the loader cases - # a clean tree by construction does not contain — without them the - # production zero could equally be a scanner that stopped matching. - # Invoked as `node` rather than `pnpm check:*`: see the GATE INVOCATION - # IDIOM note at the top of this file. ~1s, no spawns. - - name: 'The #13419 name-fold fixture has no non-test loader' - id: the-name-fold-fixture-has-no-non-test-loader - continue-on-error: true - run: | - node scripts/check-position-name-fold-loaders.mjs --self-test - node scripts/check-position-name-fold-loaders.mjs - # The dashboard widget `options` census vs the spec (#12926). The parser's # `CONSUMED_WIDGET_OPTION_KEYS` decides which `options` keys get a # `unconsumed-widget-option` warning, and five of its six members are the diff --git a/packages/cli/test/verify-parent-write-reach.test.ts b/packages/cli/test/verify-parent-write-reach.test.ts index e7c13170614..6ff50b24aa9 100644 --- a/packages/cli/test/verify-parent-write-reach.test.ts +++ b/packages/cli/test/verify-parent-write-reach.test.ts @@ -71,7 +71,9 @@ const PERMISSIONS = [ }, ]; -const POSITIONS = [{ name: 'vpw_manager', label: 'Manager' }]; +// [ADR-0131 D3/D4; #24757] The position names its set: a set that only shares +// a position's name is granted by nothing. +const POSITIONS = [{ name: 'vpw_manager', label: 'Manager', permissionSets: ['vpw_manager'] }]; /** The same app, plus a hook that refuses every edit of a contract with the platform's own code. */ const CONTROL_SOURCE = `{ diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts index 5ab053111d6..2bb94adee65 100644 --- a/packages/core/src/security/assemble-execution-context.test.ts +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -141,6 +141,7 @@ function observable(ctx: any): Record { const baseAuthz = (over: Partial = {}): ResolvedAuthzContext => ({ positions: [], permissions: [], + directPermissions: [], systemPermissions: [], org_user_ids: [], accessible_org_ids: [], diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json index 58ea87c34e7..1e2851dc673 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json @@ -6,6 +6,7 @@ "everyone" ], "permissions": [], + "directPermissions": [], "systemPermissions": [], "org_user_ids": [ "u_empty" @@ -75,6 +76,7 @@ "everyone" ], "permissions": [], + "directPermissions": [], "systemPermissions": [], "org_user_ids": [ "u_multi", @@ -156,6 +158,7 @@ "everyone" ], "permissions": [], + "directPermissions": [], "systemPermissions": [], "org_user_ids": [ "u_lapsed", @@ -241,6 +244,7 @@ "read_all", "base_access" ], + "directPermissions": [], "systemPermissions": [ "record_write" ], @@ -337,6 +341,10 @@ "admin_full_access", "org_tools" ], + "directPermissions": [ + "admin_full_access", + "org_tools" + ], "systemPermissions": [ "manage_users" ], @@ -436,6 +444,9 @@ "low", "organization_admin" ], + "directPermissions": [ + "low" + ], "systemPermissions": [], "org_user_ids": [ "u_ta" @@ -523,6 +534,7 @@ "permissions": [ "ai_seat" ], + "directPermissions": [], "systemPermissions": [], "org_user_ids": [ "u_ai" @@ -602,6 +614,7 @@ "everyone" ], "permissions": [], + "directPermissions": [], "systemPermissions": [], "org_user_ids": [ "u_noai" @@ -676,6 +689,9 @@ "ai_seat", "extra" ], + "directPermissions": [ + "extra" + ], "systemPermissions": [], "org_user_ids": [ "u_seed" @@ -749,6 +765,7 @@ "everyone" ], "permissions": [], + "directPermissions": [], "systemPermissions": [], "org_user_ids": [ "u_lim", @@ -2029,6 +2046,7 @@ "everyone" ], "permissions": [], + "directPermissions": [], "systemPermissions": [], "org_user_ids": [ "u_noorg" diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index e00e84e994a..4afb9cad33a 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -114,6 +114,10 @@ interface Golden { sequentialLegs: number; grants: unknown; queries: RecordedCal * — the last mainline commit before the batch (post-#10982). `sequentialLegs` * is that run's leg count, which equalled its query count because every read * awaited the one before it. + * + * [#25799] `directPermissions` postdates that capture: each fixture's value was + * added from the current resolver, beside the captured envelope it leaves + * unchanged (the names the fixture's own grant rows confer in its tenant). */ const GOLDEN: Record = JSON.parse( readFileSync(new URL('./resolve-authz-context.batch-equivalence.golden.json', import.meta.url), 'utf8'), diff --git a/packages/core/src/security/resolve-authz-context.direct-permissions.test.ts b/packages/core/src/security/resolve-authz-context.direct-permissions.test.ts new file mode 100644 index 00000000000..2b3b0a73c84 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.direct-permissions.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25799] `resolveUserAuthzGrants` reports its own split of the two channels a + * set reaches a user through: `directPermissions` lists the names conferred by + * the user's OWN grant rows (`sys_user_permission_set`, §6), and never a name + * only a held position's definition names (§6a). The explainer reads it to + * tell an assignment row from a position definition. + * + * The rows it lists are the rows §6 confers, by §6's own rules: the row applies + * in the active organization, and its set is one the catalog holds and the + * activation ledger has not switched off. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; +import { resolveUserAuthzGrants } from './resolve-authz-context.js'; +import { makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; + +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +let ambient: Record = {}; +beforeAll(() => { + ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] }; + delete process.env[OWNER_ENV]; + delete process.env[POSTURE_ENV]; + resetPlatformAdminEmailMemo(); +}); +afterAll(() => { + for (const [k, v] of Object.entries(ambient)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + resetPlatformAdminEmailMemo(); +}); + +/** `everyone` (the anchor every member holds) names two sets: one also granted directly below, one not. */ +const definitions = { + permissions: [ + { name: 'tools', systemPermissions: ['cap_tools'] }, + { name: 'elsewhere', systemPermissions: ['cap_elsewhere'] }, + { name: 'both_channels', systemPermissions: ['cap_both'] }, + { name: 'definition_only', systemPermissions: ['cap_definition'] }, + ], + positions: [{ name: 'everyone', permissionSets: ['both_channels', 'definition_only'] }] as Array>, +}; + +/** The user's own grant rows: two in ORG_A, one in ORG_B. */ +const GRANTS = [ + { permission_set: 'tools', organization_id: ORG_A }, + { permission_set: 'both_channels', organization_id: ORG_A }, + { permission_set: 'elsewhere', organization_id: ORG_B }, +]; + +async function resolve(tenantId: string | undefined, ledger: unknown[] = []) { + const ql = bindStaticSecurityCatalog(makeRecordingQl({ + sys_user: [{ id: 'u1', email: 'u1@example.com' }], + sys_member: [{ user_id: 'u1', organization_id: ORG_A, role: 'member' }, { user_id: 'u1', organization_id: ORG_B, role: 'member' }], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: GRANTS.map((g) => ({ user_id: 'u1', ...g })), + sys_permission_set: [], + sys_metadata_activation: ledger, + }), definitions); + return resolveUserAuthzGrants(ql, 'u1', tenantId ? { tenantId } : {}); +} + +describe('[#25799] directPermissions is the resolver\'s own direct channel', () => { + it('lists the sets the user\'s own rows confer in this organization, a set a definition also names included, and no set only a definition names', async () => { + const out = await resolve(ORG_A); + // The control: every one of these is in force for the user. + expect(out.permissions).toEqual(expect.arrayContaining(['tools', 'both_channels', 'definition_only'])); + expect(out.directPermissions).toEqual(['tools', 'both_channels']); + }); + + it('follows the tenant rule: a row of another organization is not a direct grant here, and is one there', async () => { + expect((await resolve(ORG_A)).directPermissions).not.toContain('elsewhere'); + const inB = await resolve(ORG_B); + expect(inB.directPermissions).toEqual(['elsewhere']); + expect(inB.permissions).toContain('elsewhere'); + }); + + it('with no active organization no stored row applies, so nothing is direct while the definition-bound sets stand', async () => { + const out = await resolve(undefined); + expect(out.directPermissions).toEqual([]); + expect(out.permissions).toEqual(expect.arrayContaining(['both_channels', 'definition_only'])); + }); + + it('a set the activation ledger switched off is not listed: the row confers nothing', async () => { + const out = await resolve(ORG_A, [{ metadata_type: 'permission', name: 'tools', package_id: null, active: false }]); + expect(out.permissions).not.toContain('tools'); + expect(out.directPermissions).toEqual(['both_channels']); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index 0cf13e63ff7..e1a64346745 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -636,7 +636,13 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg requestPosture(p); envelopes[p] = await resolveUserAuthzGrants(makeQl(tables()), 'usr_1', { nowMs: NOW }); } - expect(envelopes.single).toEqual(envelopes.isolated); + // [#25799] `directPermissions` names the CHANNEL a set arrives through, + // not the standing: under \`single\` the legacy grant row anchors and so + // applies with no active organization, while walled it applies nowhere + // and the config confers the same set. The standing envelope is compared + // without it. + const standing = ({ directPermissions: _channel, ...rest }: any) => rest; + expect(standing(envelopes.single)).toEqual(standing(envelopes.isolated)); }); } diff --git a/packages/core/src/security/resolve-authz-context.request-rounds.test.ts b/packages/core/src/security/resolve-authz-context.request-rounds.test.ts index 4e74f14828f..82b2b4f6a41 100644 --- a/packages/core/src/security/resolve-authz-context.request-rounds.test.ts +++ b/packages/core/src/security/resolve-authz-context.request-rounds.test.ts @@ -94,6 +94,7 @@ describe('[#10757 leg 4] the Default Organization read and the activation ledger expect(grants).toEqual({ positions: ['platform_admin', 'org_owner', 'everyone'], permissions: [ADMIN_FULL_ACCESS], + directPermissions: [ADMIN_FULL_ACCESS], systemPermissions: expect.any(Array), org_user_ids: ['usr_1'], accessible_org_ids: ['org_default'], diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 9ceabebcf6b..d34d5b8ff94 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -105,6 +105,16 @@ export interface ResolvedAuthzContext { accessToken?: string; positions: string[]; permissions: string[]; + /** + * [#25799] The names in {@link permissions} this principal holds through its OWN + * permission-set grant rows (`sys_user_permission_set`, §6): each row applying in + * this tenant, inside its validity window, naming a set the catalog holds that the + * activation ledger has not switched off. A set a held position's definition also + * names (§6a) is listed too; a set ONLY a definition names is not. It grants + * nothing of its own: it is the resolver's own split of the two channels, read by + * the explainer to tell an assignment row from a position definition. + */ + directPermissions: string[]; systemPermissions: string[]; tabPermissions?: Record; /** Fellow-org user IDs for RLS scoping of identity tables (`id IN (...)`). */ @@ -498,6 +508,7 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise { email: 'human@example.com', positions: [], permissions: ['mcp_agent_data_write'], + directPermissions: [], systemPermissions: [], principalKind: 'agent', onBehalfOf: { userId: 'human_1', principalKind: 'human' }, diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 2c04ce6df9d..7e79913d56f 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -387,7 +387,12 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, ); - const resolve = (userId: string) => resolveUserAuthzGrants(engine, userId, { tenantId: ORG }); + // [#25799] The golden below was recorded before the resolver reported its + // channel split (`directPermissions`); it pins the grants, so the split is + // left out here and pinned where it was added + // (`resolve-authz-context.direct-permissions.test.ts`, core). + const resolve = (userId: string) => resolveUserAuthzGrants(engine, userId, { tenantId: ORG }) + .then(({ directPermissions: _channel, ...grants }) => grants); const admin = await resolve('usr_admin'); const orgAdmin = await resolve('usr_orgadmin'); const member = await resolve('usr_member'); diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts index 4a4a5e342a0..bbd3083377c 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts @@ -228,8 +228,8 @@ beforeAll(async () => { await engine.syncSchemas(); // The two delegators exist as users, so the on-behalf-of link resolves rather - // than failing closed. Each holds the manager's set through a stored position - // row owned by ORG — the organization the delegated request runs in, the only + // than failing closed. Each holds the manager's set through a stored direct + // grant row owned by ORG — the organization the delegated request runs in, the only // one a grant row applies in (ADR-0131 D10: an organization-less row applies // nowhere); only DELEGATOR_MEMBER is an organization member, so only it // carries the floor. @@ -243,12 +243,12 @@ beforeAll(async () => { // organization named here. DELEGATOR_UNBOUND's row is ORG's without a // membership: under `single` the session's organization is kept without one, // and no `sys_member` row means no `org_member` floor. - const positions = (engine as any).getDriver('sys_user_position'); + const grants = (engine as any).getDriver('sys_user_permission_set'); for (const row of [ - { id: 'up_unbound', user_id: DELEGATOR_UNBOUND, position: MANAGER_SET.name, organization_id: ORG }, - { id: 'up_member', user_id: DELEGATOR_MEMBER, position: MANAGER_SET.name, organization_id: ORG }, + { id: 'ups_unbound', user_id: DELEGATOR_UNBOUND, permission_set: MANAGER_SET.name, organization_id: ORG }, + { id: 'ups_member', user_id: DELEGATOR_MEMBER, permission_set: MANAGER_SET.name, organization_id: ORG }, ]) { - await positions.create('sys_user_position', row); + await grants.create('sys_user_permission_set', row); } let security: unknown; diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts index 76c5ad67cfc..0787ed7f84f 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts @@ -180,14 +180,14 @@ beforeAll(async () => { id, name: id, email: `${id}@omd.test`, })), SYS); await engine.insert('sys_organization', [{ id: ORG, name: 'OMD', slug: 'omd' }], SYS); - // The delegator holds the requester's set through a stored position row + // The delegator holds the requester's set through a stored direct grant row // owned by ORG, so the on-behalf-of link resolves to the same grants the // owner holds — and NO `sys_member` row, so no ownership floor binds the // delegator's pass: the only thing left to refuse a delegated update is the // master gate, which is the pin (see the chain-middle-master suite for why // the row lands straight through the driver). - await (engine as any).getDriver('sys_user_position').create('sys_user_position', { - id: 'up_delegator', user_id: DELEGATOR, position: REQUESTER_SET.name, organization_id: ORG, + await (engine as any).getDriver('sys_user_permission_set').create('sys_user_permission_set', { + id: 'ups_delegator', user_id: DELEGATOR, permission_set: REQUESTER_SET.name, organization_id: ORG, }); let securityService: unknown; 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 b8e98052876..d2bd5d07ee6 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -1113,7 +1113,8 @@ describe('DelegatedAdminGate — granted_by is the writer on every admitted inse * (a capability nobody declares — condition c), and the shipped names the ADR * keeps outside. Positions: `in_pos` → [in_set], `mixed_pos` → [in_set, * wild_set], `ghost_pos` → [missing_set], `self_named` → [in_set] beside a - * permission set of the position's own name that carries a platform capability. + * permission set of the position's own name that carries a platform capability + * — which no assignment grants (#24757), so it is not judged. */ function makeOrganizationHarness() { const tables: Record = { @@ -1277,10 +1278,11 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am await deniedOf(() => write(o.ctxOf(principal), 'sys_user_permission_set', 'delete', { id: 'ups_a_wild' })); }); - it("counts the permission set of the position's own name, which the resolver also grants", async () => { - const err = await deniedOf(() => write(o.ctxOf(principal), 'sys_user_position', 'insert', - { user_id: 'u_member', position: 'self_named' })); - expect(err.message).toMatch(/position 'self_named' carries permission set 'self_named'.*condition \(c\).*'manage_users'/); + it("does not count a set that only shares the position's name: no assignment grants it (#24757)", async () => { + // `self_named`'s own-name set carries a platform capability and would + // fail condition (c); the position lists only `in_set`, which is inside. + await expect(write(o.ctxOf(principal), 'sys_user_position', 'insert', + { user_id: 'u_member', position: 'self_named' })).resolves.toBeUndefined(); }); it('refuses a position that carries organization_admin, and a position the catalog does not hold', async () => { @@ -1360,7 +1362,7 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am isTenantAdmin: false, scopes: [], placeableBusinessUnitIds: ['bu_a1'], - assignablePositions: ['in_pos'], + assignablePositions: ['in_pos', 'self_named'], }); } }); @@ -1386,14 +1388,16 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am }); // --------------------------------------------------------------------------- -// [ADR-0090 D12, amended 2026-10-17] The delegated walk counts the own-name set +// [ADR-0090 D12, amended 2026-10-17; #24757] The delegated walk judges the +// listed sets, never a set that only shares the position's name // --------------------------------------------------------------------------- /** - * The sets a position carries are every set an assignment of it grants: the - * names its definition lists and, while the resolver folds held position names - * into the requested set names, the permission set of the position's own name. - * The delegated branch judges all of them — in the allowlist check, in strict + * The sets a position carries are every set an assignment of it grants: + * exactly the names its definition lists. The permission set of the position's + * own name was judged too while the resolver folded held position names into + * the requested set names; #24757 retired the fold, so that set is granted by + * nothing and judged by nothing — in the allowlist check, in strict * containment, in the self-delegation rule against handing out administration * and in the delegable-scope listing. * @@ -1401,7 +1405,11 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am * `lead_pos` → [field_user] beside a set named `lead_pos` that carries the wide * delegate's own scope; `clerk_pos` → [field_user] with no set of its name; * `duty_pos` (delegatable) → [field_user] beside a set named `duty_pos` that - * carries an adminScope; `duty_plain` (delegatable) → [field_user]. + * carries an adminScope; `duty_plain` (delegatable) → [field_user]. The + * controls that keep the admissions from being a gate judging nothing: + * `audit_pos` → [audit_set], which neither delegate allowlists, and + * `duty_admin` (delegatable) → [duty_admin_set], a LISTED set carrying an + * adminScope. */ function makeOwnNameHarness() { const tables: Record = { @@ -1416,6 +1424,7 @@ function makeOwnNameHarness() { // holdings are stamped with the one the delegation below runs in. { id: 'boss_duty', user_id: 'u_boss', position: 'duty_pos', organization_id: DELEGATION_ORG }, { id: 'boss_plain', user_id: 'u_boss', position: 'duty_plain', organization_id: DELEGATION_ORG }, + { id: 'boss_admin', user_id: 'u_boss', position: 'duty_admin', organization_id: DELEGATION_ORG }, ], sys_business_unit_member: [{ id: 'm1', business_unit_id: 'bu_es', user_id: 'u_east_1' }], sys_user: [{ id: 'u_east_1' }, { id: 'u_boss' }, { id: 'u_deleg' }], @@ -1444,6 +1453,8 @@ function makeOwnNameHarness() { { name: 'rep_pos', objects: { crm_account: { allowRead: true, allowEdit: true } } }, { name: 'lead_pos', objects: {}, adminScope: scope(WIDE_ALLOW) }, { name: 'duty_pos', objects: {}, adminScope: scope(['field_user']) }, + { name: 'audit_set', objects: { crm_account: { allowRead: true } } }, + { name: 'duty_admin_set', objects: {}, adminScope: scope(['field_user']) }, ], positions: [ { name: 'rep_pos', permissionSets: ['field_user'] }, @@ -1451,12 +1462,14 @@ function makeOwnNameHarness() { { name: 'clerk_pos', permissionSets: ['field_user'] }, { name: 'duty_pos', permissionSets: ['field_user'], delegatable: true }, { name: 'duty_plain', permissionSets: ['field_user'], delegatable: true }, + { name: 'audit_pos', permissionSets: ['audit_set'] }, + { name: 'duty_admin', permissionSets: ['duty_admin_set'], delegatable: true }, ], }); const SETS: Record = { - // Allowlists the sets the positions LIST, not their own-name sets. + // Allowlists the set the positions LIST, not their own-name sets. narrow: [{ name: 'east_admin', objects: {}, adminScope: scope(['field_user']) }], - // Also allowlists the own-name sets. + // Also allowlists the own-name sets — which no assignment hands out. wide: [{ name: 'east_admin_wide', objects: {}, adminScope: scope(WIDE_ALLOW) }], member: [{ name: 'member_default', objects: {} }], }; @@ -1475,63 +1488,60 @@ function makeOwnNameHarness() { return { gate, tables, ctxOf, assign, offered }; } -describe("DelegatedAdminGate — a delegate is judged on the permission set of the position's own name (ADR-0090 D12 as amended 2026-10-17)", () => { +describe("DelegatedAdminGate — a set that only shares the position's name is not judged (ADR-0090 D12 as amended 2026-10-17; #24757)", () => { let n: ReturnType; beforeEach(() => { n = makeOwnNameHarness(); }); - it("refuses an assignment whose own-name set is outside the allowlist — 403 PERMISSION_DENIED, naming the set", async () => { - const err = await deniedOf(() => n.assign('narrow', 'rep_pos')); - expect(err.message).toMatch(/position 'rep_pos' distributes permission set 'rep_pos' \(the permission set of the position's own name.*not in the scope's allowlist/); - expect(err.details).toMatchObject({ position: 'rep_pos' }); - }); - - it('CONTROL: admits the same assignment for a delegate whose allowlist covers the own-name set too', async () => { + it("admits an assignment whose own-name set is outside the allowlist: the assignment does not grant it", async () => { const row: any = { user_id: 'u_east_1', position: 'rep_pos', business_unit_id: 'bu_es' }; - await expect(n.gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: n.ctxOf('wide') })) + await expect(n.gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: n.ctxOf('narrow') })) .resolves.toBeUndefined(); - expect(row.granted_by).toBe('u_wide'); + expect(row.granted_by).toBe('u_narrow'); + }); + + it("CONTROL: refuses an assignment whose LISTED set is outside the allowlist — 403 PERMISSION_DENIED, naming the set", async () => { + const err = await deniedOf(() => n.assign('narrow', 'audit_pos')); + expect(err.message).toMatch(/position 'audit_pos' distributes permission set 'audit_set', which is not in the scope's allowlist/); + expect(err.details).toMatchObject({ position: 'audit_pos' }); }); it('a position with no own-name set in the catalog is judged on its listed sets alone, as before', async () => { await expect(n.assign('narrow', 'clerk_pos')).resolves.toBeUndefined(); }); - it('removing an assignment is judged on the same sets: the narrow delegate may not evict one granting a set outside its allowlist', async () => { - const err = await deniedOf(() => n.gate.assert({ - object: 'sys_user_position', operation: 'delete', options: { where: { id: 'held_rep' } }, context: n.ctxOf('narrow'), - })); - expect(err.message).toMatch(/permission set 'rep_pos'.*not in the scope's allowlist/); + it('removing an assignment is judged on the same sets: the narrow delegate may evict one whose own-name set it does not allowlist', async () => { await expect(n.gate.assert({ - object: 'sys_user_position', operation: 'delete', options: { where: { id: 'held_rep' } }, context: n.ctxOf('wide'), + object: 'sys_user_position', operation: 'delete', options: { where: { id: 'held_rep' } }, context: n.ctxOf('narrow'), })).resolves.toBeUndefined(); }); - it('an own-name set carrying an adminScope is held to strict containment, allowlisted or not', async () => { - const err = await deniedOf(() => n.assign('wide', 'lead_pos')); - expect(err.message).toMatch(/granting set 'lead_pos' would hand out an adminScope not strictly contained by yours/); + it('an own-name set carrying an adminScope is not held to strict containment: the assignment does not hand it out', async () => { + await expect(n.assign('wide', 'lead_pos')).resolves.toBeUndefined(); }); it('the delegable-scope listing offers exactly the positions the write admits', async () => { - expect(await n.offered('narrow')).toEqual(['clerk_pos', 'duty_plain']); - expect(await n.offered('wide')).toEqual(['clerk_pos', 'duty_plain', 'rep_pos']); + const all = ['audit_pos', 'clerk_pos', 'duty_admin', 'duty_plain', 'duty_pos', 'lead_pos', 'rep_pos']; + expect(await n.offered('narrow')).toEqual(all.filter((p) => p !== 'audit_pos' && p !== 'duty_admin')); + expect(await n.offered('wide')).toEqual(all.filter((p) => p !== 'audit_pos' && p !== 'duty_admin')); for (const principal of ['narrow', 'wide']) { const offered = await n.offered(principal); - for (const position of ['rep_pos', 'lead_pos', 'clerk_pos', 'duty_pos', 'duty_plain']) { + for (const position of all) { const admitted = await n.assign(principal, position).then(() => true, () => false); expect(admitted, `${principal} · ${position}`).toBe(offered.includes(position)); } } }); - it('a delegatable position whose own-name set carries an adminScope cannot be self-delegated', async () => { + it('a delegatable position whose own-name set carries an adminScope may be self-delegated: it distributes no administration', async () => { const delegation = (position: string) => n.gate.assert({ object: 'sys_user_position', operation: 'insert', context: { userId: 'u_boss', positions: [], principal: 'member', organizationId: DELEGATION_ORG }, data: { user_id: 'u_deleg', position, delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'cover' }, }); - const err = await deniedOf(() => delegation('duty_pos')); - expect(err.message).toMatch(/position 'duty_pos' distributes the admin set 'duty_pos' \(the permission set of the position's own name.*administration cannot be self-delegated/); - expect(err.details).toMatchObject({ position: 'duty_pos', permissionSet: 'duty_pos' }); - // CONTROL: the same holder delegates a delegatable position with no such set. - await expect(delegation('duty_plain')).resolves.toBeUndefined(); + await expect(delegation('duty_pos')).resolves.toBeUndefined(); + // CONTROL: a delegatable position whose LISTED set carries an adminScope + // still cannot be self-delegated. + const err = await deniedOf(() => delegation('duty_admin')); + expect(err.message).toMatch(/position 'duty_admin' distributes the admin set 'duty_admin_set' — administration cannot be self-delegated/); + expect(err.details).toMatchObject({ position: 'duty_admin', permissionSet: 'duty_admin_set' }); }); }); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 1c8aa077716..d7a9a9537e7 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -26,7 +26,7 @@ * (manageAssignments), inside their BU subtree, handing out only * allowlisted sets, with STRICT containment whenever the granted set itself * carries an adminScope. A position assignment hands out every set it - * grants — the definition's list and the set of the position's own name. The rows it governs are the two assignment tables + * grants — the sets its definition lists. The rows it governs are the two assignment tables * (`sys_user_position`, `sys_user_permission_set`); the binding and set * authoring rows retired with the catalog row objects (ADR-0131 D3), so a * position's sets and a set's definition are written through the metadata @@ -279,17 +279,6 @@ interface BoundSet { adminScope?: unknown; } -/** - * What a refusal adds when the set it names is the one of the position's own - * name: the position's definition need not list it, so the reader is told why - * it was judged at all. - */ -function ownNameNote(positionName: string, setName: string): string { - return setName === positionName - ? " (the permission set of the position's own name, which an assignment of the position also grants)" - : ''; -} - function rowsOf(opCtx: any): any[] { const d = opCtx?.data; if (Array.isArray(d)) return d; @@ -638,7 +627,7 @@ export class DelegatedAdminGate { const placeableBusinessUnitIds = [...new Set(placing.flatMap((h) => [...h.subtree]))]; // A position is assignable when at least ONE placing scope allowlists - // every set it distributes — the set of its own name included — exactly + // every set it distributes — exactly // `assertAssignmentWrite`'s test, containment check included. const assignablePositions: string[] = []; if (placing.length > 0) { @@ -823,12 +812,12 @@ export class DelegatedAdminGate { } /** - * [ADR-0090 D12, amended 2026-10-17] Every permission set an assignment of - * `positionName` grants: the names its definition's `permissionSets` lists, - * and — while the resolver folds held position names into the requested set - * names (`security-plugin.ts`, `[...positions, ...explicitPermissionSets]`) — - * the set of the position's own name, when the catalog holds one. `null` - * when the catalog does not hold the position. An unreadable catalog throws + * [ADR-0090 D12, amended 2026-10-17; ADR-0131 D3/D4] Every permission set an + * assignment of `positionName` grants: exactly the names its definition's + * `permissionSets` lists. A set that merely shares the position's name is not + * among them — the resolver no longer folds held position names into the + * request (#24757) — so it is not walked. `null` when the catalog does not + * hold the position. An unreadable catalog throws * (`AuthzStoreUnavailableError`) and the write is refused with it. * * The one answer to "which sets does this position carry" for both branches: @@ -842,8 +831,7 @@ export class DelegatedAdminGate { const listed = Array.isArray(position.definition.permissionSets) ? (position.definition.permissionSets as unknown[]).filter((n): n is string => typeof n === 'string' && n !== '') : []; - const ownName = (await catalog.resolve('permission', positionName)) ? [positionName] : []; - return [...new Set([...listed, ...ownName])]; + return [...new Set(listed)]; } /** @@ -1048,11 +1036,11 @@ export class DelegatedAdminGate { } // 6. A delegatable position must not distribute administration — through - // any set an assignment of it grants, the own-name set included. + // any set an assignment of it grants. const boundSets = await this.setsBoundToPosition(positionName); for (const b of boundSets) { if (parseMaybeJson((b as any).admin_scope ?? (b as any).adminScope)) { - deny(`position '${positionName}' distributes the admin set '${b.name}'${ownNameNote(positionName, b.name)} — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name }); + deny(`position '${positionName}' distributes the admin set '${b.name}' — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name }); } } } @@ -1142,11 +1130,11 @@ export class DelegatedAdminGate { if (!prevBu) return 'the existing assignment is unanchored (no business_unit_id) — only a tenant admin may modify it'; if (!s.subtree.has(String(prevBu))) return `the existing assignment's business unit '${prevBu}' is outside the delegated subtree`; } - // Every set the position distributes must be allowlisted — the set of - // the position's own name included ({@link setsBoundToPosition}). + // Every set the position distributes must be allowlisted + // ({@link setsBoundToPosition}). for (const bound of boundSets) { if (!s.scope.assignablePermissionSets.includes(bound.name)) { - return `position '${positionName}' distributes permission set '${bound.name}'${ownNameNote(positionName, bound.name)}, which is not in the scope's allowlist`; + return `position '${positionName}' distributes permission set '${bound.name}', which is not in the scope's allowlist`; } const contained = this.assertScopeGrantContainment(bound, held, /*dryRun*/ true); if (contained) return contained; @@ -1434,17 +1422,15 @@ export class DelegatedAdminGate { * position distributes, as the delegated walk judges them: every set an * assignment of it grants — {@link setsCarriedByPosition}, the one answer the * organization-level branch reads too: the names its DEFINITION carries - * (`PositionSchema.permissionSets`) and, while the resolver folds held - * position names into the requested set names, the permission set of the - * position's own name when the catalog holds one — each with the - * `adminScope` of the catalog's definition of that name. That is the binding + * (`PositionSchema.permissionSets`), each with the `adminScope` of the + * catalog's definition of that name. That is the binding * the authorization resolver grants through, read from the same security * catalog, never from a `sys_position_permission_set` junction row (which * grants nothing since the resolver stopped reading it). So the allowlist * check, the D12 strict containment, the self-delegation rule against * distributing administration and the assignable-positions listing all judge - * exactly what an assignment will grant — a set granted by the position's - * name alone included. + * exactly what an assignment will grant. A set that only shares the + * position's name is granted by nothing (#24757), so it is not judged. * * A name the definition carries that the catalog does not hold is still * returned, with no `adminScope`: it grants nothing today, but the binding is diff --git a/packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts b/packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts index 0ac3b993baf..3b46e2c6e8a 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts @@ -1,10 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0090 D12, amended 2026-10-17] A delegate's position assignment is judged - * on EVERY permission set the assignment grants — the names the position's - * definition lists and, while the resolver folds held position names into the - * requested set names, the permission set of the position's own name. + * [ADR-0090 D12, amended 2026-10-17; ADR-0131 D3/D4; #24757] A delegate's + * position assignment is judged on EVERY permission set the assignment grants — + * exactly the names the position's definition lists. The permission set of the + * position's own name was judged too while the resolver folded held position + * names into the requested set names; #24757 retired that fold, so the + * own-name set is granted by nothing and judged by nothing. * * Measured on a REAL `ObjectQL` engine over a real SQL driver with the REAL * `SecurityPlugin` registered on it (the delegated-administration gate, the CRUD @@ -15,8 +17,8 @@ * and names the set it judged; every admission reads the stored row back. * * The unit pins beside the gate (`delegated-admin-gate.test.ts`) hold the same - * rule on a fake engine; this file holds it where the catalog, the resolver's - * fold and the gate meet. + * rule on a fake engine; this file holds it where the catalog, the resolver and + * the gate meet: the gate admits exactly what the resolver grants. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -44,13 +46,17 @@ const CRUD = { sys_user_position: { allowRead: true, allowCreate: true, allowEdi /** * The catalog. `field_rep` and `field_lead` each list only `field_user`, and - * the catalog also holds a permission set of each one's own name; `field_clerk` - * lists `field_user` and has no set of its own name. + * the catalog also holds a permission set of each one's own name — which their + * definitions do not list, so an assignment grants neither; `field_clerk` + * lists `field_user` and has no set of its own name; `field_auditor` lists + * `field_audit`, which neither delegate allowlists. */ const PERMISSIONS = [ ...defaultPermissionSets, { name: 'field_user', label: 'Field user', objects: { sys_user: { allowRead: true } } }, { name: 'field_rep', label: 'Field rep (own name)', objects: { sys_user: { allowRead: true, allowEdit: true } } }, + // Listed by `field_auditor`, allowlisted by neither delegate. + { name: 'field_audit', label: 'Field audit', objects: { sys_user: { allowRead: true } } }, // Carries an adminScope equal to the wide delegate's own: not strictly contained by it. { name: 'field_lead', label: 'Field lead (own name)', objects: CRUD, adminScope: eastScope(['field_user', 'field_rep', 'field_lead']) }, // Narrow: allowlists the sets the positions LIST, not their own-name sets. @@ -62,6 +68,7 @@ const POSITIONS = [ { name: 'field_rep', label: 'Field rep', permissionSets: ['field_user'] }, { name: 'field_lead', label: 'Field lead', permissionSets: ['field_user'] }, { name: 'field_clerk', label: 'Field clerk', permissionSets: ['field_user'] }, + { name: 'field_auditor', label: 'Field auditor', permissionSets: ['field_audit'] }, ]; type Posture = 'single' | 'group' | 'isolated'; @@ -160,28 +167,32 @@ async function storedPositions(h: Awaited>): Promise r.position).sort(); } -describe('[ADR-0090 D12, amended 2026-10-17] a delegate is judged on every set a position assignment grants — three postures', () => { +describe('[ADR-0090 D12, amended 2026-10-17; #24757] a delegate is judged on exactly the sets a position assignment grants — three postures', () => { for (const posture of POSTURES) { describe(`posture '${posture}'`, () => { - it("ground truth: an assignment of the position grants the permission set of the position's own name", async () => { + it("ground truth: an assignment of the position does NOT grant the permission set of the position's own name", async () => { const h = await boot(posture); const sets = await (h.plugin as any).resolvePermissionSetsForContext( { userId: 'u_target', positions: ['field_rep'], permissions: ['field_user'], tenantId: ORG_A, accessible_org_ids: [ORG_A] }); - expect(sets.map((s: any) => s.name)).toEqual(expect.arrayContaining(['field_user', 'field_rep'])); + const names = sets.map((s: any) => s.name); + expect(names).toContain('field_user'); + expect(names).not.toContain('field_rep'); }); - it("a delegate whose allowlist lacks the position's own-name set is refused 403, naming that set", async () => { + it("a delegate whose allowlist lacks the position's own-name set is admitted: that set is granted by nothing", async () => { const h = await boot(posture); - const r = await assign(h, NARROW, 'field_rep'); - expect([r.status, r.code], r.message).toEqual([403, 'PERMISSION_DENIED']); - expect(r.message).toMatch(/position 'field_rep' distributes permission set 'field_rep'.*not in the scope's allowlist/); - expect(await storedPositions(h)).toEqual([]); + expect(await assign(h, NARROW, 'field_rep')).toEqual({ status: 200 }); + expect(await storedPositions(h)).toEqual(['field_rep']); }); - it('CONTROL: a delegate whose allowlist also covers the own-name set is admitted', async () => { + it('CONTROL: an assignment whose LISTED set is outside the allowlist is still refused 403, naming that set', async () => { + // The gate has teeth: the admissions above are not a gate judging + // nothing at all. const h = await boot(posture); - expect(await assign(h, WIDE, 'field_rep')).toEqual({ status: 200 }); - expect(await storedPositions(h)).toEqual(['field_rep']); + const r = await assign(h, NARROW, 'field_auditor'); + expect([r.status, r.code], r.message).toEqual([403, 'PERMISSION_DENIED']); + expect(r.message).toMatch(/position 'field_auditor' distributes permission set 'field_audit'.*not in the scope's allowlist/); + expect(await storedPositions(h)).toEqual([]); }); it('a position with no own-name set in the catalog is judged on its listed sets alone, as before', async () => { @@ -190,12 +201,10 @@ describe('[ADR-0090 D12, amended 2026-10-17] a delegate is judged on every set a expect(await storedPositions(h)).toEqual(['field_clerk']); }); - it('an own-name set that carries an adminScope is held to strict containment', async () => { + it('an own-name set that carries an adminScope is not judged for containment: an assignment does not hand it out', async () => { const h = await boot(posture); - const r = await assign(h, WIDE, 'field_lead'); - expect([r.status, r.code], r.message).toEqual([403, 'PERMISSION_DENIED']); - expect(r.message).toMatch(/granting set 'field_lead' would hand out an adminScope not strictly contained by yours/); - expect(await storedPositions(h)).toEqual([]); + expect(await assign(h, WIDE, 'field_lead')).toEqual({ status: 200 }); + expect(await storedPositions(h)).toEqual(['field_lead']); }); it("the delegable-scope report offers one of this catalog's positions exactly when the write admits it", async () => { @@ -207,8 +216,8 @@ describe('[ADR-0090 D12, amended 2026-10-17] a delegate is judged on every set a const report = await security.delegatedAdminGate.describeDelegableScope(sets, context); return (report.assignablePositions as string[]).filter((n) => fixture.includes(n)).sort(); }; - expect(await offered(NARROW)).toEqual(['field_clerk']); - expect(await offered(WIDE)).toEqual(['field_clerk', 'field_rep']); + expect(await offered(NARROW)).toEqual(['field_clerk', 'field_lead', 'field_rep']); + expect(await offered(WIDE)).toEqual(['field_clerk', 'field_lead', 'field_rep']); for (const [label, context] of [['narrow', NARROW], ['wide', WIDE]] as const) { const listed = await offered(context); for (const position of fixture) { diff --git a/packages/plugins/plugin-security/src/describe-position-grants.test.ts b/packages/plugins/plugin-security/src/describe-position-grants.test.ts index f1f80743280..21f399a00b4 100644 --- a/packages/plugins/plugin-security/src/describe-position-grants.test.ts +++ b/packages/plugins/plugin-security/src/describe-position-grants.test.ts @@ -7,9 +7,11 @@ * * The pin is the AGREEMENT with enforcement: a position's described sets are * exactly the sets `resolvePermissionSetsForContext` grants a holder of that - * position alone (no baseline), the same-named set the position-name fold grants - * included (`via: 'name'`) — the set #24757 found in force and invisible. And - * the holder count is served only to a caller who may read the assignment rows. + * position alone (no baseline): the sets its definition names, each `via: + * 'definition'`. A set that merely shares the position's name is granted by + * nothing since #24757 retired the position-name fold, so it is not listed, and + * `via: 'name'` is never served. And the holder count is served only to a + * caller who may read the assignment rows. */ import { describe, it, expect, vi } from 'vitest'; @@ -28,13 +30,13 @@ const SETS: PermissionSet[] = [ set('member_default'), set('crm_sales_user'), set('forecasting'), - // Same name as the position: granted by the fold, declared by no definition. + // Same name as the position, declared by no definition: granted by nothing. set('sales_manager'), - // Same name as a position that DECLARES it: no fold, a declared binding. + // Same name as a position that DECLARES it: a declared binding. set('service_agent'), - // Same name as a position, switched off in the ledger: the fold drops it. + // Same name as a position, switched off in the ledger, declared by none. set('marketing_user'), - // Same name as the guest anchor: never folded onto the anonymous principal. + // Same name as the guest anchor, declared by none. set('guest'), ]; @@ -116,26 +118,27 @@ const byPosition = (summaries: PositionGrantSummary[]) => const NAMES = POSITIONS.map((p) => p.name); describe('[ADR-0131 D3/D4] describePositionGrants — what a position grants, as enforced', () => { - it('names each set in force and how it arrives, the same-named fold grant included', async () => { + it('names each set its definition grants, in force, and no set that only shares its name', async () => { const { svc } = await boot(); const out = byPosition(await svc.describePositionGrants!(NAMES, { isSystem: true, tenantId: ORG } as any)); expect(out.sales_rep.permissionSets).toEqual([{ name: 'crm_sales_user', via: 'definition' }]); - // The #24757 case: the set named after the position is in force though the - // definition does not list it — so the list says so, as `via: 'name'`. + // The #24757 case: a set named after the position exists, and the + // definition does not list it — so it is granted by nothing, and not listed. // (In the evaluator's resolution order, which is the catalog's.) expect(out.sales_manager.permissionSets).toEqual([ { name: 'crm_sales_user', via: 'definition' }, { name: 'forecasting', via: 'definition' }, - { name: 'sales_manager', via: 'name' }, ]); - // Declared under its own name: a declaration, not a fold. + // Declared under its own name: a declaration, granted through it. expect(out.service_agent.permissionSets).toEqual([{ name: 'service_agent', via: 'definition' }]); - // The fold's set is switched off: the fold grants nothing. + // Shares its name with a set, declares none: nothing. expect(out.marketing_user.permissionSets).toEqual([]); // A switched-off position grants nothing at all. expect(out.switched_off.permissionSets).toEqual([]); - // The anonymous principal requests no position name. + // The anonymous principal's anchor declares nothing here. expect(out.guest.permissionSets).toEqual([]); + // `via: 'name'` is never served. + expect(Object.values(out).flatMap((s) => s.permissionSets).filter((p) => p.via !== 'definition')).toEqual([]); }); it('agrees, position by position, with the sets the resolver grants a holder of that position alone', async () => { diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index be41fcadee0..477fe4ec3ac 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -1029,6 +1029,8 @@ describe('buildContextForUser', () => { userId: 'u2', positions: ['hr_specialist', 'everyone'], permissions: ['payroll_reader'], + // [#25799] The resolver's channel split: `payroll_reader` is u2's own grant row. + directPermissions: ['payroll_reader'], // [#6352] The resolver's full envelope now reaches the panel. These four // were MISSING from the hand-written mirror, which is why an explanation // could disagree with enforcement — see the parity suite below. diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 2cdb05342d4..4e5d0b7d244 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -739,6 +739,7 @@ export async function buildContextForUser( userId, positions: grants.positions, permissions: grants.permissions, + directPermissions: grants.directPermissions, systemPermissions: grants.systemPermissions, org_user_ids: grants.org_user_ids, accessible_org_ids: grants.accessible_org_ids, @@ -1245,6 +1246,31 @@ function objectDefaultRowScopeSource( ); } +/** + * [ADR-0131 D3/D4; #24757] For each permission set a held position's + * DEFINITION names in `permissionSets`, the first such position in held order — + * the one channel a position grants through. `undefined` when the security + * catalog cannot be read: the attribution then says it cannot tell a + * position's set from a direct grant, rather than guessing either. + */ +async function positionBindingsOf(deps: ExplainEngineDeps, positions: readonly string[]): Promise | undefined> { + try { + const catalog = securityCatalogReaderOf(deps.ql); + if (!catalog) return undefined; + const boundBy = new Map(); + for (const position of positions) { + const entry = await catalog.resolve('position', position); + const listed = (entry?.definition as { permissionSets?: unknown } | undefined)?.permissionSets; + for (const name of Array.isArray(listed) ? listed : []) { + if (typeof name === 'string' && name !== '' && !boundBy.has(name)) boundBy.set(name, position); + } + } + return boundBy; + } catch { + return undefined; + } +} + /** * [#25234 ruling A, rider 3] The permission sets that let their holders create * or edit rows of the roster object `object`: whoever can write a roster row @@ -2453,12 +2479,35 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: // and it is attributed by that name (`external-baseline.ts`). const baselineNames = humanBaselineFor(positions, deps.baselinePermissionSets); const externalBaseline = holdsExternalPosition(positions); + // [ADR-0131 D3/D4; #24757] A set reaches a principal through a position only + // when that position's DEFINITION names it — never by sharing the position's + // name, a channel the resolver no longer has. Both definition-bound and + // directly assigned sets arrive as `context.permissions`, so which one a set + // is gets read from the held positions' definitions. + // + // [#25799] A set can arrive through both channels: the user's own grant row + // AND a held position's definition. The resolver's own split of the two + // (`directPermissions`, `resolveUserAuthzGrants` §6) says which, so such a + // set names both, and revoking the position is not read as removing it. A + // context the resolver did not build carries no split, and a set a held + // definition names is then attributed to that position alone — unless the + // caller marked the split as one it tried and could not read + // (`directPermissionsUnread`), which is said rather than guessed. + const boundBy = await positionBindingsOf(deps, positions); + const direct: readonly string[] = Array.isArray(context?.directPermissions) ? context.directPermissions : []; + const directUnread = context?.directPermissionsUnread === true; const viaOf = (name: string): string => { if (baselineNames.includes(name)) { return externalBaseline ? 'external baseline (org_external)' : 'additive baseline (ADR-0090 D5)'; } - if (positions.includes(name)) return `position:${name}`; - if ((context?.permissions ?? []).includes(name)) return 'direct grant'; + const position = boundBy?.get(name); + if (position !== undefined) { + if (direct.includes(name)) return `direct grant + position:${position}`; + return directUnread ? `position:${position} (whether a direct grant also names it could not be read)` : `position:${position}`; + } + if ((context?.permissions ?? []).includes(name)) { + return boundBy ? 'direct grant' : 'direct grant or position definition (the position definitions could not be read)'; + } return 'resolved'; }; // [ADR-0091 D2 / ADR-0049 / commit 42b05af89] Held-but-dropped grant rows (populated by diff --git a/packages/plugins/plugin-security/src/explain-position-set-attribution.test.ts b/packages/plugins/plugin-security/src/explain-position-set-attribution.test.ts new file mode 100644 index 00000000000..1d7b0b20200 --- /dev/null +++ b/packages/plugins/plugin-security/src/explain-position-set-attribution.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4; #24757] The explain panel attributes a permission set to a + * position exactly when that position's DEFINITION names it. + * + * Both a position's definition-bound sets and the directly assigned ones reach + * the explainer as `context.permissions` (§6b of `resolveUserAuthzGrants`), so + * the attribution reads the held positions' definitions to tell them apart. It + * used to read the position NAMES instead — the position-name fold's view — + * which labelled a definition-bound set "direct grant" and a direct grant that + * happened to share a held position's name `position:NAME`. #24757 retired the + * fold; this pins the attribution that replaces it, and what it says when the + * definitions cannot be read. + * + * [#25799] A set held through BOTH channels names both, read from the + * resolver's own split (`directPermissions`), so the direct row is not hidden + * behind the position. + */ + +import { describe, it, expect } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { PermissionEvaluator } from './permission-evaluator'; +import { explainAccess, buildContextForUser, type ExplainEngineDeps } from './explain-engine'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const set = (name: string) => PermissionSetSchema.parse({ name, objects: { leave_request: { allowRead: true } } }); +const BOUND = set('crm_sales_user'); +const DIRECT = set('forecasting'); +/** Directly assigned, and named like a position the principal holds whose definition does not list it. */ +const NAMESAKE = set('sales_manager'); + +const SCHEMA = { name: 'leave_request' }; + +function deps(ql: unknown): ExplainEngineDeps { + return { + ql: ql as any, + resolveSets: async () => [BOUND, DIRECT, NAMESAKE], + evaluator: new PermissionEvaluator(), + getObjectSecurityMeta: async () => ({ + isPrivate: false, + requiredPermissions: { all: [], read: [], create: [], update: [], delete: [] }, + fieldRequiredPermissions: {}, + }), + requiredCaps: () => [], + computeRlsFilter: async () => null, + getFieldMask: () => ({}), + getPartialMaskRules: async () => ({}), + baselinePermissionSets: ['member_default'], + tenancyPosture: 'single', + }; +} + +const CONTEXT = { + userId: 'u1', + posture: 'MEMBER', + positions: ['sales_rep', 'sales_manager'], + permissions: ['crm_sales_user', 'forecasting', 'sales_manager'], +}; + +async function attributions(ql: unknown, context: Record = CONTEXT): Promise> { + const d = await explainAccess(deps(ql), { object: 'leave_request', operation: 'read', context }); + const principal = d.layers.find((l) => l.layer === 'principal'); + return Object.fromEntries( + (principal?.contributors ?? []).filter((c) => c.kind === 'permission_set').map((c) => [c.name, c.via]), + ); +} + +describe('[ADR-0131 D3/D4; #24757] explain attributes a set to a position only through its definition', () => { + const withCatalog = () => bindTestSecurityCatalog({ getSchema: () => SCHEMA }, { + positions: [ + { name: 'sales_rep', permissionSets: ['crm_sales_user'] }, + { name: 'sales_manager', permissionSets: [] }, + ], + permissions: [BOUND, DIRECT, NAMESAKE], + }); + + it('a set the held position\'s definition names is attributed to that position', async () => { + expect((await attributions(withCatalog())).crm_sales_user).toBe('position:sales_rep'); + }); + + it('a set no held definition names is a direct grant', async () => { + expect((await attributions(withCatalog())).forecasting).toBe('direct grant'); + }); + + it('a direct grant sharing a held position\'s name is a direct grant, not that position\'s', async () => { + expect((await attributions(withCatalog())).sales_manager).toBe('direct grant'); + }); + + it('a set held both through the user\'s own grant row and through a held position\'s definition names both channels (#25799)', async () => { + // `crm_sales_user` is bound by `sales_rep` AND assigned directly, as the + // resolver's own split says (`directPermissions`): revoking the position + // would not remove the access, so the attribution must not read as the + // position's alone. + const dual = { ...CONTEXT, directPermissions: ['crm_sales_user', 'forecasting', 'sales_manager'] }; + const out = await attributions(withCatalog(), dual); + expect(out.crm_sales_user).toBe('direct grant + position:sales_rep'); + // The other two are direct only, through either reading. + expect(out.forecasting).toBe('direct grant'); + expect(out.sales_manager).toBe('direct grant'); + }); + + it('…and the explain API\'s own principal builder carries that split through, so explaining by userId reads it too', async () => { + const ORG = 'org_x'; + const tables: Record>> = { + sys_user: [{ id: 'u1', email: 'u1@example.com' }], + sys_member: [{ user_id: 'u1', organization_id: ORG, role: 'member' }], + sys_user_position: [{ user_id: 'u1', position: 'sales_rep', organization_id: ORG }], + sys_user_permission_set: [ + { user_id: 'u1', permission_set: 'crm_sales_user', organization_id: ORG }, + { user_id: 'u1', permission_set: 'forecasting', organization_id: ORG }, + ], + }; + const ql = bindTestSecurityCatalog({ + getSchema: () => SCHEMA, + async find(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((row) => Object.entries(opts?.where ?? {}).every(([k, v]) => { + // The resolver delegates filtering to the engine: a `$` combinator is refused, not approximated. + if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + return v === null ? (row[k] ?? null) === null : row[k] === v; + })); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + }, { + positions: [{ name: 'sales_rep', permissionSets: ['crm_sales_user'] }], + permissions: [BOUND, DIRECT], + }); + const context = await buildContextForUser(ql, 'u1', Date.now(), ORG); + expect(context.directPermissions).toEqual(['crm_sales_user', 'forecasting']); + const out = await attributions(ql, context); + expect(out.crm_sales_user).toBe('direct grant + position:sales_rep'); + expect(out.forecasting).toBe('direct grant'); + }); + + it('with the definitions unreadable it says it cannot tell the two apart, rather than guessing either', async () => { + const out = await attributions({ getSchema: () => SCHEMA }); + for (const name of ['crm_sales_user', 'forecasting', 'sales_manager']) { + expect(out[name], name).toBe('direct grant or position definition (the position definitions could not be read)'); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-self-direct-grant.test.ts b/packages/plugins/plugin-security/src/explain-self-direct-grant.test.ts new file mode 100644 index 00000000000..fc01384fbf3 --- /dev/null +++ b/packages/plugins/plugin-security/src/explain-self-direct-grant.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25799] A self-explanation names both channels for a set held both through + * the caller's own grant row and through a held position's definition, exactly + * as explaining that user by id does. + * + * The request envelope a self-explanation runs on does not carry the + * resolver's split of the two channels (its field set is closed), so + * `explainAccessForCaller` resolves a HUMAN caller once more through + * `resolveUserAuthzGrants` and attaches only that split. An agent and a guest + * keep the context as it arrived; a resolution that fails is said, never + * guessed. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { ExplainDecision } from '@objectstack/spec/security'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const ORG = 'org_self'; +const OBJECT = 'leave_request'; +const SCHEMA = { name: OBJECT, label: 'Leave request', systemFields: false, fields: { id: { name: 'id' } } }; +const set = (name: string) => ({ name, label: name, objects: { [OBJECT]: { allowRead: true } }, systemPermissions: [] }); + +/** `crm_sales_user` reaches u1 through `sales_rep`'s definition AND u1's own grant row. */ +const TABLES: Record>> = { + sys_user: [{ id: 'u1', email: 'u1@example.com' }], + sys_member: [{ user_id: 'u1', organization_id: ORG, role: 'member' }], + sys_user_position: [{ user_id: 'u1', position: 'sales_rep', organization_id: ORG }], + sys_user_permission_set: [{ user_id: 'u1', permission_set: 'crm_sales_user', organization_id: ORG }], +}; + +/** + * A `where`-honouring double: the resolver delegates filtering to the engine, + * so a fake that ignored `where` would report grants nobody holds. A top-level + * `$` combinator is refused rather than approximated. + */ +function matches(row: Record, where: any): boolean { + return Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + if (v === null) return (row[k] ?? null) === null; + return row[k] === v; + }); +} + +async function plugin(opts: { failGrantRead?: boolean } = {}) { + const ql: any = bindTestSecurityCatalog({ + registerMiddleware: () => {}, + getSchema: (name: string) => (name === OBJECT ? SCHEMA : null), + findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }, + async find(object: string, query: any) { + if (opts.failGrantRead && object === 'sys_user_permission_set') throw new Error('store unavailable'); + const rows = (TABLES[object] ?? []).filter((row) => matches(row, query?.where)); + return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; + }, + }, { + positions: [{ name: 'sales_rep', permissionSets: ['crm_sales_user'] }], + permissions: [set('crm_sales_user'), set('member_default')], + }); + const metadata: any = { + get: async (_type: string, name: string) => (name === OBJECT ? SCHEMA : null), + list: async () => [set('member_default')], + }; + const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const p = new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any); + await p.init(ctx); + await p.start(ctx); + return p; +} + +/** The request envelope as a transport assembles it: grants flat, no channel split. */ +const envelope = (over: Record) => ({ + userId: 'u1', + tenantId: ORG, + positions: ['sales_rep', 'everyone'], + permissions: ['crm_sales_user'], + systemPermissions: [], + posture: 'MEMBER', + ...over, +}); + +const viaOf = (d: ExplainDecision, name: string): string | undefined => + d.layers.find((l) => l.layer === 'principal')?.contributors?.find((c) => c.kind === 'permission_set' && c.name === name)?.via; + +describe('[#25799] a self-explanation reads the resolver\'s channel split', () => { + it('a human caller holding a set directly and through a position sees both channels', async () => { + const d = await (await plugin()).explainAccessForCaller({ object: OBJECT, operation: 'read' }, envelope({ principalKind: 'human' })); + expect(viaOf(d, 'crm_sales_user')).toBe('direct grant + position:sales_rep'); + }); + + it('…and when that resolution fails it says so, rather than guessing either way', async () => { + const d = await (await plugin({ failGrantRead: true })).explainAccessForCaller({ object: OBJECT, operation: 'read' }, envelope({ principalKind: 'human' })); + expect(viaOf(d, 'crm_sales_user')).toBe('position:sales_rep (whether a direct grant also names it could not be read)'); + }); + + it('an agent acting for the user keeps the context as it arrived: no re-resolution, today\'s reading', async () => { + const d = await (await plugin()).explainAccessForCaller( + { object: OBJECT, operation: 'read' }, + envelope({ principalKind: 'agent', onBehalfOf: { userId: 'u1', principalKind: 'human' } }), + ); + expect(viaOf(d, 'crm_sales_user')).toBe('position:sales_rep'); + }); +}); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 1015162f0a0..00c660d8b54 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -197,7 +197,12 @@ async function grantsByPrincipal(posture: Posture): Promise resolveUserAuthzGrants(engine, userId, { tenantId: ORG }); + // [#25799] The golden below was recorded before the resolver reported its + // channel split (`directPermissions`); it pins the grants, so the split is + // left out here and pinned where it was added + // (`resolve-authz-context.direct-permissions.test.ts`, core). + const resolve = (userId: string) => resolveUserAuthzGrants(engine, userId, { tenantId: ORG }) + .then(({ directPermissions: _channel, ...grants }) => grants); const admin = await resolve('usr_admin'); const orgAdmin = await resolve('usr_orgadmin'); const member = await resolve('usr_member'); diff --git a/packages/plugins/plugin-security/src/guest-grants-channel.test.ts b/packages/plugins/plugin-security/src/guest-grants-channel.test.ts index 7df5aeacd8d..10d206b7877 100644 --- a/packages/plugins/plugin-security/src/guest-grants-channel.test.ts +++ b/packages/plugins/plugin-security/src/guest-grants-channel.test.ts @@ -5,15 +5,17 @@ * * - D3 (Enforced by): the set resolution treats the sets the guest envelope * carries — the `guest` anchor's bindings `resolveAuthzContext` resolved — - * as any requested set; and the position-name fold, the authenticated - * baseline and `everyone` never reach the guest (D3 item 3). + * as any requested set; and a set sharing the anchor's name, the + * authenticated baseline and `everyone` never reach the guest (D3 item 3). + * The position-name fold that once made the first of those a live channel + * is retired for every principal (#24757). * - D4: a guest that reaches a data operation acting in no organization, on * a deployment where the guest entry's organization step refuses, meets the * located refusal at the CRUD gate — `NOT_IMPLEMENTED` (501), before the * operation runs and before any set resolves. * * Every pin carries its control: the same resolution for a signed-in human - * (who still folds and still takes the baseline), and the same gate on a + * (who still takes the baseline), and the same gate on a * single-organization deployment (an ordinary 403 for a guest holding nothing). * * Harness mirrors `zero-set-deny-baseline.test.ts`; the engine double adds the @@ -114,16 +116,21 @@ describe('ADR-0138 D3 — the set resolution treats the guest\'s bound sets as a expect(names(sets)).toEqual([BOUND_SET.name]); }); - it('the position-name fold never reaches the guest (D3 item 3)', async () => { + it('a set sharing the anchor\'s name never reaches the guest (D3 item 3)', async () => { const { security } = await boot({ organizations: ['org_one'] }); const guest = await security.resolvePermissionSetsForContext({ ...GUEST, permissions: [], tenantId: 'org_one' }); expect(names(guest)).toEqual([]); - // Control: the fold is real — a signed-in human holding a position of that - // name still resolves the same-named set through it, beside the baseline. + // Nor a signed-in human holding a position of that name: the shared name + // grants nothing to anyone (#24757) — only the baseline resolves. const human = await security.resolvePermissionSetsForContext({ principalKind: 'human', userId: 'u_synth', positions: ['guest'], permissions: [], tenantId: 'org_one', }); - expect(names(human)).toEqual(['guest', 'member_default']); + expect(names(human)).toEqual(['member_default']); + // Control: the set is real — named by the context, it resolves. + const named = await security.resolvePermissionSetsForContext({ + principalKind: 'human', userId: 'u_synth', positions: [], permissions: ['guest'], tenantId: 'org_one', + }); + expect(names(named)).toEqual(['guest', 'member_default']); }); it('the authenticated baseline never reaches the guest (D3 item 3)', async () => { diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index 349d38f433b..9b61042539d 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -7,8 +7,10 @@ * * ## The seam * - * `resolvePermissionSetsForContextUnmemoized` requests - * `[...positions, ...permissions]` as permission-set NAMES, and anything not + * `resolvePermissionSetsForContextUnmemoized` requested + * `[...positions, ...permissions]` as permission-set NAMES until #24757 + * retired the position-name fold (it now requests `permissions` alone), and + * anything not * declared in metadata or bootstrap was read from `sys_permission_set` by name * (the loader reads the security catalog instead since ADR-0131 D3/D4, so no * row reaches it at all; the pins below hold either way). With no active @@ -33,11 +35,11 @@ * (`grantAppliesInTenant`). An organization-less grant row applies nowhere: * the 18.0 ownership ceremony (ADR-0131 D10) attributes every such row before * an upgraded database boots, so one is invalid state, not a "global" grant. - * So the negative pins below are paired with preservation pins: the grants a - * principal holds in its own organization (a position folded onto the - * catalog's same-named set, a user grant) resolve while that organization is - * active and confer nothing with none active, and a legacy organization-less - * grant row confers nothing either way. + * So the negative pins below are paired with preservation pins: a user grant a + * principal holds in its own organization resolves while that organization is + * active and confers nothing with none active, and a legacy organization-less + * grant row confers nothing either way. A position sharing the catalog's + * same-named set confers that set in neither case (#24757). * * ## The rig * @@ -338,20 +340,23 @@ describe('[#20555] with no active organization, another organization\'s set name }); describe('[#20555] what the rule keeps', () => { - it('a position folded onto a global same-named set resolves while its owner is active, and not with none active', async () => { + it('a position sharing a global set\'s name does not resolve that set — with its owner active, or with none (#24757)', async () => { const r = await rig(); const home = await resolve(r, USER_GLOBAL, ORG_HOME); + // Held, and the catalog does carry a set of the same name (the grant + // below resolves its sibling `platform_ops` from the same catalog), so the + // absence is the retired fold's and not a missing set. expect(home.positions).toContain('ops_lead'); - expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); + expect(home.byName.get('ops_lead')).toBeUndefined(); + expect([...home.capabilities].filter((c) => c === 'probe.global_ops_lead')).toEqual([]); const orgless = await resolve(r, USER_GLOBAL); expect(orgless.positions).not.toContain('ops_lead'); expect(orgless.byName.get('ops_lead')).toBeUndefined(); }, 120_000); - it('the owned grants resolve with the principal\'s own organization active', async () => { + it('the owned grant resolves with the principal\'s own organization active', async () => { const r = await rig(); const home = await resolve(r, USER_GLOBAL, ORG_HOME); - expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); expect(home.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); expect(home.setNames).not.toContain('org_member'); }, 120_000); diff --git a/packages/plugins/plugin-security/src/permission-set-active.test.ts b/packages/plugins/plugin-security/src/permission-set-active.test.ts index 9bc5df16606..5acced5ce4f 100644 --- a/packages/plugins/plugin-security/src/permission-set-active.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-active.test.ts @@ -6,15 +6,13 @@ * * The dominant enforcement point is `resolveUserAuthzGrants` in * `@objectstack/core`, which drops a deactivated set before - * `context.permissions` is built. The plugin's resolution is the SECOND - * reader, and it is not defence in depth that nothing reaches — the - * reachability case below is the reason it exists: - * - * `resolvePermissionSetsForContext` requests `context.positions` as - * permission-set NAMES too (a position name is commonly reused as a set - * name). So an ACTIVE position whose name matches a DEACTIVATED set arrives - * with that name still standing: core judged the position, not a set of - * the same name. + * `context.permissions` is built. The plugin's resolution used to be a SECOND + * reader, for one case: it requested `context.positions` as permission-set + * NAMES too, so an ACTIVE position whose name matched a DEACTIVATED set + * arrived with that name still standing. #24757 retired that position-name + * fold, and with it the second read: every name the plugin requests arrives + * already judged, and a held position's name reaches no set at all, active or + * switched off. * * [ADR-0131 D3, ADR-0126 §4] The switch is the activation ledger's * (`sys_metadata_activation`, type `permission`), read through core's own @@ -111,41 +109,20 @@ const namesFor = async (fixture: Fixture, context: Record): Pro const switchedOff = (active: unknown) => ({ metadata_type: 'permission', name: 'sales_manager', active }); -describe('[#8613] a position name reaching a set the activation ledger switched off', () => { - it('THE REACHABILITY CASE: the folded name resolves nothing; the baseline still applies', async () => { - const names = await namesFor({ ledger: [switchedOff(false)] }, { positions: ['sales_manager'], permissions: [] }); +describe('[#8613, #24757] a position name reaches no set, so there is nothing for the ledger to switch off', () => { + it('an ACTIVE same-named set is not granted through the position name; the baseline still applies', async () => { + const names = await namesFor({}, { positions: ['sales_manager'], permissions: [] }); expect(names).not.toContain('sales_manager'); expect(names).toContain('member_default'); }); - it('…and the same request with no ledger row does resolve, whole — the case is real, not vacuous', async () => { + it('…while the same set, named by the context, resolves whole — the set is real, so the absence above is not vacuous', async () => { const { svc } = await locate(); - const sets = await svc.resolvePermissionSetsForContext?.({ userId: 'u1', positions: ['sales_manager'], permissions: [] } as any); + const sets = await svc.resolvePermissionSetsForContext?.({ userId: 'u1', positions: [], permissions: ['sales_manager'] } as any); const found: any = (sets ?? []).find((s) => s.name === 'sales_manager'); expect(found?.objects).toEqual({ deal: { allowRead: true, allowEdit: true } }); expect(found?.systemPermissions).toEqual(['setup.access']); }); - - it('the 0/1 storage shape of the ledger is judged too, not only a literal `false`', async () => { - expect(await namesFor({ ledger: [switchedOff(0)] }, { positions: ['sales_manager'] })).not.toContain('sales_manager'); - expect(await namesFor({ ledger: [switchedOff(1)] }, { positions: ['sales_manager'] })).toContain('sales_manager'); - }); - - it("a ledger row of ANOTHER type under the same name switches nothing off (a position's row is not the set's)", async () => { - const names = await namesFor( - { ledger: [{ metadata_type: 'position', name: 'sales_manager', active: false }] }, - { positions: ['sales_manager'] }, - ); - expect(names).toContain('sales_manager'); - }); - - it("a catalog ROW's `active: false` is not read — only the ledger switches a set off", async () => { - const names = await namesFor( - { rows: [{ name: 'sales_manager', active: false }] }, - { positions: ['sales_manager'] }, - ); - expect(names).toContain('sales_manager'); - }); }); describe('[ADR-0049] names that arrive already judged are not judged again', () => { @@ -158,9 +135,9 @@ describe('[ADR-0049] names that arrive already judged are not judged again', () expect(ledgerReads()).toBe(0); }); - it('a request with no folded collision issues no ledger read', async () => { - const { svc, ledgerReads } = await locate(); - await svc.resolvePermissionSetsForContext?.({ userId: 'u1', positions: ['everyone', 'org_member'], permissions: [] } as any); + it('a held position — one whose name a set shares included — issues no ledger read', async () => { + const { svc, ledgerReads } = await locate({ ledger: [switchedOff(false)] }); + await svc.resolvePermissionSetsForContext?.({ userId: 'u1', positions: ['everyone', 'org_member', 'sales_manager'], permissions: [] } as any); expect(ledgerReads()).toBe(0); }); }); diff --git a/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts b/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts index af6cc1ec5f1..2599217a58f 100644 --- a/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts @@ -130,8 +130,8 @@ const boot = async () => { const requestContext = () => ({ userId: 'u1', tenantId: 'org-1', - positions: ['custom_role'], - permissions: [] as string[], + positions: [] as string[], + permissions: ['custom_role'], }); const readOp = (context: unknown, operation: 'find' | 'count') => ({ @@ -206,7 +206,7 @@ describe('[#10757] permission-set resolution is memoized per execution context', const context = requestContext(); await harness.run(readOp(context, 'find')); - context.positions = ['some_other_role']; + context.permissions = ['some_other_role']; await harness.run(readOp(context, 'find')); expect(harness.permissionSetReads()).toBe(2); diff --git a/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts b/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts index 7477fef352c..4f0da74c199 100644 --- a/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts +++ b/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts @@ -1,49 +1,47 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#13419 执行要点 3; #24757 under 「甲」] The position-name fold, said out loud — - * and, since 18.x, said as the loud half of its retirement. + * [#13419 ruling B, 执行要点 5; #24757] The position-name fold is retired, and + * its retirement is said out loud. * * ## What is pinned * - * `resolvePermissionSetsForContextUnmemoized` requests - * `[...positions, ...explicitPermissionSets]`, so a POSITION name resolves a - * same-named PERMISSION SET that the position's definition does not name in - * `permissionSets` (ADR-0131 D3/D4: the definition is the binding). The - * maintainer ruling on #13419 (2026-08-31, 「同意」 to B) retires the fold; 要点 5 - * permits a warning and nothing else until the fold itself is deleted — - * 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」. + * `resolvePermissionSetsForContextUnmemoized` used to request + * `[...positions, ...explicitPermissionSets]`, so a POSITION name resolved a + * same-named PERMISSION SET that the position's definition did not name in + * `permissionSets`. The maintainer ruling on #13419 (2026-08-31, 「同意」 to B) + * retires that channel; ADR-0131 D3/D4 make the definition the one binding + * ("the binding is written, not inferred"); 「甲」 as amended (ruling record + * 6303865025 on #6015) ships the narrowing in 18.x. 要点 5 requires the + * difference to be loud — 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」 — + * so the fold's warning now reports a namesake set that is NOT granted. This + * file pins it in four directions: * - * The standing ruling 「甲」 (ruling record 6292005854 on #6015) places that - * deletion in 19.0, because no ADR-0087 conversion can carry it, and ships its - * loud half in 18.x. This file pins the loud half in four directions: - * - * - the TEXT says the grant is in force only because the names match, that - * the name-based grant is removed in the next major version and what - * holders lose then, and the fix (`permissionSets: ['P']`, or a rename); - * - the PAYLOAD carries the consequence as `removedIn: 'next_major'` beside - * its unchanged `event` / `position` / `permissionSet`, and no other key; - * - the TRIGGER and CADENCE are unchanged: an undeclared pair warns, also - * for a position bound to some OTHER set; a declared pair, a baseline name - * and an inert position stay silent; once per position, not per request; - * - ⛔ the GRANT is unchanged: the holder still resolves set `P`. + * - ⛔ the GRANT is gone: a holder of position `P` does not resolve set `P` + * unless something declares it (the definition's `permissionSets`, a + * direct grant or the baseline), whatever the names; + * - the TEXT says the set shares the position's name, that holders are NOT + * granted it and why, and the fix (`permissionSets: ['P']`, or a rename); + * - the PAYLOAD carries `granted: false` beside its unchanged `event` / + * `position` / `permissionSet`, and no other key; + * - the TRIGGER and CADENCE: an undeclared pair warns, also for a position + * bound to some OTHER set; a declared pair, a baseline name and an inert + * position stay silent; once per position, not per request. * * ## Where the tuples come from * - * `scripts/measure-position-name-fold-census.mjs` (slice 1, merged `2cd0821cf`) - * classifies all 19 declared positions into three groups, and the constants - * below are that classification transcribed. This file pins the runtime - * PREDICATE against that classification in BOTH directions; the census pins the - * classification against the repository. Neither substitutes for the other. - * The census called the declared bindings "junction" rows; since ADR-0131 the - * same pairs are the definitions' `permissionSets`, which reach this resolver - * as set names in `context.permissions` — the shape every case below feeds. + * The #13419 census (slice 1, merged `2cd0821cf`; its instrument + * `scripts/measure-position-name-fold-census.mjs` retired with the fold) + * classified all 19 declared positions into three groups, and the constants + * below are that classification transcribed. The census called the declared + * bindings "junction" rows; since ADR-0131 the same pairs are the definitions' + * `permissionSets`, which reach this resolver as set names in + * `context.permissions` — the shape every case below feeds. * - * ⚠️ `sales_rep` and `sales_manager` appear in BOTH the fold list and the - * binding list, and that is the finding slice 1 exists for: each is bound to - * `crm_sales_user`, and is folded onto its own same-name set anyway. A - * predicate that asked "is this position bound to anything?" would report - * neither of the repository's two real folds while looking complete. + * ⚠️ `sales_rep` and `sales_manager` appear in BOTH the namesake list and the + * binding list: each is bound to `crm_sales_user`, and a set of its own name + * exists too. A predicate that asked "is this position bound to anything?" + * would report neither while looking complete. * * ## The expensive failure mode, pinned first * @@ -65,9 +63,10 @@ import type { ISecurityService } from '@objectstack/spec/contracts'; const EVENT = 'position_name_fold_grant'; /** - * The census's `NAME-FOLD DEPENDENCIES` block — grants in force that no - * position definition declares. Position half declared by `examples/app-crm/src/security/ - * sales-positions.ts`; permission-set half by the vendored HotCRM artifact. + * The census's `NAME-FOLD DEPENDENCIES` block — the pairs the fold granted with + * no position definition declaring them. Position half declared by + * `examples/app-crm/src/security/sales-positions.ts`; permission-set half by the + * vendored HotCRM artifact. */ const CENSUS_NAME_FOLDS = ['sales_rep', 'sales_manager'] as const; @@ -95,6 +94,7 @@ const CENSUS_JUNCTION_BINDINGS: ReadonlyArray, universe: Permissio } /** Every key the structured report carries — the three it has always carried, - * which a log consumer may key on, plus the retirement (「甲」, 18.x). */ -const PAYLOAD_KEYS = ['event', 'permissionSet', 'position', 'removedIn']; + * which a log consumer may key on, plus the consequence. */ +const PAYLOAD_KEYS = ['event', 'granted', 'permissionSet', 'position']; -describe('[#13419] MUST FIRE — a position folded onto its own same-name set that its definition does not name', () => { +describe('[#13419] MUST FIRE — a held position whose same-name set exists and is not declared', () => { for (const position of CENSUS_NAME_FOLDS) { - it(`warns for '${position}', the census's own cross_scope fold, and still grants the set`, async () => { + it(`warns for '${position}', the census's own cross_scope pair, and does NOT grant the set`, async () => { // The measured shape: the position IS bound — to `crm_sales_user`, not - // to itself — so `permissions` carries that other set. The grant on the - // same-name set has no declaration behind it at all. + // to itself — so `permissions` carries that other set. Nothing declares + // the same-name set. const { sets, events } = await resolveWith({ userId: 'u1', positions: [position], permissions: ['crm_sales_user'], }); - // Reported once, naming both halves of the pair and the retirement. + // Reported once, naming both halves of the pair and the consequence. expect(events).toHaveLength(1); - expect(events[0]).toEqual({ event: EVENT, position, permissionSet: position, removedIn: 'next_major' }); + expect(events[0]).toEqual({ event: EVENT, position, permissionSet: position, granted: false }); - // ⛔ Purely additive: the fold still grants exactly what it granted before. - // 「甲」 moves the removal to 19.0, so in 18.x the holder still resolves - // set `P`. A warning that also changed the answer would be the silent - // behaviour change 要点 5 forbids. - expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', position].sort()); + // ⛔ The retirement itself: the shared name grants nothing. The holder + // resolves what is declared — its bound set and the baseline — and the + // same-name set is absent. + expect(sets.sort()).toEqual(['crm_sales_user', 'member_default'].sort()); }); } - it('carries the consequence as data: the unchanged fields plus removedIn, and no other key', async () => { + it('carries the consequence as data: the unchanged fields plus granted: false, and no other key', async () => { const { events, sets } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] }); expect(events).toHaveLength(1); // Exact keys, so a renamed or dropped legacy field fails here as loudly as - // a missing retirement field does. + // a missing consequence field does. expect(Object.keys(events[0]).sort()).toEqual(PAYLOAD_KEYS); - expect(events[0].removedIn).toBe('next_major'); - expect(sets.sort()).toEqual(['member_default', 'sales_rep'].sort()); + expect(events[0].granted).toBe(false); + expect(sets).toEqual(['member_default']); }); - it('says why the grant is in force, that it is removed in the next major and what holders lose, and the fix', async () => { + it('says the set shares the name, that holders are NOT granted it and why, and the fix', async () => { const { warn } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] }); const message = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes(EVENT))!; - // ① In force today only because the names match. - expect(message).toContain( - "permission set 'sales_rep' is in force for holders of position 'sales_rep' only because the two share a name", - ); - // ② Deprecated, removed in the next major, and what holders lose then. - expect(message).toContain('This name-based grant is deprecated and is removed in the next major version'); + // ① The pair, and that the shared name grants nothing. expect(message).toContain( - "holders of position 'sales_rep' do not receive permission set 'sales_rep' unless the position's definition names it", + "permission set 'sales_rep' shares its name with position 'sales_rep', but holders of position 'sales_rep' are NOT granted it", ); - // ③ The fix. [ADR-0131 D3/D4] The governed way out is the position - // definition's `permissionSets` — a junction row no longer grants or - // governs anything. + // ② Why: the definition is the binding, and it does not name the set. + expect(message).toContain('A position grants only the permission sets its definition names in permissionSets'); + expect(message).toContain("the definition of position 'sales_rep' does not name 'sales_rep'"); + // ③ The fix. [ADR-0131 D3/D4] The governed way is the position + // definition's `permissionSets` — a junction row grants nothing. expect(message).toContain("permissionSets: ['sales_rep']"); expect(message).toMatch(/rename one of the two/); expect(message).not.toContain('sys_position_permission_set'); + // The 18.x deprecation wording is gone with the grant it described. + expect(message).not.toMatch(/next major|deprecated|in force/); // An operator reading the log has no tracker to resolve an issue id against. expect(message).not.toMatch(/#\d/); }); @@ -225,14 +223,14 @@ describe('[#13419] MUST FIRE — a position folded onto its own same-name set th const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial; // Distinct context OBJECTS, so the per-context memo cannot be what silences // the second call — the deduplication under test has to be the one in the - // reporter. Each request still resolves the same-name set. + // reporter. No request resolves the same-name set. for (let i = 0; i < 3; i++) { const sets = await svc.resolvePermissionSetsForContext?.({ userId: `u${i}`, positions: ['sales_rep'] } as any); - expect((sets ?? []).map((s) => s.name)).toContain('sales_rep'); + expect((sets ?? []).map((s) => s.name)).not.toContain('sales_rep'); } const reports = warn.mock.calls.filter((c) => String(c[0]).includes(EVENT)); expect(reports).toHaveLength(1); - expect(reports[0][1]).toMatchObject({ position: 'sales_rep', removedIn: 'next_major' }); + expect(reports[0][1]).toMatchObject({ position: 'sales_rep', granted: false }); }); }); @@ -242,9 +240,9 @@ describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects', async (position) => { const { events, sets } = await resolveWith({ userId: 'u1', positions: [position] }); expect(events).toEqual([]); - // Silent for the right reason: nothing resolved off the position name, so - // the caller fell back to the baseline. A pass produced by a broken - // resolution would show up here as an empty set list. + // Silent for the right reason: no set shares the name, and the caller + // resolves the baseline. A pass produced by a broken resolution would + // show up here as an empty set list. expect(sets).toEqual(['member_default']); }, ); @@ -267,12 +265,11 @@ describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects', }, ); - it('stays silent once the definition declares the pair (position N: permissionSets: [N])', async () => { + it('stays silent once the definition declares the pair (position N: permissionSets: [N]), and grants it', async () => { // The fix the warning prescribes, and what objectstack-ai/hotcrm#2200 does // for HotCRM's five: the definition's `permissionSets` names the same-name - // set, so it arrives in `permissions` (§6b) and the grant no longer depends - // on the fold. This is the forward pin: a declared pair is silent without a - // second edit here. + // set, so it arrives in `permissions` (§6b) and is granted through the + // declaration. const { events, sets } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], @@ -282,7 +279,7 @@ describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects', expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', 'sales_rep'].sort()); }); - it('stays silent when the same-name set IS the baseline (in force with or without the fold)', async () => { + it('stays silent when the same-name set IS the baseline (granted to every authenticated caller)', async () => { const { events } = await resolveWith({ userId: 'u1', positions: ['member_default'] }); expect(events).toEqual([]); }); diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index 5d47b1f8489..80c956e3564 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -229,11 +229,17 @@ async function resolverByPrincipal(posture: Posture): Promise + resolveUserAuthzGrants(engine, userId, opts).then(({ directPermissions: _channel, ...grants }) => grants); const byPrincipal: Record = {}; for (const [kind, userId] of Object.entries(principals)) { byPrincipal[kind] = { - inOrganization: await resolveUserAuthzGrants(engine, userId, { tenantId: ORG }), - noOrganization: await resolveUserAuthzGrants(engine, userId, {}), + inOrganization: await grantsOf(userId, { tenantId: ORG }), + noOrganization: await grantsOf(userId, {}), platformStanding: await hasPlatformAdminStanding(engine, userId), }; } diff --git a/packages/plugins/plugin-security/src/security-plugin-explain-access.ts b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts index cf880ce726f..750f9d59a6e 100644 --- a/packages/plugins/plugin-security/src/security-plugin-explain-access.ts +++ b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts @@ -67,7 +67,7 @@ * * Nothing re-exports this module, so the published surface does not move. */ -import { vetOrganizationClaim } from '@objectstack/core'; +import { resolveUserAuthzGrants, vetOrganizationClaim } from '@objectstack/core'; import type { AuthoredRowWriteOperation } from '@objectstack/spec/contracts'; import type { ExplainDecision, ExplainOperation, PermissionSet } from '@objectstack/spec/security'; import type { SecurityPlugin } from './security-plugin.js'; @@ -195,6 +195,25 @@ export async function explainAccessForCaller( // their organization authored. if (explainedTenantId !== undefined) explained.tenantId = explainedTenantId; targetContext = explained; + } else if ( + callerContext?.principalKind === 'human' + && callerContext.isSystem !== true + && !callerContext.onBehalfOf?.userId + && typeof callerContext.userId === 'string' && callerContext.userId !== '' + ) { + // [#25799] A self-explanation runs on the request's own context, and the + // request envelope does not carry the resolver's split of a set's two + // channels (`directPermissions`): its field set is closed. So a HUMAN + // caller is resolved once more through the resolver itself, in the + // organization the request runs in, and only that split is attached; the + // context's grants stay the request's. An agent, a guest and a system + // caller keep the context as it arrived. A resolution that fails, or + // answers no split, is said as such (`directPermissionsUnread`), never + // guessed. + const tenantId = typeof callerContext.tenantId === 'string' && callerContext.tenantId !== '' ? callerContext.tenantId : undefined; + const split = await resolveUserAuthzGrants(this.ql, callerContext.userId, { tenantId, nowMs: Date.now(), bypassGrantsCache: true }) + .then((g) => (Array.isArray(g?.directPermissions) ? g.directPermissions : undefined), () => undefined); + targetContext = split ? { ...callerContext, directPermissions: split } : { ...callerContext, directPermissionsUnread: true }; } // [C2 / ADR-0095] The optional `sharing` service backs the record-grained diff --git a/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts b/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts index d7f64df98e3..53ba6ab9beb 100644 --- a/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts +++ b/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts @@ -5,22 +5,20 @@ * execution context, moved out of `security-plugin.ts`: * * - `resolvePermissionSetsForContextUnmemoized`, the resolution itself: the - * positions folded into the request (never the guest's), the additive human - * baseline or the agent's restricted ceiling, and the post-resolution - * fallback for an authenticated caller whose positions resolved nothing. - * - `dropDeactivatedFoldedSets`, the activation ledger's say over a set that - * reached the resolution only through a position of the same name. - * - `reportNameFoldCollisions`, the once-per-name warning for a grant that - * exists only because a position and a permission set share a name - * (#13419, #24757). + * permission sets the context names, the additive human baseline or the + * agent's restricted ceiling, and the post-resolution fallback for an + * authenticated caller whose sets resolved nothing. + * - `reportUngrantedNamesakeSets`, the once-per-name warning for a permission + * set that shares a held position's name and is NOT granted through it, + * because the position's definition does not name it (#13419, #24757). * - `permissionSetMemoKey`, the fields of the context the per-context memo is * keyed on (#10757). * - * A MOVE, not a redesign. Each function below is the body of the - * `SecurityPlugin` method of the same name as it stood, re-indented one level - * and otherwise unedited: the same request, in the same order, the same - * baseline and fallback rules, the same ledger read, the same warning text and - * structured payload, once per name, and the same memo key. + * A MOVE, not a redesign, when it landed. Each function below is the body of + * the `SecurityPlugin` method of the same name. #24757 then retired the + * position-name fold here: no position name is requested as a permission-set + * name any more, the folded-set ledger drop went with it, and the fold's + * warning became the report of a namesake set that is not granted. * * ## How the moved bodies reach the class * @@ -32,11 +30,10 @@ * so its method calls its function directly, as cut 1's `extractTargetField` * does. A method is handed over as a closure that calls it on the plugin at * call time, so a test double or spy set on the plugin is still the one the - * body reaches: the resolution still asks the plugin's catalog loader, its - * folded-set drop and its fold report, and the memo, the position catalog's - * description and every other caller still reach these methods through the - * plugin. Two of the methods were async and their functions still are; each - * of those methods now returns its function's promise. + * body reaches: the resolution still asks the plugin's catalog loader and its + * namesake report, and the memo, the position catalog's description and every + * other caller still reach these methods through the plugin. The async + * methods return their function's promise. * * Each field was classified on its own declaration, by the rule cut 1 set. * `baselinePermissionSets`, `bootstrapPermissionSets` and `nameFoldWarned` are @@ -68,16 +65,15 @@ * Two module-level values these bodies read stay there too, and are exported * and imported here by value: `isGuestEnvelope`, which the middleware's * owner-write step reads as well (cut 6 exported it), and - * `POSITION_NAME_FOLD_EVENT`, which the fold report's docblock there names in - * place, with a `{@link}`. Each is read only inside a function body, at call + * `POSITION_NAME_FOLD_EVENT`, which the namesake report's docblock there names + * in place, with a `{@link}`. Each is read only inside a function body, at call * time, never while either module evaluates, so the two-way import is safe * under ESM, and the published bundle holds one module. The event name is * still not exported from the package: `index.ts` exports only * `SecurityPlugin`. * * The imports only these bodies read moved here: the agent's restricted set - * from `@objectstack/spec/ai`, and the activation ledger's reader from - * `@objectstack/core`. `PermissionSet` is imported from + * from `@objectstack/spec/ai`. `PermissionSet` is imported from * `@objectstack/spec/security`, as `security-plugin.ts` imports it. * * Nothing re-exports this module, so the published surface does not move. @@ -87,7 +83,6 @@ import type { SecurityPlugin } from './security-plugin.js'; import { isGuestEnvelope, POSITION_NAME_FOLD_EVENT } from './security-plugin.js'; import type { PermissionSet } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; -import { readDisabledCatalogNames } from '@objectstack/core'; import { humanBaselineFor } from './external-baseline.js'; /** @@ -107,8 +102,7 @@ export interface PermissionSetsHost { readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; readonly ql: SecurityPlugin['ql']; dbLoaderForContext: SecurityPlugin['dbLoaderForContext']; - dropDeactivatedFoldedSets: SecurityPlugin['dropDeactivatedFoldedSets']; - reportNameFoldCollisions: SecurityPlugin['reportNameFoldCollisions']; + reportUngrantedNamesakeSets: SecurityPlugin['reportUngrantedNamesakeSets']; } /** The body of `SecurityPlugin#permissionSetMemoKey`; the method's docblock there is its contract. */ @@ -127,22 +121,25 @@ export function permissionSetMemoKey( /** The body of `SecurityPlugin#resolvePermissionSetsForContextUnmemoized`; the method's docblock there is its contract. */ export async function resolvePermissionSetsForContextUnmemoized( - this: Pick, + this: Pick, context: any, ): Promise { // [ADR-0138 D3] The GUEST requests exactly the sets its envelope carries — // the `guest` anchor's bindings, which `resolveAuthzContext` resolved in // the organization D4 derived — and they are treated as any requested set - // below. Its position is NOT folded into the request: the position-name - // fold (#13419's ruling retires it) would hand an anonymous caller any set - // that merely shares the anchor's name, the one channel D3 item 3 says - // never reaches the guest. The baseline does not reach it either (applied - // only to a caller with a user id, below), and `everyone` is never among - // its positions. + // below. The baseline does not reach it (applied only to a caller with a + // user id, below), and `everyone` is never among its positions; its anchor + // is not even judged for a namesake set. const isGuest = isGuestEnvelope(context); const positions = isGuest ? [] : (context?.positions ?? []); + // [ADR-0131 D3/D4; #13419 ruling B, retired by #24757] The request is the + // permission-set NAMES the context carries and nothing else: the sets each + // held position's definition names in `permissionSets` (§6b of + // `resolveUserAuthzGrants` pushes them as names) and the directly assigned + // ones. A held position's NAME is not a permission-set name — the binding is + // written, not inferred — so a set that merely shares it is not requested. const explicitPermissionSets = context?.permissions ?? []; - const requested = [...positions, ...explicitPermissionSets]; + const requested = [...explicitPermissionSets]; // [ADR-0090 D10] An AGENT principal's grants are EXACTLY its scope-derived // ceiling set(s) — the additive human baseline (member_default) must NOT // apply, or its write bit would silently widen a read-only agent past the @@ -177,14 +174,11 @@ export async function resolvePermissionSetsForContextUnmemoized( // declaring an `isDefault` set REPLACED `member_default` here, so every // member of that app lost the platform floor (and with it every built-in // Account destination) the moment the app declared a posture of its own. - // [#13419] Recorded, not merely pushed: a name granted by the BASELINE is in - // force whether or not the position-name fold exists, so it is not evidence - // of the fold and must not be warned about below. - const baselineApplied: string[] = []; + // A baseline name joins the request, so a position sharing it is granted + // that set anyway and is not reported below. if (!isAgent && context?.userId) { for (const name of baseline) { if (!requested.includes(name)) requested.push(name); - baselineApplied.push(name); } } let permissionSets = await this.permissionEvaluator.resolvePermissionSets( @@ -194,10 +188,9 @@ export async function resolvePermissionSetsForContextUnmemoized( this.dbLoaderForContext(context), { logger: this.logger }, ); - permissionSets = await this.dropDeactivatedFoldedSets(positions, explicitPermissionSets, baselineApplied, permissionSets); - // [#13419 执行要点 3] Report — never alter — a grant that exists ONLY because - // this method folded a POSITION name into the permission-set request above. - this.reportNameFoldCollisions(positions, explicitPermissionSets, baselineApplied, permissionSets); + // [#13419 执行要点 5] The retirement is LOUD: a held position whose namesake + // set exists, and is not granted, is reported — never granted, never silent. + await this.reportUngrantedNamesakeSets(positions, requested, context); // Post-resolution fallback — closes the fail-open hole where a populated // `positions` array maps to no permission set yet (no sys_position binding), // which would otherwise skip RLS entirely and expose every tenant's data. @@ -219,49 +212,30 @@ export async function resolvePermissionSetsForContextUnmemoized( return permissionSets; } -/** The body of `SecurityPlugin#dropDeactivatedFoldedSets`; the method's docblock there is its contract. */ -export async function dropDeactivatedFoldedSets( - this: Pick, +/** The body of `SecurityPlugin#reportUngrantedNamesakeSets`; the method's docblock there is its contract. */ +export async function reportUngrantedNamesakeSets( + this: Pick, positions: unknown, - explicitPermissionSets: unknown, - baselineApplied: string[], - resolved: PermissionSet[], -): Promise { - if (!Array.isArray(positions) || positions.length === 0 || resolved.length === 0) return resolved; - const judged = new Set(baselineApplied); - for (const n of Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []) { - if (typeof n === 'string') judged.add(n); - } - const resolvedNames = new Set(resolved.map((ps) => (ps as any)?.name).filter((n: unknown): n is string => typeof n === 'string')); - const folded = positions.filter((n): n is string => typeof n === 'string' && resolvedNames.has(n) && !judged.has(n)); - if (folded.length === 0) return resolved; - const disabled = await readDisabledCatalogNames(this.ql, [], [...new Set(folded)]); - if (disabled.permissions.size === 0) return resolved; - return resolved.filter((ps) => !disabled.permissions.has((ps as any)?.name)); -} - -/** The body of `SecurityPlugin#reportNameFoldCollisions`; the method's docblock there is its contract. */ -export function reportNameFoldCollisions( - this: Pick, - positions: unknown, - explicitPermissionSets: unknown, - baselineApplied: string[], - resolved: PermissionSet[], -): void { + requested: readonly string[], + context: any, +): Promise { if (!Array.isArray(positions) || positions.length === 0) return; - const governed = new Set( - (Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []).filter( - (n): n is string => typeof n === 'string', - ), + const candidates = [...new Set(positions)].filter( + (n): n is string => typeof n === 'string' && n !== '' && !requested.includes(n) && !this.nameFoldWarned.has(n), ); - for (const name of baselineApplied) governed.add(name); - const resolvedNames = new Set( - resolved.map((ps) => (ps as any)?.name).filter((n: unknown): n is string => typeof n === 'string'), + if (candidates.length === 0) return; + // The same three sources the resolution reads, asked only whether a set of + // each name exists. What comes back is never granted. + const namesakes = await this.permissionEvaluator.resolvePermissionSets( + candidates, + this.metadata, + this.bootstrapPermissionSets, + this.dbLoaderForContext(context), + { logger: this.logger }, ); - for (const position of positions) { - if (typeof position !== 'string' || position.length === 0) continue; - if (!resolvedNames.has(position)) continue; - if (governed.has(position)) continue; + for (const set of namesakes) { + const position = (set as any)?.name; + if (typeof position !== 'string' || !candidates.includes(position)) continue; if (this.nameFoldWarned.has(position)) continue; this.nameFoldWarned.add(position); // ⛔ No tracker id in the STRING — an operator reading this log has no tracker, no @@ -269,21 +243,19 @@ export function reportNameFoldCollisions( // ruling 2026-08-12). The card anchor lives in the docblock above, where the reader // who can resolve it is already looking. this.logger.warn?.( - `[security] ${POSITION_NAME_FOLD_EVENT}: permission set '${position}' is in force for ` + - `holders of position '${position}' only because the two share a name — the definition ` + - `of position '${position}' does not name it in its permissionSets. This name-based ` + - `grant is deprecated and is removed in the next major version: from then on, holders ` + - `of position '${position}' do not receive permission set '${position}' unless the ` + - `position's definition names it. To keep the grant, declare it (position ` + - `'${position}': permissionSets: ['${position}']); if the shared name is a ` + - `coincidence, rename one of the two.`, + `[security] ${POSITION_NAME_FOLD_EVENT}: permission set '${position}' shares its name ` + + `with position '${position}', but holders of position '${position}' are NOT granted it. ` + + `A position grants only the permission sets its definition names in permissionSets, ` + + `and the definition of position '${position}' does not name '${position}'; a shared ` + + `name grants nothing. To grant it, declare it (position '${position}': permissionSets: ` + + `['${position}']); if the shared name is a coincidence, rename one of the two.`, { event: POSITION_NAME_FOLD_EVENT, position, permissionSet: position, // The consequence as data, for a consumer that keys on fields rather // than prose. The three fields above are unchanged on purpose. - removedIn: 'next_major', + granted: false, }, ); } diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index a64025c21b9..8497e2b5af9 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3680,15 +3680,15 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { /** * In-memory harness (modelled on the D6/D12 explain harness) that resolves the - * delegator's sets from `sys_user_position` and answers the `sys_user` + * delegator's sets from `sys_user_permission_set` and answers the `sys_user` * existence check. Business-object (`task`) reads route to `taskFindOne` so a * write pre-image can be made visible or hidden per test; sys_* reads route to * the seeded tables. Every set named in `metadata.list()` resolves by name. */ const makeD10Harness = (opts: { sets: PermissionSet[]; - agentPositions: string[]; - delegatorPositions: string[] | null; // null → delegator has NO sys_user row (deleted) + agentSets: string[]; + delegatorSets: string[] | null; // null → delegator has NO sys_user row (deleted) schemaExtra?: Record; taskFindOne?: (query: any) => any; }) => { @@ -3699,10 +3699,12 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { sys_permission_set: [], sys_position: [], }; - if (opts.delegatorPositions !== null) { + if (opts.delegatorSets !== null) { tables.sys_user.push({ id: DELEGATOR, email: 'delegator@x' }); - for (const p of opts.delegatorPositions) { - tables.sys_user_position.push({ user_id: DELEGATOR, position: p, organization_id: D10_ORG }); + // Direct grants: a position grants only what its definition names, and + // this harness declares no position definitions. + for (const p of opts.delegatorSets) { + tables.sys_user_permission_set.push({ user_id: DELEGATOR, permission_set: p, organization_id: D10_ORG }); } } const matches = (row: any, where: any): boolean => @@ -3719,6 +3721,13 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const ql: any = { registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, getSchema: () => baseSchema, + // The security catalog the direct grants below are read through + // (ADR-0131 D4): every set in `opts.sets`, by name. + registry: { + getItem: (type: string, name: string) => (type === 'permission' ? opts.sets.find((s) => s.name === name) : undefined), + listItems: (type: string) => (type === 'permission' ? opts.sets : []), + isPackageDisabled: () => false, + }, async find(object: string, o: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, o?.where)); return typeof o?.limit === 'number' ? rows.slice(0, o.limit) : rows; @@ -3768,7 +3777,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { }; const agentCtx = (extra?: Record) => ({ - userId: AGENT, tenantId: D10_ORG, positions: ['agent_set'], permissions: [], + userId: AGENT, tenantId: D10_ORG, positions: [], permissions: ['agent_set'], onBehalfOf: { userId: DELEGATOR, principalKind: 'human' }, principalKind: 'agent', ...(extra ?? {}), }); @@ -3783,19 +3792,19 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { } as any); it('agent may EDIT but delegator is read-only → update DENIED (agent cannot exceed the user it acts for)', async () => { - const { h } = await boot({ sets: [editor('agent_set'), reader('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); + const { h } = await boot({ sets: [editor('agent_set'), reader('del_set')], agentSets: ['agent_set'], delegatorSets: ['del_set'] }); const opCtx: any = { object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: agentCtx() }; await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); it('delegator may EDIT but agent is read-only → update DENIED (symmetry)', async () => { - const { h } = await boot({ sets: [reader('agent_set'), editor('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); + const { h } = await boot({ sets: [reader('agent_set'), editor('del_set')], agentSets: ['agent_set'], delegatorSets: ['del_set'] }); const opCtx: any = { object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: agentCtx() }; await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); it('both grant edit → update is ALLOWED', async () => { - const { h } = await boot({ sets: [editor('agent_set'), editor('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], taskFindOne: () => ({ id: 'r1' }) }); + const { h } = await boot({ sets: [editor('agent_set'), editor('del_set')], agentSets: ['agent_set'], delegatorSets: ['del_set'], taskFindOne: () => ({ id: 'r1' }) }); const opCtx: any = { object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: agentCtx() }; await expect(h.run(opCtx)).resolves.toBeDefined(); }); @@ -3805,7 +3814,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const capGated = { requiredPermissions: ['export_data'] }; // array form → gates EVERY op const agentWithCap: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true } }, systemPermissions: ['export_data'] } as any; const delNoCap = reader('del_set'); - const { h } = await boot({ sets: [agentWithCap, delNoCap], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], schemaExtra: capGated }); + const { h } = await boot({ sets: [agentWithCap, delNoCap], agentSets: ['agent_set'], delegatorSets: ['del_set'], schemaExtra: capGated }); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); @@ -3820,7 +3829,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it('read filter is the AND (intersection) of the agent and delegator RLS', async () => { const { h } = await boot({ sets: [rlsReader('agent_set', 'owner_id = current_user.id'), rlsReader('del_set', 'manager_id = current_user.id')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; await h.run(opCtx); @@ -3832,10 +3841,10 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it('non-delegated principal is byte-identical (only the agent filter, no delegator read)', async () => { const { h } = await boot({ sets: [rlsReader('agent_set', 'owner_id = current_user.id'), rlsReader('del_set', 'manager_id = current_user.id')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); // Same context, but WITHOUT onBehalfOf. - const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: { userId: AGENT, tenantId: D10_ORG, positions: ['agent_set'], permissions: [] } }; + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: { userId: AGENT, tenantId: D10_ORG, positions: [], permissions: ['agent_set'] } }; await h.run(opCtx); expect(opCtx.ast.where).toEqual({ owner_id: AGENT }); }); @@ -3844,7 +3853,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const agentViewAll: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true, viewAllRecords: true } } } as any; const delScoped = rlsReader('del_set', 'owner_id = current_user.id'); const { h } = await boot({ - sets: [agentViewAll, delScoped], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + sets: [agentViewAll, delScoped], agentSets: ['agent_set'], delegatorSets: ['del_set'], schemaExtra: { sharingModel: 'private' }, }); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; @@ -3858,7 +3867,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it('masks a field the delegator cannot read even though the agent can', async () => { const agentFull = reader('agent_set'); const delMask: PermissionSet = { name: 'del_set', label: 'd', objects: { task: { allowRead: true } }, fields: { 'task.salary': { readable: false, editable: false } } } as any; - const { h } = await boot({ sets: [agentFull, delMask], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); + const { h } = await boot({ sets: [agentFull, delMask], agentSets: ['agent_set'], delegatorSets: ['del_set'] }); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 999 }], context: agentCtx() }; await h.run(opCtx); expect(opCtx.result[0].name).toBe('A'); @@ -3869,7 +3878,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it("stashes the agent's own depth AND the delegator's depth SEPARATELY (identity-scoped OWD intersection)", async () => { const agentViewAll: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true, viewAllRecords: true } } } as any; const delOwn: PermissionSet = { name: 'del_set', label: 'd', objects: { task: { allowRead: true, readScope: 'own' } } } as any; - const { h } = await boot({ sets: [agentViewAll, delOwn], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], schemaExtra: { sharingModel: 'private' } }); + const { h } = await boot({ sets: [agentViewAll, delOwn], agentSets: ['agent_set'], delegatorSets: ['del_set'], schemaExtra: { sharingModel: 'private' } }); const ctx: any = agentCtx(); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: ctx }; await h.run(opCtx); @@ -3903,7 +3912,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it('[#16549] a ceiling silent about depth contributes NO owner narrowing — the delegator\'s viewAllRecords stands', async () => { const { h } = await boot({ sets: [silentCeiling('agent_set'), viewAllDelegator('del_set')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], schemaExtra: { sharingModel: 'private' }, }); const ctx: any = agentCtx(); @@ -3920,7 +3929,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const delOwn: PermissionSet = { name: 'del_set', label: 'd', objects: { task: { allowRead: true, readScope: 'own' } } } as any; const { h } = await boot({ sets: [silentCeiling('agent_set'), delOwn], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], schemaExtra: { sharingModel: 'private' }, }); const ctx: any = agentCtx(); @@ -3937,7 +3946,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const declaringCeiling: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true, readScope: 'own' } } } as any; const { h } = await boot({ sets: [declaringCeiling, viewAllDelegator('del_set')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], schemaExtra: { sharingModel: 'private' }, }); const ctx: any = agentCtx(); @@ -3958,7 +3967,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { } as any; const { h } = await boot({ sets: [silentWriteCeiling, modifyAllDelegator], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], schemaExtra: { sharingModel: 'private' }, }); const ctx: any = agentCtx(); @@ -3978,7 +3987,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { } as any; const { h } = await boot({ sets: [silentCeiling('agent_set'), modifyAllDelegator], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); await expect(h.run({ object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, @@ -3997,7 +4006,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { } as any; const { h } = await boot({ sets: [silentWriteCeiling, transferringDelegator], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); // The delegator holds transfer via modifyAllRecords; the ceiling does not // declare `allowTransfer` and D10 requires BOTH. ⛔ The ruling explicitly @@ -4017,7 +4026,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const declaringCeiling: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true, readScope: 'own' } } } as any; const { h } = await boot({ sets: [declaringCeiling, viewAllDelegator('del_set')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); const verdict = await h.security().describeDelegationNarrowing('task', agentCtx()); expect(verdict.narrowed).toBe(true); @@ -4035,7 +4044,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it('reports NO narrowing for a silent ceiling — an un-narrowed delegated read carries no statement', async () => { const { h } = await boot({ sets: [silentCeiling('agent_set'), viewAllDelegator('del_set')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); const verdict = await h.security().describeDelegationNarrowing('task', agentCtx()); expect(verdict).toEqual({ narrowed: false }); @@ -4044,16 +4053,16 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it('reports NO narrowing for a NON-delegated principal', async () => { const { h } = await boot({ sets: [silentCeiling('agent_set'), viewAllDelegator('del_set')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); - const humanCtx = { userId: DELEGATOR, tenantId: D10_ORG, positions: ['del_set'], permissions: [] }; + const humanCtx = { userId: DELEGATOR, tenantId: D10_ORG, positions: [], permissions: ['del_set'] }; expect(await h.security().describeDelegationNarrowing('task', humanCtx)).toEqual({ narrowed: false }); }); it('reports NO narrowing for a system context — a diagnostic never speaks for the engine\'s own writer', async () => { const { h } = await boot({ sets: [silentCeiling('agent_set'), viewAllDelegator('del_set')], - agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + agentSets: ['agent_set'], delegatorSets: ['del_set'], }); expect(await h.security().describeDelegationNarrowing('task', { isSystem: true })).toEqual({ narrowed: false }); }); @@ -4061,7 +4070,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { it('a DANGLING delegator is a denial upstream, not a narrowing — the probe stays silent', async () => { const { h } = await boot({ sets: [silentCeiling('agent_set')], - agentPositions: ['agent_set'], delegatorPositions: null, + agentSets: ['agent_set'], delegatorSets: null, }); expect(await h.security().describeDelegationNarrowing('task', agentCtx())).toEqual({ narrowed: false }); }); @@ -4069,15 +4078,15 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { // ── fail-closed on a dangling delegation link ─────────────────────────── it('delegator no longer exists → fail CLOSED (PermissionDeniedError, not baseline access)', async () => { - const { h } = await boot({ sets: [reader('agent_set')], agentPositions: ['agent_set'], delegatorPositions: null }); + const { h } = await boot({ sets: [reader('agent_set')], agentSets: ['agent_set'], delegatorSets: null }); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); // ── explain attribution (D6 × D10) ────────────────────────────────────── it('explain reports the D10 intersection deny when the delegator lacks the grant', async () => { - const { plugin } = await boot({ sets: [editor('agent_set'), reader('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); - const caller = { userId: AGENT, positions: ['agent_set'], permissions: [], onBehalfOf: { userId: DELEGATOR } }; + const { plugin } = await boot({ sets: [editor('agent_set'), reader('del_set')], agentSets: ['agent_set'], delegatorSets: ['del_set'] }); + const caller = { userId: AGENT, positions: [], permissions: ['agent_set'], onBehalfOf: { userId: DELEGATOR } }; const d = await plugin.explainAccessForCaller({ object: 'task', operation: 'update' }, caller); expect(d.allowed).toBe(false); const crud = d.layers.find((l) => l.layer === 'object_crud'); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7c0cbb1899d..6c24ec4ed83 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -137,13 +137,12 @@ import { } from './security-plugin-rls-filter.js'; import { resolverCollisionMessage, resolverCollisionsOf } from './object-membership-keys.js'; // [#25223 cut 8] The permission-set resolution for an execution context: the -// resolution itself, the deactivated folded-set drop, the position-name fold -// report and the memo's key; the methods of the same names below hand each -// body the members it reads (`PermissionSetsHost`). +// resolution itself, the namesake-set report and the memo's key; the methods +// of the same names below hand each body the members it reads +// (`PermissionSetsHost`). import { - dropDeactivatedFoldedSets, permissionSetMemoKey, - reportNameFoldCollisions, + reportUngrantedNamesakeSets, resolvePermissionSetsForContextUnmemoized, type PermissionSetsHost, } from './security-plugin-permission-sets.js'; @@ -302,7 +301,6 @@ import { securityPluginManifestHeader, SECURITY_PLUGIN_ID, } from './manifest.js'; -import { GUEST_POSITION } from '@objectstack/spec'; import { registerBuiltinCapabilities } from './builtin-capabilities.js'; import { humanBaselineFor } from './external-baseline.js'; @@ -970,31 +968,27 @@ interface SecurityReportSink { * (#10706), so a degraded boot reports through the host too. */ /** - * [#13419 执行要点 3; #24757 under 「甲」] The stable event name stamped on every - * position-name-fold report — the structured half of - * {@link SecurityPlugin.reportNameFoldCollisions}, so an operator can grep one - * token for every grant that exists only because a position and a permission - * set share a name. + * [#13419 执行要点 3 and 5; #24757] The stable event name stamped on every + * report of a permission set that shares a held position's name and is NOT + * granted through it — the structured half of + * {@link SecurityPlugin.reportUngrantedNamesakeSets}, so an operator can grep + * one token for every such pair. * - * ## The 18.x loud half of the fold's retirement + * ## The fold is retired; the event is its loud half * - * The standing ruling 「甲」 (ruling record 6292005854 on #6015) splits a - * narrowing that no ADR-0087 conversion can carry: 18.x ships only its loud - * half, and the refusal waits for 19.0. Retiring the position-name fold is - * such a narrowing: the platform cannot write `permissionSets` into a sealed - * managed position definition (ADR-0131 D6), so nothing rewrites an author's - * reliance on it (triage's derivation for #24757, record 6292072572). This - * event is that loud half's REPORTED EVENT. Every report carries the retirement - * as data, `removedIn: 'next_major'`, beside the fields it has always carried - * (`event`, `position`, `permissionSet`), which stay unchanged because a log - * consumer may already key on them. 19.0 removes the fold, and with it this - * event and its reporter (the card's 19.0 half). + * The resolver used to fold each held position's NAME into the permission-set + * request, so a set sharing a position's name was granted to its holders + * though nothing declared it. #13419's ruling B retired that channel (ADR-0131: + * "the binding is written, not inferred"), and 「甲」 as amended (ruling record + * 6303865025 on #6015) ships the narrowing in 18.x. Its 要点 5 requires that a + * behaviour difference shows only as a refusal or a loud warning, never as a + * silent change of resolution: this event is that warning. The token is the + * one the deprecation half shipped under (17.3.0, re-pointed in 18.x), so a + * consumer already grepping it keeps finding the pair; the payload keeps + * `event`, `position` and `permissionSet`, and carries `granted: false`. * - * Deliberately NOT exported from this package. The maintainer ruling this - * serves permits a warning and nothing more; adding a published symbol would - * widen the surface a slice that changes no resolution result has no business - * widening. Its pin asserts the literal rather than importing it, which is what - * makes a silent rename fail. + * Deliberately NOT exported from this package. Its pin asserts the literal + * rather than importing it, which is what makes a silent rename fail. * * snake_case per the precedent beside it (`platform_owner_wall_bypass`): this * is a data value, not an error code (ADR-0112 governs those). @@ -1492,8 +1486,8 @@ export class SecurityPlugin implements Plugin { private logger: SecurityReportSink = CONSOLE_SECURITY_SINK; /** - * [#13419 执行要点 3, warning half] Position names already reported by - * {@link reportNameFoldCollision}, so the warning is LOUD ONCE rather than + * [#13419 执行要点 5; #24757] Position names already reported by + * {@link reportUngrantedNamesakeSets}, so the warning is LOUD ONCE rather than * once per request. * * Deduplication is what keeps it loud. This resolution runs on every request @@ -2064,9 +2058,7 @@ export class SecurityPlugin implements Plugin { // report "no such set" for a read that never answered. // // Whether a set is switched off is not this loader's question: the names - // it is asked for arrive already judged by the resolver's ledger read, - // except a POSITION name folded into the request, which - // `resolvePermissionSetsForContextUnmemoized` judges itself. + // it is asked for arrive already judged by the resolver's ledger read. this.catalogSetLoader = async (names: string[]) => { const catalog = securityCatalogReaderOf(ql); if (!catalog) return []; @@ -3508,75 +3500,39 @@ export class SecurityPlugin implements Plugin { const readLogger = () => this.logger; const readMetadata = () => this.metadata; const readPermissionEvaluator = () => this.permissionEvaluator; - const host: Pick = { + const host: Pick = { baselinePermissionSets: this.baselinePermissionSets, bootstrapPermissionSets: this.bootstrapPermissionSets, dbLoaderForContext: (...a) => this.dbLoaderForContext(...a), - dropDeactivatedFoldedSets: (...a) => this.dropDeactivatedFoldedSets(...a), get logger() { return readLogger(); }, get metadata() { return readMetadata(); }, get permissionEvaluator() { return readPermissionEvaluator(); }, - reportNameFoldCollisions: (...a) => this.reportNameFoldCollisions(...a), + reportUngrantedNamesakeSets: (...a) => this.reportUngrantedNamesakeSets(...a), }; return resolvePermissionSetsForContextUnmemoized.call(host, context); } - /** - * [ADR-0049, ADR-0131 D3] Drop a set that reached the resolution ONLY because - * a POSITION of the same name was folded into the request, when the - * activation ledger switched that set off. - * - * Every other name arrives already judged: `context.permissions` is the - * resolver's output, which drops a deactivated set (§6b), and the baseline - * is the deployment's configuration, applied regardless. A folded position - * name is the one name nobody judged as a SET — the position is in effect, - * but the set that shares its name may not be. The row loader this replaced - * judged it with the catalog row's `active` column; the switch now lives in - * `sys_metadata_activation`, read through the resolver's own - * `readDisabledCatalogNames`, so this path and the resolver cannot disagree. - * - * The ledger is read only when a folded name actually resolved, so a request - * with no such collision issues no read. A read that fails throws - * (`AuthzStoreUnavailableError`), and the request fails closed. - */ - private dropDeactivatedFoldedSets( - positions: unknown, - explicitPermissionSets: unknown, - baselineApplied: string[], - resolved: PermissionSet[], - ): Promise { - // [#25223 cut 8] The body moved to `security-plugin-permission-sets.ts` (see `PermissionSetsHost`). - const readQl = () => this.ql; - const host: Pick = { - get ql() { return readQl(); }, - }; - return dropDeactivatedFoldedSets.call(host, positions, explicitPermissionSets, baselineApplied, resolved); - } - /** * [ADR-0131 D3/D4] `ISecurityService.describePositionGrants` — each position * as the resolver enforces it for a holder, for the position catalog's list. * * ## The sets, from the enforcement path * - * A holder of position P is granted, through P, two things, each read where - * the resolver reads it — ⛔ never a second copy of either rule: - * - * 1. the sets P's DEFINITION names, in force — `@objectstack/core`'s - * `readPositionGrantsInForce`, the reading §6a/§6b of - * `resolveUserAuthzGrants` takes (a switched-off position grants - * nothing; a switched-off set, or a name the catalog holds no set for, is - * not granted). These arrive here as a context's `permissions`; - * 2. the set that shares P's NAME — the position-name fold of - * {@link resolvePermissionSetsForContextUnmemoized}, requested by the same - * evaluator call and judged by the same {@link dropDeactivatedFoldedSets}. - * Never for `guest`: the guest envelope requests no position. + * A holder of position P is granted, through P, exactly the sets P's + * DEFINITION names, in force — `@objectstack/core`'s + * `readPositionGrantsInForce`, the reading §6a/§6b of + * `resolveUserAuthzGrants` takes (a switched-off position grants nothing; a + * switched-off set, or a name the catalog holds no set for, is not granted). + * These arrive at the resolver as a context's `permissions`, and are resolved + * here by the same evaluator call — ⛔ never a second copy of the rule. A set + * that merely shares P's NAME is not granted (#24757 retired the + * position-name fold), so it is not listed. * * So each position's sets are this resolver's request for a holder of that * position ALONE: the baseline and every other grant left out, and nothing - * reported ({@link reportNameFoldCollisions} is the request path's, and an - * admin reading the list is not a holder resolving). A set is `via: - * 'definition'` when the definition names it, else `via: 'name'`. + * reported ({@link reportUngrantedNamesakeSets} is the request path's, and an + * admin reading the list is not a holder resolving). Every set is `via: + * 'definition'`; the contract's `'name'` member is no longer served. * * ## The holders * @@ -3605,19 +3561,17 @@ export class SecurityPlugin implements Plugin { const permissionSets: PositionGrantSummary['permissionSets'] = []; if (!disabledPositions.has(position)) { const declared = inForce.get(position) ?? []; - const folded = position === GUEST_POSITION ? [] : [position]; - let resolved = await this.permissionEvaluator.resolvePermissionSets( - [...folded, ...declared.filter((n) => !folded.includes(n))], + const resolved = await this.permissionEvaluator.resolvePermissionSets( + declared, this.metadata, this.bootstrapPermissionSets, this.dbLoaderForContext(context), { logger: this.logger }, ); - resolved = await this.dropDeactivatedFoldedSets(folded, declared, [], resolved); for (const set of resolved) { const name = (set as any)?.name; if (typeof name !== 'string' || permissionSets.some((p) => p.name === name)) continue; - permissionSets.push({ name, via: declared.includes(name) ? 'definition' : 'name' }); + permissionSets.push({ name, via: 'definition' }); } } summaries.push({ position, holderCount: holders.get(position) ?? null, permissionSets }); @@ -3626,101 +3580,66 @@ export class SecurityPlugin implements Plugin { } /** - * [#13419 执行要点 3; #24757 under 「甲」] Report a permission set granted to a - * position's holders only because the two share a NAME — a grant the - * position's definition does not declare — and say what happens to it when - * the fold is retired. - * - * ## What is being reported - * - * {@link resolvePermissionSetsForContextUnmemoized} builds its request as - * `[...positions, ...explicitPermissionSets]`. That fold means a position - * called `sales_rep` resolves a permission set called `sales_rep` although - * the definition of `sales_rep` names no such set in `permissionSets`, the - * one binding spec and docs declare (ADR-0131 D3/D4; `position.zod.ts`, - * `positions.mdx`). Nothing declares that it happens, and Setup cannot show - * it: both the position's Permission Sets field and the set's Assigned Users - * read the declared binding. The maintainer ruling on #13419 (2026-08-31, - * 「同意」 to B) retires the fold; ADR-0131 later made the definition's - * `permissionSets` the binding in place of the junction table that ruling - * named. - * - * ## The 18.x loud half (「甲」, #24757) - * - * The fold's retirement is a narrowing no ADR-0087 conversion can carry, so - * under 「甲」 (ruling record 6292005854 on #6015) 18.x ships its loud half - * and the removal waits for 19.0. This warning IS that loud half, and its - * text says three things the 17.x wording did not: the grant is in force - * today only because the names match; that name-based grant is deprecated - * and is removed in the next major version, after which holders of `P` do - * not receive set `P` unless `P`'s definition names it; and the fix, - * `permissionSets: ['P']` on position `P` (or renaming one of the two). The - * structured payload carries the same consequence as `removedIn: - * 'next_major'` beside its unchanged `event`, `position` and `permissionSet` - * ({@link POSITION_NAME_FOLD_EVENT}). The string says "the next major - * version" and not a number, and carries no tracker id: an operator reading - * the log can resolve neither a ruling record nor `#NNNN`. + * [#13419 ruling B, 执行要点 5; #24757] Report a permission set that shares a + * held position's NAME and is NOT granted to that position's holders, + * because the position's definition does not name it. + * + * ## Why there is anything to report + * + * The resolver used to fold each held position's name into the permission-set + * request, so position `sales_rep` granted a set called `sales_rep` that its + * definition never named. That channel is retired (#13419's ruling B; + * ADR-0131 D3/D4: a position grants only the sets its definition names in + * `permissionSets`; `position.zod.ts`, `positions.mdx`), in 18.x under 「甲」 + * as amended (ruling record 6303865025 on #6015). An author who relied on it + * — or who names a set after a position expecting that to bind them — now + * gets nothing from the shared name, and 要点 5 of the ruling forbids that + * difference being silent: 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」. + * This warning is that loud half. It says the set is NOT granted, why, and + * the fix (`permissionSets: ['P']` on position `P`, or a rename). Its text + * carries no tracker id: an operator reading the log can resolve neither a + * ruling record nor `#NNNN`. * * ## When it fires * - * Lazily, on the first resolution that meets an undeclared pair — never at - * boot, so a deployment where no holder of `P` has made a request has not - * been told yet. Once per position NAME per plugin instance - * ({@link nameFoldWarned}): not per holder, and not per organization, since - * the key is the name alone. A restart reports again. - * - * ## The predicate, and why each clause is there - * - * A position name `P` is reported when ALL of: - * - * 1. a permission set named `P` actually resolved — a position whose name - * matches nothing grants nothing, and the seven inert positions - * (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `guest`, - * and app-showcase's `finance` / `legal`) are all of that kind. ⛔ A - * false positive on a built-in identity is the most expensive failure - * this warning has, and this clause is what prevents it; - * 2. `P` was NOT already requested through the governed channel. The sets - * a position's definition names (`permissionSets`, ADR-0131 D3/D4) - * arrive as permission-set NAMES in `context.permissions` - * (`resolve-authz-context.ts` §6b pushes `ps.name`), as do directly - * assigned sets. Either way the grant does not depend on the fold, so - * there is nothing ungoverned to report; - * 3. `P` is not one of the baseline names folded in just above — those are - * in force for every authenticated caller regardless of the fold. - * - * ⚠️ Clause 2 is about the pair `(position P, set P)` SPECIFICALLY. A - * position bound to some OTHER set is still folded onto its own name: the - * #13419 census measured `sales_manager` bound to `crm_sales_user` and - * folding onto the same-named set anyway. Testing "is this position bound to - * anything?" would report neither of the two real name-folds in the repo - * while looking complete. - * - * ## Boundary (#13419 slice 2, kept by #24757) - * - * ⛔ Purely additive. The resolution result is READ, never rewritten — 要点 5 - * of the ruling: 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」, and a - * warning is the permitted half. Under 「甲」 the grant does not change in 18.x - * either: a holder of `P` still resolves set `P`, and - * `position-name-fold-warning.test.ts` pins that beside the text. ⛔ Deleting - * the fold is NOT this change. It is #24757's 19.0 half, with - * {@link dropDeactivatedFoldedSets} and the delegated walk, explain and - * collision-gate follow-ups. The reliance it would revoke, as measured on the - * card, is HotCRM's: 5 of its 12 positions at `f22dfa52`, which - * objectstack-ai/hotcrm#2200 declares in `permissionSets`. + * A position name `P` held by the caller is reported when ALL of: + * + * 1. `P` is not already in the request — not named by a held position's + * definition, not directly assigned, not a baseline name. Those grant + * set `P` through the governed channel, so there is nothing to say; + * 2. a permission set named `P` exists, asked of the same three sources the + * resolution reads (the metadata list, the bootstrap sets, the catalog + * loader). A position whose name matches no set has nothing ungranted, + * which keeps built-in identities (`platform_admin`, `org_owner`, …) + * silent. What the probe returns is never granted. + * + * Lazily, on a resolution whose holder carries `P`; once per position NAME + * per plugin instance ({@link nameFoldWarned}), and a restart reports again. + * The name alone is the right key: the catalog the probe reads is + * environment-level, as is the position's definition, so the pair is a + * deployment fact, not an organization's. A name already reported is not + * probed again; an unreported one is probed on each unmemoized resolution + * that carries it — the reads the fold itself made on every such resolution. + * Never for `guest`: the guest envelope judges no position. */ - private reportNameFoldCollisions( + private reportUngrantedNamesakeSets( positions: unknown, - explicitPermissionSets: unknown, - baselineApplied: string[], - resolved: PermissionSet[], - ): void { + requested: readonly string[], + context: any, + ): Promise { // [#25223 cut 8] The body moved to `security-plugin-permission-sets.ts` (see `PermissionSetsHost`). const readLogger = () => this.logger; - const host: Pick = { + const readMetadata = () => this.metadata; + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + bootstrapPermissionSets: this.bootstrapPermissionSets, + dbLoaderForContext: (...a) => this.dbLoaderForContext(...a), get logger() { return readLogger(); }, + get metadata() { return readMetadata(); }, nameFoldWarned: this.nameFoldWarned, + get permissionEvaluator() { return readPermissionEvaluator(); }, }; - reportNameFoldCollisions.call(host, positions, explicitPermissionSets, baselineApplied, resolved); + return reportUngrantedNamesakeSets.call(host, positions, requested, context); } /** diff --git a/packages/verify/src/parent-write-reach.test.ts b/packages/verify/src/parent-write-reach.test.ts index 7c28ff43813..1d59294fa43 100644 --- a/packages/verify/src/parent-write-reach.test.ts +++ b/packages/verify/src/parent-write-reach.test.ts @@ -88,9 +88,11 @@ const source = { }, ], permissions: [manager, rep], + // [ADR-0131 D3/D4; #24757] Each position names its set: a set that only + // shares a position's name is granted by nothing. positions: [ - { name: 'pwr_manager', label: 'Manager' }, - { name: 'pwr_rep', label: 'Rep' }, + { name: 'pwr_manager', label: 'Manager', permissionSets: ['pwr_manager'] }, + { name: 'pwr_rep', label: 'Rep', permissionSets: ['pwr_rep'] }, ], }; diff --git a/scripts/check-position-name-fold-loaders.mjs b/scripts/check-position-name-fold-loaders.mjs deleted file mode 100644 index 578e5a68607..00000000000 --- a/scripts/check-position-name-fold-loaders.mjs +++ /dev/null @@ -1,805 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The vendored HotCRM permissions artifact has NO non-test loader (#13419). - * - * node scripts/check-position-name-fold-loaders.mjs - * node scripts/check-position-name-fold-loaders.mjs --self-test - * - * ## The determination this pins, and why a zero had to become a gate - * - * `scripts/measure-position-name-fold-census.mjs` (slice 1) reports exactly two - * position-name folds in this repository, both `cross_scope`: - * - * sales_rep position <- examples/app-crm/src/security/sales-positions.ts - * set <- packages/metadata/src/__fixtures__/ - * hotcrm-17.1-built-permissions.artifact.json - * sales_manager the same two halves - * - * The maintainer ruling (2026-08-31, verbatim 「同意」) orders those folds - * MATERIALISED as `sys_position_permission_set` rows before the fold is - * deleted. Slice 2 measured the load paths first, and they invert the order: - * the artifact is read by ONE test file and by nothing else, so no deployment - * composes it alongside `examples/app-crm`. The two "dependencies" are a - * collision between an example app's POSITION names and a vendored test - * fixture's PERMISSION-SET names — not grants anything holds. Materialising - * them would MINT two authorizations nothing intends, which is the opposite of - * what the ruling protects. - * - * ⚠️ That determination rests on a LOAD PATH, never on the file's location. - * "It lives under `__fixtures__/`" is a hint; a directory is not an access - * control, and a fixture wired into a real composition tomorrow is a real - * grant tomorrow. So the in-repo materialisation worklist is empty, and this - * gate is what keeps it empty ON PURPOSE rather than by luck: wire the artifact - * into anything that is not a test and this goes RED, instead of two live - * grants quietly appearing on the next boot. - * - * ## What counts as a loader - * - * Anything that names the artifact — an `import`, a `readFileSync`, a glob - * whose pattern names the file or its directory. The scan reads source TEXT, - * so what it checks is a spelling ({@link referencesArtifact}): the file's - * basename; a path that resolves to the fixtures directory once case, `\`, - * `.`, `..` and `//` are folded and a relative path is joined to the - * directory of the file that holds it; or `__fixtures__` with `hotcrm` or - * `.artifact` on one line. A text scan cannot follow a COMPUTED path — a - * concatenation, a `.replace`, a variable, a base other than the holding - * file's directory or the repository root — and this one does not expand a - * wildcard in a directory segment, so a loader that reaches the artifact only - * that way, with neither marker on its line, is outside what this gate sees. - * Two dispositions are allowed and every other reference fails: - * - * TEST a `*.test.ts` / `*.spec.ts` file, or a file under a `test/`, - * `tests/` or `__tests__/` directory. A test composes nothing. - * INSTRUMENT one of {@link DECLARED_INSTRUMENTS} — a measuring script that - * READS the artifact to count it. Declared by exact path, so a new - * reader has to be added here deliberately and can be argued about - * at that moment rather than being credited silently. - * - * ## Why it cannot pass for the wrong reason - * - * A scan that finds nothing and a scan that is broken produce the same zero, so - * the green here is conditioned on three positive facts as well as the absence: - * - * 1. the walker REACHED the known test loader — a green from a walk that - * visited nothing is refused (#4690's shape); - * 2. the artifact still declares permission sets named `sales_rep` and - * `sales_manager` — if it stops, the fold this gate guards is gone and the - * ledger the next slice reads is stale; - * 3. `examples/app-crm` still declares positions of those two names — the - * other half of the same collision. - * - * Facts 2 and 3 are not this gate's subject; they are what make its subject - * meaningful. Either one moving is a real event the next slice's PM must see, - * and the honest way to surface it is a red gate naming the change, not a - * silent green over a premise that has dissolved. - */ - -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, posix, relative, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { isEntrypoint } from './invoked-as.mjs'; - -const REPO_ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); - -/** The artifact whose load paths decide the determination. */ -const ARTIFACT = 'packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json'; -const ARTIFACT_BASENAME = 'hotcrm-17.1-built-permissions.artifact.json'; - -/** The directory the artifact lives in: a reference to it is a reference to the artifact. */ -const FIXTURES_DIR = 'packages/metadata/src/__fixtures__'; - -/** {@link FIXTURES_DIR} in the form rule 2 compares a folded spelling against. */ -const FIXTURES_DIR_FOLDED = FIXTURES_DIR.toLowerCase(); - -/** The two permission-set names that make this artifact load-bearing for #13419. */ -const FOLDED_NAMES = ['sales_rep', 'sales_manager']; - -/** Where the positions of those names are declared — the collision's other half. */ -const POSITION_SOURCE = 'examples/app-crm/src/security/sales-positions.ts'; - -/** - * Non-test readers that are allowed, by exact repo-relative path. A measuring - * instrument reads the artifact to COUNT it; it composes nothing into a - * deployment. Listed rather than pattern-matched so that adding a reader is a - * decision someone makes on this line. - */ -const DECLARED_INSTRUMENTS = [ - 'scripts/measure-position-name-fold-census.mjs', - 'scripts/check-position-name-fold-loaders.mjs', -]; - -/** - * The roots the walk visits, spelled as the subtree population this gate - * DECLARES to `scripts/pm/dispatch-gates.mjs` (the `ROOT_DIR_WATCH_HINTS` - * idiom; `check:watch-hint-literal` holds it to a literal array the extractor - * admits). A literal is how the declaration reaches the derivation: the - * extractor reads source text, and a bare single-segment word (`'packages'`) - * yields no hint at all, so while the walk read a private list of bare words a - * card editing any file under these roots was told nothing about this gate and - * met it first in CI (#24037). - * - * ONE list, two readers: {@link SCAN_ROOTS} is DERIVED from it, so the walk and - * the declaration cannot drift apart. ⛔ Never hand-copy the roots back into a - * second literal — the declaration would then be free to say one thing while - * the walk does another, which is the silent-green shape the derivation exists - * to refuse. Every root is live in the tree: a root the tree does not have - * scans nothing and would be printed by the derivation as a dead lead. - */ -export const ROOT_DIR_WATCH_HINTS = ['packages/**', 'examples/**', 'apps/**', 'scripts/**', '.github/**']; - -/** The directories the walk descends from, read off {@link ROOT_DIR_WATCH_HINTS}. */ -const SCAN_ROOTS = ROOT_DIR_WATCH_HINTS.map((hint) => hint.replace(/\/\*\*$/, '')); - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage', '.git']); - -const TEXT_EXT = new Set([ - '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', - '.json', '.jsonc', '.yml', '.yaml', '.md', '.mdx', '.sh', '.bash', -]); - -/** A test file composes nothing — the one shape allowed to name the artifact. */ -export function isTestFile(relPath) { - const parts = relPath.split('/'); - if (parts.some((p) => p === 'test' || p === 'tests' || p === '__tests__')) return true; - const base = parts[parts.length - 1]; - return /\.(test|spec)\.[cm]?[jt]sx?$/.test(base) || /\.tripwire\.test\./.test(base); -} - -/** - * Classify one reference. Split out from the walk so `--self-test` can drive it - * with the adversarial inputs a clean tree by construction does not contain. - */ -export function classifyReference(relPath) { - if (relPath === ARTIFACT || relPath.startsWith(`${FIXTURES_DIR}/`)) return 'self'; - if (DECLARED_INSTRUMENTS.includes(relPath)) return 'instrument'; - if (isTestFile(relPath)) return 'test'; - return 'loader'; -} - -/** - * Does this file's text name the artifact, its fixtures directory, or a glob - * that would sweep it up? - * - * Three spellings, because a loader has three ways to say the same thing and - * only one of them is a literal path: - * - * 1. the basename — an `import`, a `readFileSync`, a `join(HERE, '…')`; - * 2. the fixtures directory in any spelling that RESOLVES to it - * ({@link namesFixturesDir}) — a config or workflow path, a relative - * climb from the file that holds it, a case variant; - * 3. `__fixtures__` on the SAME LINE as `hotcrm` or `.artifact` — the glob - * shape (`__fixtures__/*.artifact.json`), which names neither the file nor - * the full directory and is invisible to 1 and 2. - * - * Rule 3 is line-scoped deliberately. Asked of a whole file it would credit any - * long file that happens to mention `__fixtures__` in one place and `hotcrm` in - * another, and a gate whose red is a coincidence gets weakened rather than - * obeyed. - * - * Rule 3 does not count a fully spelled path to ANOTHER package's fixtures - * directory: `packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json` - * names one file in one other directory and cannot reach this artifact, while - * the glob shape would red on it. The unit judged is a whole PATH TOKEN - * ({@link PATH_TOKEN}): it starts with `packages/` at the start of the line or - * after a quote, backtick, whitespace, `,`, `[` or `(`, and it runs to the - * next quote, backtick, whitespace, `,`, `]`, `)` or the end of the line, so a - * glob character or `${` inside it stays part of the token. A token is set - * aside only when {@link isOtherPackageFixturePath} says so, which needs all - * three: - * - * - no `..` segment; - * - after `path.posix.normalize` (so `packages/./metadata/…` and - * `packages/metadata/./src/…` are read as the directories they are), it - * does not lie in this package's fixtures directory, judged by the one - * folded comparison rule 2 uses ({@link liesInFixturesDir}); - * - the normalized token is one literal file path, `packages//src/ - * __fixtures__/` with only word characters, `.` and `-` in its - * segments — so a token carrying `*`, `?`, `[`, `{` or `${` is never set - * aside. - * - * Only such a token leaves the line; the rest of the line is still judged, so - * a bare glob beside it still counts. This package's own directory, in any - * spelling rule 2 resolves, is rule 2's, before rule 3 is asked at all. - */ -const PATH_TOKEN = /(?<=^|[\s'"`,[(])packages\/[^\s'"`,\])]*/g; -const LITERAL_OTHER_FIXTURE_FILE = /^packages\/(?:[\w.-]+\/)+src\/__fixtures__\/[\w.-]+(?:\/[\w.-]+)*$/; - -export function isOtherPackageFixturePath(token) { - if (token.split('/').includes('..')) return false; - const normalized = posix.normalize(token); - if (liesInFixturesDir(foldPath(normalized))) return false; - return LITERAL_OTHER_FIXTURE_FILE.test(normalized); -} - -function withoutOtherPackageFixturePaths(line) { - return line.replace(PATH_TOKEN, (token) => (isOtherPackageFixturePath(token) ? '' : token)); -} - -/** - * Rule 2: does this text spell a path that resolves to the fixtures directory, - * or into it? - * - * The repo-relative literal is one spelling of the directory among several, - * and the others reach it just the same: `../../metadata/src/__fixtures__` - * from a file in `packages/objectql/src/`, `packages/Metadata/src/__fixtures__` - * on a case-insensitive filesystem, `packages\metadata\src\__fixtures__` on - * Windows. So every path spelling ({@link PATH_SPELLING}) on a line that - * carries `__fixtures__` is FOLDED ({@link foldPath}) and read two ways: - * - * - as written: the folded spelling holds the directory's four segments in a - * row — a repo-relative path, `ROOT + '/packages/…'`, a URL, a vendored - * prefix, a climb that comes back in; - * - from the file that holds it: a relative spelling, joined to that file's - * own directory, lands on the directory or under it. A relative climb can - * only be resolved against the file that contains it, which is why this - * rule takes `relPath`. - * - * Segments are compared, never substrings: - * `packages/services/service-metadata/src/__fixtures__` contains the string - * `metadata/src/__fixtures__` and is another package's directory. The literal - * repo-relative substring is still asked first, unchanged, so the rule counts - * every file it counted before it learned to fold. - */ -const PATH_SPELLING = /[^\s'"`,;:=+()[\]{}<>|&]+/g; - -/** One path spelling in the form rule 2 compares: `\` read as `/`, `.`, `..` and `//` resolved, case folded. */ -function foldPath(spelling) { - return posix.normalize(spelling.replace(/\\+/g, '/')).toLowerCase(); -} - -/** Is this folded path the fixtures directory, or something under it? */ -function liesInFixturesDir(folded) { - return folded === FIXTURES_DIR_FOLDED || folded.startsWith(`${FIXTURES_DIR_FOLDED}/`); -} - -function namesFixturesDir(text, relPath) { - if (text.includes(FIXTURES_DIR)) return true; - if (!/__fixtures__/i.test(text)) return false; - const holder = posix.dirname(relPath); - for (const line of text.split('\n')) { - if (!line.toLowerCase().includes('__fixtures__')) continue; - for (const spelling of line.match(PATH_SPELLING) ?? []) { - const folded = foldPath(spelling); - if (`/${folded}/`.includes(`/${FIXTURES_DIR_FOLDED}/`)) return true; - if (!folded.startsWith('/') && liesInFixturesDir(foldPath(`${holder}/${spelling}`))) return true; - } - } - return false; -} - -/** - * `relPath` is the repo-relative path of the file `text` was read from, and it - * is required: rule 2 resolves a relative spelling against it, so a caller - * that dropped it would lose every relative climb without a word. - */ -export function referencesArtifact(text, relPath) { - if (typeof relPath !== 'string' || relPath === '') { - throw new TypeError( - 'referencesArtifact(text, relPath): relPath, the repo-relative path of the file holding the text, ' - + 'is required. A relative climb can only be resolved against the file that contains it.', - ); - } - if (text.includes(ARTIFACT_BASENAME) || namesFixturesDir(text, relPath)) return true; - for (const raw of text.split('\n')) { - if (!raw.includes('__fixtures__')) continue; - const line = withoutOtherPackageFixturePaths(raw); - if (!line.includes('__fixtures__')) continue; - if (line.includes('hotcrm') || line.includes('.artifact')) return true; - } - return false; -} - -function walk(root, out) { - let entries; - try { - entries = readdirSync(root, { withFileTypes: true }); - } catch { - return out; - } - for (const e of entries) { - if (e.name.startsWith('.') && e.name !== '.github') continue; - if (SKIP_DIRS.has(e.name)) continue; - const abs = join(root, e.name); - if (e.isDirectory()) { - walk(abs, out); - continue; - } - if (!e.isFile()) continue; - const dot = e.name.lastIndexOf('.'); - if (dot === -1 || !TEXT_EXT.has(e.name.slice(dot))) continue; - out.push(abs); - } - return out; -} - -export function scanTree(root = REPO_ROOT) { - const files = []; - for (const dir of SCAN_ROOTS) walk(join(root, dir), files); - - const references = []; - for (const abs of files) { - let text; - try { - text = readFileSync(abs, 'utf8'); - } catch { - continue; - } - const rel = relative(root, abs).split(sep).join('/'); - if (!referencesArtifact(text, rel)) continue; - references.push({ file: rel, disposition: classifyReference(rel) }); - } - references.sort((a, b) => a.file.localeCompare(b.file)); - return { scanned: files.length, references }; -} - -/** Premise facts 2 and 3 — see the header. Returned, never thrown, so the - * report can print every problem at once. */ -export function premiseFindings(root = REPO_ROOT) { - const findings = []; - - let artifactText = null; - try { - artifactText = readFileSync(join(root, ARTIFACT), 'utf8'); - } catch { - findings.push( - `the artifact ${ARTIFACT} is gone. This gate, and the #13419 census reading it pins, ` - + 'both describe a fold that no longer has a permission-set half. Re-run ' - + '`node scripts/measure-position-name-fold-census.mjs` and retire or re-aim this gate.', - ); - artifactText = null; - } - if (artifactText !== null) { - let names = []; - try { - const parsed = JSON.parse(artifactText); - names = collectPermissionSetNames(parsed); - } catch (e) { - findings.push(`the artifact ${ARTIFACT} did not parse as JSON: ${e.message}`); - } - for (const n of FOLDED_NAMES) { - if (!names.includes(n)) { - findings.push( - `the artifact no longer declares a permission set named '${n}'. The #13419 fold on that ` - + 'name is gone with it, so the census reading this gate guards is stale — re-run the ' - + 'census before trusting any worklist derived from it.', - ); - } - } - } - - let positionText = null; - try { - positionText = readFileSync(join(root, POSITION_SOURCE), 'utf8'); - } catch { - findings.push(`${POSITION_SOURCE} is gone — the position half of both #13419 folds. Re-run the census.`); - } - if (positionText !== null) { - for (const n of FOLDED_NAMES) { - if (!positionText.includes(`'${n}'`)) { - findings.push( - `${POSITION_SOURCE} no longer declares a position named '${n}'. The collision that made ` - + 'the artifact interesting is gone; re-run the census and re-aim this gate.', - ); - } - } - } - - return findings; -} - -/** Every `name` under a `permissions` collection, at any depth. */ -export function collectPermissionSetNames(node, inPermissions = false, out = []) { - if (Array.isArray(node)) { - for (const v of node) collectPermissionSetNames(v, inPermissions, out); - return out; - } - if (!node || typeof node !== 'object') return out; - if (inPermissions && typeof node.name === 'string') out.push(node.name); - for (const [k, v] of Object.entries(node)) { - collectPermissionSetNames(v, inPermissions || k === 'permissions', out); - } - return out; -} - -// --------------------------------------------------------------------------- - -function report(loaders) { - console.error( - `✗ check-position-name-fold-loaders: ${loaders.length} non-test loader(s) of ${ARTIFACT}.\n`, - ); - for (const l of loaders) console.error(` ${l.file}`); - console.error( - '\n This artifact supplies the permission sets `sales_rep` and `sales_manager`. The positions\n' - + ` of those names are declared by ${POSITION_SOURCE}, and\n` - + ' `security-plugin.ts` still folds a POSITION name into the permission-set request\n' - + ' (`const requested = [...positions, ...explicitPermissionSets];`). Composing this artifact\n' - + ' into a deployment that also loads examples/app-crm therefore GRANTS both sets by name,\n' - + ' with no sys_position_permission_set row and no audit line — the ungoverned channel the\n' - + ' 2026-08-31 ruling on #13419 retires.\n\n' - + ' If the load is deliberate, it is an authorization-data change and needs the ruling\n' - + ' applied (执行要点 2: materialise the (position N, set N) junction rows), not a new entry\n' - + ' in DECLARED_INSTRUMENTS. That list is for readers that MEASURE the artifact, never for\n' - + ' code that composes it.', - ); -} - -// Set by `selfTest()` only after its verdict is printed, and read at the -// dispatch: a `return` that leaves the function above that line prints nothing -// and still exits 0 — a self-test that never finished, reported as one that -// passed (#13798). The self-test's own exit code stays load-bearing, so the -// handshake is a flag rather than a returned sentinel. -let selfTestReachedVerdict = false; - -// ── The self-test's own battery roster and floor (#13489) ────────────────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. Closed the -// way PR #13487 validated on check-doc-authoring: what is pinned is the -// registered NAMES, not a number. Every section opens with `battery('')`, -// every assertion is attributed to the battery most recently opened, and the -// floor requires the OPENED set to equal the DECLARED set with each battery at -// or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - 'REVERSE CONTROL. A zero from a scan that cannot report anything is not a': 3, - 'The allowed dispositions, each for its own stated reason.': 5, - 'The text matcher, both directions. A matcher that never matches turns': 29, - 'The permission-set extractor, on the real artifact shape.': 2, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 4; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const seen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - seen.set(b, (seen.get(b) ?? 0) + 1); - }; - let failed = 0; - let cases = 0; - const check = (label, actual, expected) => { - registerCase(); - cases++; - const ok = JSON.stringify(actual) === JSON.stringify(expected); - if (!ok) { - failed++; - console.error(` ✗ ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); - } else { - console.log(` ✓ ${label}`); - } - }; - - console.log('check-position-name-fold-loaders --self-test\n'); - - // ── REVERSE CONTROL. A zero from a scan that cannot report anything is not a - // reading. These are the adversarial inputs a clean tree does not contain. - battery('REVERSE CONTROL. A zero from a scan that cannot report anything is not a'); - check( - 'REVERSE: a non-test loader is a finding', - classifyReference('packages/runtime/src/seed-marketplace-apps.ts'), - 'loader', - ); - check( - 'REVERSE: an app composition entry is a finding', - classifyReference('examples/app-crm/objectstack.config.ts'), - 'loader', - ); - check( - 'REVERSE: a build script that vendors the artifact is a finding', - classifyReference('scripts/build-marketplace-bundle.mjs'), - 'loader', - ); - - // ── The allowed dispositions, each for its own stated reason. - battery('The allowed dispositions, each for its own stated reason.'); - check('test file is allowed', classifyReference('packages/metadata/src/plugin-artifact-forward-conversion.test.ts'), 'test'); - check('file under test/ is allowed', classifyReference('packages/qa/dogfood/test/shared-showcase.ts'), 'test'); - check('declared instrument is allowed', classifyReference('scripts/measure-position-name-fold-census.mjs'), 'instrument'); - check('the artifact itself is self', classifyReference(ARTIFACT), 'self'); - check('a sibling fixture is self', classifyReference(`${FIXTURES_DIR}/hotcrm-17.1-built-bare-root-predicates.artifact.json`), 'self'); - - // ── The text matcher, both directions. A matcher that never matches turns - // every loader into a silent pass, and it is the half a clean tree cannot - // exercise from the negative side alone. - battery('The text matcher, both directions. A matcher that never matches turns'); - // The file a case's text is read as coming from. Rule 2 resolves a relative - // spelling against it, so a case that is ABOUT where the text lives names its - // own file instead. - const PROBE_FILE = 'packages/objectql/src/loader.ts'; - check('matcher sees the basename', referencesArtifact(`join(HERE, '__fixtures__/${ARTIFACT_BASENAME}')`, PROBE_FILE), true); - check('matcher sees a fixtures-directory glob', referencesArtifact(`glob('${FIXTURES_DIR}/*.artifact.json')`, PROBE_FILE), true); - check('matcher ignores an unrelated hotcrm mention', referencesArtifact("// the HotCRM shape from hotcrm#788", PROBE_FILE), false); - check('matcher sees a bare glob sweep of the fixtures dir', referencesArtifact("await glob('**/__fixtures__/*.artifact.json')", PROBE_FILE), true); - check( - 'matcher does NOT credit __fixtures__ and hotcrm on separate lines', - referencesArtifact("import x from './__fixtures__/widget.json';\n// unrelated: hotcrm#788\n", PROBE_FILE), - false, - ); - check( - "matcher does NOT count a full path to another package's fixture", - referencesArtifact(" 'packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json',", PROBE_FILE), - false, - ); - check( - "matcher does NOT count a full path to a nested package's fixture", - referencesArtifact("readFileSync('packages/services/service-x/src/__fixtures__/hotcrm-1.artifact.json')", PROBE_FILE), - false, - ); - check( - 'matcher still sees a bare fixtures glob', - referencesArtifact("globs: ['__fixtures__/*.artifact.json'],", PROBE_FILE), - true, - ); - check( - 'matcher still sees a path into this package\'s own fixtures directory', - referencesArtifact("readFileSync('packages/metadata/src/__fixtures__/x.json')", PROBE_FILE), - true, - ); - check( - 'matcher still sees a bare glob beside another package\'s fixture path on one line', - referencesArtifact("['packages/objectql/src/__fixtures__/a.json', '**/__fixtures__/*.artifact.json']", PROBE_FILE), - true, - ); - check( - 'matcher still sees another package\'s path that climbs back toward this directory', - referencesArtifact("glob('packages/objectql/src/__fixtures__/../../../metadata/src/__fixtures__/*.artifact.json')", PROBE_FILE), - true, - ); - check( - 'matcher still sees a wildcard package path', - referencesArtifact("glob('packages/*/src/__fixtures__/*.artifact.json')", PROBE_FILE), - true, - ); - check( - 'matcher still sees a `.`-segment path to this directory, in a template literal', - referencesArtifact("readFileSync(`packages/./metadata/src/__fixtures__/hotcrm-17.1-${kind}.artifact.json`)", PROBE_FILE), - true, - ); - check( - 'matcher still sees a `.`-segment glob into this directory (package segment)', - referencesArtifact("glob('packages/./metadata/src/__fixtures__/hotcrm-*.artifact.json')", PROBE_FILE), - true, - ); - check( - 'matcher still sees a `.`-segment glob into this directory (src segment)', - referencesArtifact("glob('packages/metadata/./src/__fixtures__/hotcrm-*.artifact.json')", PROBE_FILE), - true, - ); - check( - 'matcher still sees another package\'s fixtures path that a glob continues', - referencesArtifact("glob('packages/objectql/src/__fixtures__/a*.artifact.json')", PROBE_FILE), - true, - ); - check( - 'matcher still sees another package\'s fixtures path that a template continues', - referencesArtifact("readFileSync(`packages/objectql/src/__fixtures__/${name}.artifact.json`)", PROBE_FILE), - true, - ); - check( - 'matcher still sees a fixtures path that does not START a token', - referencesArtifact("readFileSync('vendor/packages/objectql/src/__fixtures__/hotcrm.artifact.json')", PROBE_FILE), - true, - ); - check( - 'matcher still sees a `.`-segment LITERAL path into this directory (only normalization reads it)', - referencesArtifact("readFileSync('packages/./metadata/src/__fixtures__/hotcrm-17.1-built-bare-root-predicates.artifact.json')", PROBE_FILE), - true, - ); - check( - "matcher does NOT count a `.`-segment literal path to another package's fixture", - referencesArtifact(" 'packages/./objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json',", PROBE_FILE), - false, - ); - check( - 'matcher sees a relative climb into this directory, resolved from the file that holds it', - referencesArtifact("readdirSync(resolve(HERE, '../../../metadata/src/__fixtures__'))", 'packages/objectql/src/loaders/catalog.ts'), - true, - ); - check( - 'matcher does NOT count the same climb from a file where it lands in another directory', - referencesArtifact("readdirSync(resolve(HERE, '../../../metadata/src/__fixtures__'))", 'packages/services/service-metadata/src/loaders/catalog.ts'), - false, - ); - check( - 'matcher sees a case variant of this directory', - referencesArtifact("readdirSync('packages/Metadata/src/__fixtures__')", PROBE_FILE), - true, - ); - check( - 'matcher sees a backslash-separated spelling of this directory', - referencesArtifact("readdirSync('packages\\\\metadata\\\\src\\\\__fixtures__')", PROBE_FILE), - true, - ); - check( - "matcher does NOT count service-metadata's fixtures directory, whose path contains the string metadata/src/__fixtures__", - referencesArtifact("readdirSync('packages/services/service-metadata/src/__fixtures__')", PROBE_FILE), - false, - ); - // Rule 3's token runs PAST `${`, `?`, `[` and `{`. Each of the four below - // puts its character in the MIDDLE of a file name: cut there, the token is - // `…/__fixtures__/hotcrm-` (or `x-`), a literal file path that is set aside, - // and the marker left behind has no `__fixtures__` beside it. Right after - // `__fixtures__/` a cut would leave a token ending in `/`, which the literal - // test keeps anyway, so the case would pin nothing. - check( - "matcher still sees a `${kind}` in the middle of a file name in another package's fixtures path", - referencesArtifact("readFileSync(`packages/objectql/src/__fixtures__/hotcrm-${kind}.artifact.json`)", PROBE_FILE), - true, - ); - check( - "matcher still sees a `hotcrm-?` glob in another package's fixtures path", - referencesArtifact("glob('packages/objectql/src/__fixtures__/hotcrm-?.artifact.json')", PROBE_FILE), - true, - ); - check( - "matcher still sees a `hotcrm-[ab]` glob in another package's fixtures path", - referencesArtifact("glob('packages/objectql/src/__fixtures__/hotcrm-[ab].artifact.json')", PROBE_FILE), - true, - ); - check( - "matcher still sees a `{a,hotcrm}` glob in another package's fixtures path", - referencesArtifact("glob('packages/objectql/src/__fixtures__/x-{a,hotcrm}.artifact.json')", PROBE_FILE), - true, - ); - - // ── The permission-set extractor, on the real artifact shape. - battery('The permission-set extractor, on the real artifact shape.'); - check( - 'extractor finds both folded names in the shipped artifact', - FOLDED_NAMES.every((n) => collectPermissionSetNames(JSON.parse(readFileSync(join(REPO_ROOT, ARTIFACT), 'utf8'))).includes(n)), - true, - ); - check( - 'extractor does not credit a name outside a permissions collection', - collectPermissionSetNames({ objects: [{ name: 'crm_lead' }] }), - [], - ); - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { - failed += 1; - console.error(` FAIL ${message}`); - }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of seen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = seen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - if (failed > 0) { - console.error(`\n✗ check-position-name-fold-loaders self-test failed (${failed} of ${cases} case(s)).`); - process.exit(1); - } - console.log(`\n✓ check-position-name-fold-loaders self-test: ${cases} cases pass.`); - selfTestReachedVerdict = true; -} - -function main() { - if (process.argv.includes('--self-test')) { - const selfTestCode = selfTest(); - if (!selfTestReachedVerdict) { - console.error( - '\n✗ check-position-name-fold-loaders self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } - return selfTestCode; - } - - const { scanned, references } = scanTree(); - - if (scanned === 0) { - console.error( - `✗ check-position-name-fold-loaders: the walk visited 0 files under ${SCAN_ROOTS.join(', ')}.\n` - + ' "nothing references the artifact" and "the walk found nothing" are different answers,\n' - + ' and this gate refuses to report the second as the first.', - ); - process.exit(1); - } - - // POSITIVE CONTROL. The known test loader must be seen. Without it a scanner - // that silently stopped matching would report the same clean zero as a tree - // that genuinely has no loader — the exact way this gate would rot. - const sawKnownTestLoader = references.some( - (r) => r.file === 'packages/metadata/src/plugin-artifact-forward-conversion.test.ts' && r.disposition === 'test', - ); - if (!sawKnownTestLoader) { - console.error( - '✗ check-position-name-fold-loaders: the scan did not see the KNOWN reader\n' - + ' packages/metadata/src/plugin-artifact-forward-conversion.test.ts.\n' - + ' Either that test stopped reading the artifact — in which case the artifact may now be\n' - + ' vendored for nothing — or this scan stopped matching, in which case its zero is not a\n' - + ' reading. Both need a human; neither is a pass.', - ); - process.exit(1); - } - - const premise = premiseFindings(); - if (premise.length > 0) { - console.error('✗ check-position-name-fold-loaders: the #13419 premise this gate guards has moved.\n'); - for (const f of premise) console.error(` - ${f}`); - process.exit(1); - } - - const loaders = references.filter((r) => r.disposition === 'loader'); - if (loaders.length > 0) { - report(loaders); - process.exit(1); - } - - const tests = references.filter((r) => r.disposition === 'test'); - const instruments = references.filter((r) => r.disposition === 'instrument'); - console.log( - `✓ check-position-name-fold-loaders: ${ARTIFACT}\n` - + ` has no non-test loader. ${scanned} file(s) scanned under ${SCAN_ROOTS.join(', ')}; ` - + `${references.length} reference(s): ${tests.length} test, ${instruments.length} declared instrument, 0 loader.\n` - + ` ⇒ #13419 执行要点 2's in-repo materialisation worklist is EMPTY, and stays empty by measurement:\n` - + ' the two name-folds the census reports are a collision between an example app\'s position\n' - + ' names and a test fixture\'s permission-set names, not grants any deployment holds.', - ); -} - -if (isEntrypoint(import.meta.url)) { - main(); -} diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 07cf3b05c6c..fe36424cd99 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3531,6 +3531,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/explain-self-direct-grant.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/explain-write-verdict-inputs.test.ts", "verb": "delete", diff --git a/scripts/measure-position-name-fold-census.mjs b/scripts/measure-position-name-fold-census.mjs deleted file mode 100644 index e6fcf147acb..00000000000 --- a/scripts/measure-position-name-fold-census.mjs +++ /dev/null @@ -1,1102 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// measure-position-name-fold-census -- the #13419 census instrument. -// -// node scripts/measure-position-name-fold-census.mjs # census (要点 1) -// node scripts/measure-position-name-fold-census.mjs --json # machine -// node scripts/measure-position-name-fold-census.mjs --self-test # controls only -// node scripts/measure-position-name-fold-census.mjs --audit FILE # report (要点 4) -// node scripts/measure-position-name-fold-census.mjs --audit-schema -// -// It is NOT a gate: not wired into any workflow, exits 0 on any membership -// count, and deliberately not named `check:*` or `gen:*` so the #4203 script -// ledger has nothing to classify -- the shape -// `measure-durability-swallow-family.mjs` established. The only non-zero exits -// are a failing self-test and an unreadable `--audit` input. -// -// ## Why it exists -// -// #13419, maintainer ruling of 2026-08-31 (verbatim 「同意」). The ruling -// retires the name-fold channel in favour of the governed -// `sys_position_permission_set` junction, and orders the work 「顺序即依赖」: -// -// 1. 普查先行 -- census every binding that depends on name-based resolution, -// WITH POSITIVE CONTROLS. (This script's default mode.) -// 2. 物化 -- materialise the legitimate ones as junction rows. ⛔ 不物化不删折叠. -// 3. 删折叠 -- delete the fold, warn on collisions. -// 4. 隐式授予审计报告 -- per organization, the grants actually in effect via -// name-match with NO junction row. (This script's `--audit` mode.) -// -// This script is step 1 and the generator for step 4. It changes no resolution -// behaviour and reads no source file it does not print a citation for. -// -// ## The mechanism being measured (re-located by SYMBOL, never by line number) -// -// `SecurityPlugin.resolvePermissionSetsForContextUnmemoized` -// (packages/plugins/plugin-security/src/security-plugin.ts): -// -// const positions = context?.positions ?? []; -// const explicitPermissionSets = context?.permissions ?? []; -// const requested = [...positions, ...explicitPermissionSets]; -// -// `requested` is then handed to `PermissionEvaluator.resolvePermissionSets`, -// which matches NAMES against (a) `metadataService.list('permission')`, -// (b) the plugin-owned `bootstrapPermissionSets`, (c) a `sys_permission_set` -// db loader. So a position NAME that equals a permission set NAME grants that -// set -- with no junction row, no declaration, no audit trail. -// -// The upstream deactivation sweep documents the same fold from the other side -// (packages/core/src/security/resolve-authz-context.ts, step 6a): a deactivated -// position's NAME is dropped from `grants.positions` too, "because -// resolvePermissionSetsForContext requests positions as permission-set NAMES -// (position names are commonly reused as set names)". That comment is the -// ruling's 「上游停用清扫的注释自证知情」. -// -// The GOVERNED channel is the other half of step 6a: active `sys_position` ids -// -> `sys_position_permission_set` rows -> `sys_permission_set` rows -> names. -// -// ## The predicate, stated so it can be argued with -// -// A declared position name N is a NAME-FOLD DEPENDENCY when both hold: -// -// 1. COLLISION -- some permission set is declared under the same name N. -// 2. NO JUNCTION -- nothing in this repository creates a -// `sys_position_permission_set` row binding position N to set N. -// -// Then every principal holding position N resolves permission set N, and the -// junction table shows nothing. Delete the fold without materialising that -// row (要点 2) and the grant disappears silently -- which is the whole reason -// the ruling put the census first. -// -// Two collision SCOPES are reported separately, because they answer different -// questions for 要点 2: -// -// - `intra_scope` -- one artifact declares both halves. A fold in EVERY -// deployment of that artifact. -// - `cross_scope` -- position from artifact X, set from artifact Y. A fold -// in every deployment where BOTH are installed. The fold -// matches a bare name with no namespace and no app -// scoping, so this is a real channel, not a modelling -// artefact -- it is the ruling's 「建一个与既有 position -// 同名的 permission set,即可让持有该 position 的所有主体 -// 获得它」 spelled as two packages instead of one. -// -// ## What this instrument CANNOT see, stated up front -// -// Positions and permission sets are RUNTIME rows. This census reads -// DECLARATIONS in this repository. An operator who creates a position in Setup -// named after an existing permission set produces a name-fold that no static -// census can ever see. That population is measurable only against a real -// deployment -- which is exactly what `--audit` consumes, and why `--audit` -// refuses to call an empty input "zero". -// -// ⚠️ A zero from the static census is a zero over DECLARATIONS, never over a -// deployment. The report prints that sentence next to every zero it emits. - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '..'); -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']); - -/* ------------------------------------------------------------------------- * - * Corpus scan - * ------------------------------------------------------------------------- */ - -/** Every file under `root` whose extension is in `exts`, repo-relative. */ -function walk(root, exts, out = []) { - let entries; - try { - entries = fs.readdirSync(root, { withFileTypes: true }); - } catch { - return out; - } - for (const e of entries) { - if (e.name.startsWith('.') && e.name !== '.claude') continue; - const full = path.join(root, e.name); - if (e.isDirectory()) { - if (SKIP_DIRS.has(e.name)) continue; - walk(full, exts, out); - } else if (exts.has(path.extname(e.name))) { - out.push(path.relative(REPO_ROOT, full)); - } - } - return out; -} - -/** - * The declaring ARTIFACT a file belongs to -- the unit that gets installed - * together, and therefore the unit a collision is `intra_scope` within. - */ -function scopeOf(relFile) { - if (relFile.startsWith('examples/')) return relFile.split('/').slice(0, 2).join('/'); - if (relFile.includes('__fixtures__/hotcrm')) return 'artifact:hotcrm'; - if (relFile.startsWith('packages/spec/') || relFile.startsWith('packages/plugins/plugin-security/')) return 'platform'; - return relFile.split('/').slice(0, 2).join('/'); -} - -/** `export const IDENT = 'literal';` across the two packages that own the names. */ -function scanStringConstants() { - const consts = new Map(); - const files = [ - ...walk(path.join(REPO_ROOT, 'packages/spec/src'), new Set(['.ts'])), - ...walk(path.join(REPO_ROOT, 'packages/plugins/plugin-security/src'), new Set(['.ts'])), - ]; - for (const rel of files) { - if (rel.includes('.test.')) continue; - const src = read(rel); - const re = /export const ([A-Z][A-Z0-9_]*)\s*(?::[^=]+)?=\s*'([^']*)'/g; - let m; - while ((m = re.exec(src)) !== null) consts.set(m[1], m[2]); - } - return consts; -} - -function read(rel) { - try { - return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); - } catch { - return ''; - } -} - -function lineOf(src, index) { - return src.slice(0, index).split('\n').length; -} - -/** - * The text of the balanced object literal whose `{` is at or after `from`. - * - * ⚠️ A fixed-size window is NOT good enough here, and the first run of this - * script proved it: a 4000-character look-ahead from `showcase_ops` reached the - * `isDefault: true` belonging to `showcase_member_default` ~90 lines later, and - * the census reported two junction bindings (`everyone -> showcase_ops`, - * `everyone -> showcase_client_liaison`) that no code creates. Over-collection - * on the JUNCTION side is the dangerous direction: it marks a pair governed and - * suppresses the name-fold that would otherwise be reported. - * - * Strings and comments are skipped so a brace inside either cannot unbalance - * the count. - */ -function objectLiteralAt(src, from) { - const start = src.indexOf('{', from); - if (start === -1) return ''; - let depth = 0; - for (let i = start; i < src.length; i += 1) { - const ch = src[i]; - if (ch === '/' && src[i + 1] === '/') { - i = src.indexOf('\n', i); - if (i === -1) break; - continue; - } - if (ch === '/' && src[i + 1] === '*') { - const end = src.indexOf('*/', i + 2); - if (end === -1) break; - i = end + 1; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - for (let j = i + 1; j < src.length; j += 1) { - if (src[j] === '\\') { j += 1; continue; } - if (src[j] === ch) { i = j; break; } - } - continue; - } - if (ch === '{') depth += 1; - else if (ch === '}') { - depth -= 1; - if (depth === 0) return src.slice(start, i + 1); - } - } - return src.slice(start); -} - -/** - * The first `name:` inside the declaration's own object literal. - * Both `definePosition`/`definePermissionSet` and the platform's - * `PermissionSetSchema.parse({...})` put it first, and a nested `name:` - * (an RLS rule inside a permission set) is therefore never the first one. - */ -function firstNameAfter(src, from, consts) { - const m = /name:\s*(?:'([^']*)'|"([^"]*)"|([A-Z][A-Z0-9_]*))/.exec(objectLiteralAt(src, from)); - if (!m) return null; - if (m[1] ?? m[2]) return m[1] ?? m[2]; - return consts.get(m[3]) ?? null; -} - -/** Whether the declaration's OWN object literal declares `isDefault: true`. */ -function isDefaultAfter(src, from) { - return /isDefault:\s*true/.test(objectLiteralAt(src, from)); -} - -function scanDeclarations(consts) { - const positions = new Map(); - const permissionSets = new Map(); - const add = (map, name, entry) => { - if (!name) return; - if (!map.has(name)) map.set(name, []); - map.get(name).push(entry); - }; - - // 1. Built-in identities and audience anchors -- the platform's own roster. - for (const ident of ['BUILTIN_IDENTITY_PLATFORM_ADMIN', 'BUILTIN_IDENTITY_ORG_OWNER', - 'BUILTIN_IDENTITY_ORG_ADMIN', 'BUILTIN_IDENTITY_ORG_MEMBER', 'EVERYONE_POSITION', 'GUEST_POSITION']) { - add(positions, consts.get(ident), { - scope: 'platform', kind: 'builtin', - file: 'packages/spec/src/identity (BUILTIN_IDENTITY_NAMES / AUDIENCE_ANCHOR_POSITIONS)', line: 0, - }); - } - - // 2. `definePosition` / `definePermissionSet` call sites, anywhere. - const tsFiles = [ - ...walk(path.join(REPO_ROOT, 'packages'), new Set(['.ts'])), - ...walk(path.join(REPO_ROOT, 'examples'), new Set(['.ts'])), - ...walk(path.join(REPO_ROOT, 'apps'), new Set(['.ts'])), - ]; - for (const rel of tsFiles) { - if (rel.includes('.test.') || rel.includes('.d.ts')) continue; - const src = read(rel); - for (const [needle, map, kind] of [ - ['definePosition({', positions, 'declared'], - ['definePermissionSet({', permissionSets, 'declared'], - ]) { - let i = src.indexOf(needle); - while (i !== -1) { - const name = firstNameAfter(src, i, consts); - add(map, name, { - scope: scopeOf(rel), kind, file: rel, line: lineOf(src, i), - ...(map === permissionSets ? { isDefault: isDefaultAfter(src, i) } : {}), - }); - i = src.indexOf(needle, i + 1); - } - } - } - - // 3. The platform's shipped permission sets. - const dps = 'packages/plugins/plugin-security/src/objects/default-permission-sets.ts'; - const dpsSrc = read(dps); - let j = dpsSrc.indexOf('PermissionSetSchema.parse({'); - while (j !== -1) { - add(permissionSets, firstNameAfter(dpsSrc, j, consts), { - scope: 'platform', kind: 'builtin', file: dps, line: lineOf(dpsSrc, j), - isDefault: isDefaultAfter(dpsSrc, j), - }); - j = dpsSrc.indexOf('PermissionSetSchema.parse({', j + 1); - } - - // 4. Composed app artifacts -- a built app as it ships, the shape the - // #13419 observation came from. In-repo these are metadata fixtures; - // the marketplace's own artifacts are NOT here (see NOT MEASURED). - for (const rel of walk(path.join(REPO_ROOT, 'packages'), new Set(['.json']))) { - if (!rel.includes('artifact')) continue; - let doc; - try { - doc = JSON.parse(read(rel)); - } catch { - continue; - } - for (const [key, map, kind] of [ - ['positions', positions, 'artifact'], - ['permissions', permissionSets, 'artifact'], - ]) { - if (!Array.isArray(doc?.[key])) continue; - for (const item of doc[key]) { - add(map, item?.name, { - scope: scopeOf(rel), kind, file: rel, line: 0, - ...(map === permissionSets ? { isDefault: item?.isDefault === true } : {}), - }); - } - } - } - - return { positions, permissionSets }; -} - -/** - * Junction rows this repository creates. Two producers exist, both located by - * reading the code (never by name-matching): - * - * - an app's imperative binder, exporting a `[position, set]` tuple list - * (`examples/app-showcase/src/security/bind-position-sets.ts`); - * - `SecurityPlugin`'s `bindBaselineToEveryone` (ADR-0090 D5), which binds the - * configured baseline set(s) to each organization's `everyone` anchor, plus - * the same auto-bind for an app's own `isDefault` set. - */ -function scanJunctionBindings(consts, permissionSets) { - const bindings = []; - const everyone = consts.get('EVERYONE_POSITION') ?? 'everyone'; - - // ⚠️ Anchored on the tuple TYPE, not on the constant's NAME. The first - // version of this scan looked for the identifier - // `POSITION_PERMISSION_SET_BINDINGS` -- which is what app-showcase happens to - // call its list. app-crm calls the identical structure `BINDINGS`, so three - // real junction rows (sales_rep / sales_manager / finance_approver -> - // crm_sales_user) were invisible, and `finance_approver` was reported INERT - // while it is in fact bound. Under-collection on the junction side is how a - // census hands 要点 2 a worklist that deletes a live grant. - // - // The labelled tuple `[position: string, permissionSet: string]` is what both - // binders actually share, and it is a declaration a new binder cannot omit - // without also giving up the type. - const TUPLE_TYPE = /\[\s*position:\s*string\s*,\s*permissionSet:\s*string\s*\]/; - for (const rel of [ - ...walk(path.join(REPO_ROOT, 'examples'), new Set(['.ts'])), - ...walk(path.join(REPO_ROOT, 'packages'), new Set(['.ts'])), - ...walk(path.join(REPO_ROOT, 'apps'), new Set(['.ts'])), - ]) { - if (rel.includes('.test.')) continue; - const src = read(rel); - const typed = TUPLE_TYPE.exec(src); - const named = src.indexOf('POSITION_PERMISSION_SET_BINDINGS'); - const anchor = typed ? typed.index : named; - if (anchor === -1 || anchor === undefined) continue; - const open = src.indexOf('[', src.indexOf('=', anchor)); - const close = src.indexOf('];', open); - if (open === -1 || close === -1) continue; - const re = /\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/g; - let m; - while ((m = re.exec(src.slice(open, close))) !== null) { - bindings.push({ - position: m[1], set: m[2], scope: scopeOf(rel), file: rel, - line: lineOf(src, anchor), via: 'app binder (kernel:bootstrapped)', - }); - } - } - - // The ADR-0090 D5 baseline auto-bind. `PLATFORM_BASELINE_PERMISSION_SET` is - // the configured default; a deployment may configure others, which is why the - // audit report never assumes this is the only junction row it will meet. - const baseline = consts.get('PLATFORM_BASELINE_PERMISSION_SET'); - if (baseline) { - bindings.push({ - position: everyone, set: baseline, scope: 'platform', - file: 'packages/plugins/plugin-security/src/security-plugin.ts', - line: 0, via: 'bindBaselineToEveryone (ADR-0090 D5)', - }); - } - for (const [name, decls] of permissionSets) { - for (const d of decls) { - if (d.isDefault !== true) continue; - bindings.push({ - position: everyone, set: name, scope: d.scope, file: d.file, line: d.line, - via: 'isDefault auto-bind / audience-binding suggestion (ADR-0090 D5/D9)', - }); - } - } - return bindings; -} - -/* ------------------------------------------------------------------------- * - * The census - * ------------------------------------------------------------------------- */ - -function census() { - const consts = scanStringConstants(); - const { positions, permissionSets } = scanDeclarations(consts); - const bindings = scanJunctionBindings(consts, permissionSets); - const bound = new Set(bindings.map((b) => `${b.position}::${b.set}`)); - - const nameFolds = []; - const junctionOnSameName = []; - for (const [name, posDecls] of positions) { - const setDecls = permissionSets.get(name); - if (!setDecls) continue; - const record = { - name, - positions: posDecls.map((d) => ({ scope: d.scope, kind: d.kind, file: d.file, line: d.line })), - permissionSets: setDecls.map((d) => ({ scope: d.scope, kind: d.kind, file: d.file, line: d.line })), - collision: posDecls.some((p) => setDecls.some((s) => s.scope === p.scope)) ? 'intra_scope' : 'cross_scope', - }; - if (bound.has(`${name}::${name}`)) junctionOnSameName.push(record); - else nameFolds.push(record); - } - - // A declared position that neither carries a junction binding nor collides - // with a set name grants nothing beyond the baseline. Not a name-fold -- but - // it IS the population 要点 3's warning must not fire on, so the census - // reports it rather than dropping it. - const inert = []; - for (const [name, decls] of positions) { - if (permissionSets.has(name)) continue; - if (bindings.some((b) => b.position === name)) continue; - inert.push({ name, declarations: decls.map((d) => ({ scope: d.scope, file: d.file, line: d.line })) }); - } - - return { - positions, permissionSets, bindings, nameFolds, junctionOnSameName, inert, - stats: { - positionNames: positions.size, - permissionSetNames: permissionSets.size, - junctionBindings: bindings.length, - nameFolds: nameFolds.length, - inertPositions: inert.length, - }, - }; -} - -/* ------------------------------------------------------------------------- * - * 要点 4 -- the implicit-grant audit report - * ------------------------------------------------------------------------- */ - -const AUDIT_SCHEMA = `Input for \`--audit\` -- one export per deployment, organizations kept apart: - -{ - "source": "REQUIRED provenance: which deployment, which pin, who exported it", - "organizations": [ - { - "id": "org_...", - "name": "optional label", - "positions": [ { "id": "pos_...", "name": "sales_manager", "active": true } ], - "permissionSets": [ { "id": "ps_...", "name": "sales_manager", "active": true } ], - "junction": [ { "position_id": "pos_...", "permission_set_id": "ps_..." } ], - "assignments": [ { "user_id": "u_...", "position_id": "pos_..." } ] - } - ] -} - -positions <- SELECT id, name, active FROM sys_position WHERE organization_id = :org -permissionSets <- SELECT id, name, active FROM sys_permission_set WHERE organization_id = :org -junction <- SELECT position_id, permission_set_id FROM sys_position_permission_set -assignments <- SELECT user_id, position_id FROM sys_user_position (OPTIONAL: blast radius) - -\`assignments\` is optional. Without it the report still names every implicit -grant; with it, each one carries how many principals hold it today. - -⚠️ An organization whose \`positions\` or \`permissionSets\` is empty is reported -NOT_MEASURED, never "zero implicit grants" -- an empty dev database and a clean -deployment are indistinguishable at the query, and reading one as the other is -the failure #12775 is stuck on.`; - -/** - * The report. Mirrors the runtime exactly: - * - a DEACTIVATED position grants nothing (ADR-0049, resolve-authz-context 6a - * drops its name from `grants.positions` as well as its junction rows); - * - a DEACTIVATED permission set grants nothing (ADR-0049, step 6b); - * - `everyone` is held implicitly by every authenticated member (step 5), so - * an implicit grant on `everyone` reaches the whole organization. - */ -function auditImplicitGrants(input) { - const orgs = Array.isArray(input?.organizations) ? input.organizations : null; - if (!orgs) { - return { status: 'NOT_MEASURED', reason: 'input has no `organizations` array', organizations: [] }; - } - if (orgs.length === 0) { - return { - status: 'NOT_MEASURED', - reason: 'input carries ZERO organizations -- an export that reached no tenant, not a deployment without implicit grants', - organizations: [], - }; - } - - const results = orgs.map((org) => { - const positions = Array.isArray(org?.positions) ? org.positions : []; - const sets = Array.isArray(org?.permissionSets) ? org.permissionSets : []; - const junction = Array.isArray(org?.junction) ? org.junction : []; - const assignments = Array.isArray(org?.assignments) ? org.assignments : null; - - if (positions.length === 0 || sets.length === 0) { - return { - organization: org?.id ?? '(unidentified)', - status: 'NOT_MEASURED', - reason: `export carries ${positions.length} position row(s) and ${sets.length} permission-set row(s)` - + ' -- at least one table came back empty, so a zero here would be indistinguishable from an empty database', - implicitGrants: [], - }; - } - - const isActive = (r) => r?.active !== false && r?.is_active !== false; - const boundPairs = new Set(junction.map((r) => `${r?.position_id}::${r?.permission_set_id ?? r?.permissionSetId}`)); - const holders = assignments - ? assignments.reduce((m, a) => m.set(a?.position_id, (m.get(a?.position_id) ?? 0) + 1), new Map()) - : null; - - const implicitGrants = []; - for (const p of positions) { - if (!isActive(p)) continue; - for (const s of sets) { - if (s?.name !== p?.name || !isActive(s)) continue; - if (boundPairs.has(`${p.id}::${s.id}`)) continue; - implicitGrants.push({ - name: p.name, - positionId: p.id, - permissionSetId: s.id, - channel: 'name-fold (requested = [...positions, ...explicitPermissionSets])', - ...(p.name === 'everyone' - ? { reach: 'EVERY authenticated member of this organization (everyone is held implicitly)' } - : {}), - ...(holders ? { principalsHolding: holders.get(p.id) ?? 0 } : {}), - }); - } - } - - return { - organization: org?.id ?? '(unidentified)', - status: 'MEASURED', - counted: { positions: positions.length, permissionSets: sets.length, junctionRows: junction.length }, - blastRadiusMeasured: Boolean(assignments), - implicitGrants, - }; - }); - - const measured = results.filter((r) => r.status === 'MEASURED'); - return { - status: measured.length === 0 ? 'NOT_MEASURED' : 'MEASURED', - ...(measured.length === 0 ? { reason: 'no organization in this export was measurable' } : {}), - source: input?.source ?? null, - organizations: results, - }; -} - -/* ------------------------------------------------------------------------- * - * Declared controls -- asserted before ANY reading is printed - * ------------------------------------------------------------------------- */ - -/** - * The two anchors the ruling names, plus the parse-integrity controls that stop - * a drifted scanner from reporting a quiet zero. - * - * Chosen by reading the code before the instrument was first run: a control - * read off the output would only prove the instrument agrees with itself. - */ -const CONTROLS = { - /** Ruling anchor A: HAS a junction row -- must NOT be reported as a name-dependency. */ - junctionAnchor: { - position: 'everyone', - set: 'member_default', - why: 'ADR-0090 D5. `bindBaselineToEveryone` in security-plugin.ts inserts this' - + ' `sys_position_permission_set` row per organization. It is the one row the #13419' - + ' observation found in the junction table, and the census must classify it as a JUNCTION' - + ' binding. It must also never appear as a name-fold -- and not merely because the two' - + ' names differ: the pair must be present in the junction output.', - }, - /** - * Ruling anchor B: relies on the NAME -- must be DETECTED. - * - * ⚠️ TO WHOEVER LANDS 要点 2. This control pins TODAY's state: `sales_manager` - * is a name-fold *because nothing binds it yet*. The moment you materialise - * its junction row, the pair moves from `nameFolds` to `junctionOnSameName` - * and this control goes RED -- correctly. That is the ledger noticing the - * world changed, not a broken instrument. Measured both directions before - * this shipped: deleting the artifact's set half makes the control fail and - * the census refuse to print; adding `['sales_rep','sales_rep']` to an app - * binder moves `sales_rep` out of `nameFolds` (2 -> 1) and into - * `junctionOnSameName`. Re-point this anchor at a pair still on the fold, or - * retire it with the fold itself in 要点 3. - */ - nameFoldAnchor: { - name: 'sales_manager', - why: 'The #13419 observation: a principal holding the `sales_manager` POSITION resolves the' - + ' `sales_manager` PERMISSION SET with no junction row. In-repo both halves are declared --' - + ' the position by examples/app-crm, the set by the composed HotCRM artifact fixture -- so' - + ' the detector fires on real declarations, not on a fixture written to make it fire.', - }, - /** Negative: junction-bound under DIFFERENT names -- must not be a name-fold. */ - negative: { - position: 'contributor', - set: 'showcase_contributor', - why: 'app-showcase binds every persona through `POSITION_PERMISSION_SET_BINDINGS`, and its sets' - + ' are prefixed so no name collides. If this pair ever shows up as a name-fold, the' - + ' collision predicate has stopped requiring a collision.', - }, - /** Parse integrity: a drifted scanner must go red, not quiet. */ - builtinPositions: ['everyone', 'guest', 'platform_admin', 'org_member'], - builtinPermissionSets: ['admin_full_access', 'organization_admin', 'member_default', 'viewer_readonly'], - /** - * Regression control -- the `isDefault` window bug this instrument shipped - * with and its first run exposed. `showcase_member_default` is the ONLY - * app-showcase set declaring `isDefault: true`; a look-ahead that is not - * bounded by the declaration's own object literal reaches it from - * `showcase_ops` ~90 lines earlier and invents an `everyone -> showcase_ops` - * junction binding. Over-collection on the junction side marks a pair - * governed and SUPPRESSES a name-fold, so it must stay red-able. - */ - isDefaultBleed: { bound: 'showcase_member_default', unbound: ['showcase_ops', 'showcase_client_liaison'] }, - /** - * Second binder control -- a DIFFERENT app, whose binding list is named - * `BINDINGS`, not `POSITION_PERMISSION_SET_BINDINGS`. The first version of the - * binder scan keyed on that identifier and silently missed all three of - * app-crm's rows, reporting `finance_approver` as an INERT position while it - * is bound. One binder is not a population: if this control ever goes red, - * the scan has gone back to recognising one app's spelling. - * - * It also pins the distinction 要点 2 turns on: `sales_manager` IS junction - * bound (to `crm_sales_user`) and is STILL a name-fold, because the fold is - * about the pair (position N, set N) -- binding N to some other set does not - * retire it. - */ - secondBinder: { position: 'finance_approver', set: 'crm_sales_user' }, -}; - -/** Synthetic inputs -- they exercise the `--audit` GENERATOR, never a reading. */ -const AUDIT_CONTROLS = [ - { - label: 'collision with no junction row => one implicit grant', - input: { - source: 'self-test fixture', - organizations: [{ - id: 'org_a', - positions: [{ id: 'p1', name: 'sales_manager', active: true }], - permissionSets: [{ id: 's1', name: 'sales_manager', active: true }], - junction: [], - assignments: [{ user_id: 'u1', position_id: 'p1' }, { user_id: 'u2', position_id: 'p1' }], - }], - }, - expect: (r) => r.status === 'MEASURED' - && r.organizations[0].implicitGrants.length === 1 - && r.organizations[0].implicitGrants[0].principalsHolding === 2, - }, - { - label: 'same collision WITH a junction row => governed, zero implicit grants', - input: { - source: 'self-test fixture', - organizations: [{ - id: 'org_b', - positions: [{ id: 'p1', name: 'sales_manager', active: true }], - permissionSets: [{ id: 's1', name: 'sales_manager', active: true }], - junction: [{ position_id: 'p1', permission_set_id: 's1' }], - }], - }, - expect: (r) => r.status === 'MEASURED' && r.organizations[0].implicitGrants.length === 0, - }, - { - label: 'DEACTIVATED position grants nothing (ADR-0049)', - input: { - source: 'self-test fixture', - organizations: [{ - id: 'org_c', - positions: [{ id: 'p1', name: 'sales_manager', active: false }], - permissionSets: [{ id: 's1', name: 'sales_manager', active: true }], - junction: [], - }], - }, - expect: (r) => r.status === 'MEASURED' && r.organizations[0].implicitGrants.length === 0, - }, - { - label: 'EMPTY export reads NOT_MEASURED, never "zero implicit grants" (the #12775 shape)', - input: { source: 'self-test fixture', organizations: [{ id: 'org_d', positions: [], permissionSets: [], junction: [] }] }, - expect: (r) => r.status === 'NOT_MEASURED' && r.organizations[0].status === 'NOT_MEASURED', - }, - { - label: 'export with zero organizations reads NOT_MEASURED', - input: { source: 'self-test fixture', organizations: [] }, - expect: (r) => r.status === 'NOT_MEASURED', - }, -]; - -// Set by `selfTest()` only after its verdict is printed, and read at the -// dispatch: a `return` that leaves the function above that line prints nothing -// and still exits 0 — a self-test that never finished, reported as one that -// passed (#13798). The self-test's own exit code stays load-bearing, so the -// handshake is a flag rather than a returned sentinel. -let selfTestReachedVerdict = false; - -// -- The self-test's own battery roster and floor (#13489) ------------------ -// -// Reaching the verdict used to be this self-test's ONLY success condition, so -// "every control held" and "the controls never ran" printed the same line. -// Closed the way PR #13487 validated on check-doc-authoring: what is pinned is -// the registered NAMES, not a number. -// -// This file's assertion sites are `if (...) problems.push(...)` -- a -// FAILURE-ONLY sink. Routing `problems.push` itself through `registerCase()` -// would register a case only when a control FAILS: a fully green run would -// register 0 and the battery would read DID NOT RUN, the floor inverted rather -// than installed. So the sites are counted by the `check(() => { ... })` THUNK -// PR #15198 measured: the existing `if (...) problems.push(...)` is carried in -// VERBATIM, no condition is touched, and registration happens whether or not -// the site fires. -// -// This file declares ONE battery, opened at the top of the self-test body: it -// carries ZERO named section banners, fewer than the two the sectioning -// criterion needs, and a comment is NOT promoted to a section head. The hoisted -// single battery is the shape PR #14896, PR #15003 and PR #15217 landed. -// -// ⭐ ONE site stays OUTSIDE the roster, deliberately: the `catch` arm of the -// AUDIT_CONTROLS loop (`audit control threw: ...`) ends in `continue`, which is -// illegal inside the thunk's arrow function. Wrapping it would mean rewriting -// that control flow -- exactly what the verbatim rule forbids. Its sibling in -// the same loop body, `if (!ac.expect(result))`, IS floored, so a loop that -// stops running still reds. This is the residue recipe A's non-table -// assertions carry too (PR #15286's `extra` call). -// -// The count is a FLOOR, not an equality -- adding controls is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering, never to lower the number. Four -// of the thirteen floored sites sit inside `for` loops, so the count is the -// number of times a site is REACHED on a run, measured on a run. -const SELF_TEST_BATTERIES = Object.freeze({ - 'measure-position-name-fold-census self-test': 24, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 1; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -function selfTest({ quiet = false } = {}) { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every site below is attributed to the one most - // recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const batterySeen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); - }; - battery('measure-position-name-fold-census self-test'); - // The thunk: it registers the case and then runs the existing site VERBATIM, - // so no control's condition is inverted or rewritten and the `problems` sink - // keeps its own semantics. - const check = (fn) => { - registerCase(); - fn(); - }; - const problems = []; - const c = census(); - - for (const name of CONTROLS.builtinPositions) { - check(() => { - if (!c.positions.has(name)) { - problems.push(`parse-integrity: built-in POSITION \`${name}\` was not found. The scanner has drifted` - + ' off the declaration shape; every number below it would be an under-count.'); - } - }); - } - for (const name of CONTROLS.builtinPermissionSets) { - check(() => { - if (!c.permissionSets.has(name)) { - problems.push(`parse-integrity: built-in PERMISSION SET \`${name}\` was not found. Same failure, other axis.`); - } - }); - } - - const a = CONTROLS.junctionAnchor; - check(() => { - if (!c.bindings.some((b) => b.position === a.position && b.set === a.set)) { - problems.push(`positive control (junction) NOT classified as a junction binding: ` - + `${a.position} -> ${a.set}\n ${a.why}`); - } - }); - check(() => { - if (c.nameFolds.some((f) => f.name === a.position)) { - problems.push(`positive control (junction) was reported as a NAME-DEPENDENCY: ${a.position}\n ${a.why}`); - } - }); - - const b = CONTROLS.nameFoldAnchor; - const fold = c.nameFolds.find((f) => f.name === b.name); - check(() => { - if (!fold) { - problems.push(`positive control (name-fold) NOT detected: ${b.name}\n ${b.why}`); - } else if (!fold.permissionSets.some((s) => s.kind === 'artifact')) { - problems.push(`positive control (name-fold) ${b.name} was detected, but its permission-set half is not` - + ' the composed artifact -- the control is passing for the wrong reason.'); - } - }); - - const n = CONTROLS.negative; - check(() => { - if (c.nameFolds.some((f) => f.name === n.position)) { - problems.push(`negative control reported as a name-fold: ${n.position}\n ${n.why}`); - } - }); - check(() => { - if (!c.bindings.some((x) => x.position === n.position && x.set === n.set)) { - problems.push(`negative control ${n.position} -> ${n.set} was not seen as a junction binding at all;` - + ' the binder scan is blind, so every junction-bound pair would be misfiled as unbound.'); - } - }); - - const sb = CONTROLS.secondBinder; - check(() => { - if (!c.bindings.some((x) => x.position === sb.position && x.set === sb.set)) { - problems.push(`second-binder control missed: \`${sb.position} -> ${sb.set}\` is a junction binding declared by` - + ' a binder whose list is NOT named POSITION_PERMISSION_SET_BINDINGS. The scan is recognising one app\'s' - + ' spelling again, so other apps\' junction rows are invisible and their positions read as inert.'); - } - }); - check(() => { - if (c.inert.some((i) => i.name === sb.position)) { - problems.push(`second-binder control: \`${sb.position}\` was reported INERT while a binder binds it.`); - } - }); - check(() => { - if (!c.nameFolds.some((f) => f.name === 'sales_manager')) { - problems.push('a position junction-bound to a DIFFERENT set must still count as a name-fold on its own name' - + ' (sales_manager is bound to crm_sales_user and still folds onto the HotCRM `sales_manager` set).' - + ' Losing this would tell 要点 2 the pair is already governed.'); - } - }); - - const bleed = CONTROLS.isDefaultBleed; - check(() => { - if (!c.bindings.some((x) => x.position === 'everyone' && x.set === bleed.bound)) { - problems.push(`regression control: \`${bleed.bound}\` declares \`isDefault: true\` and must appear as an` - + ' `everyone` auto-bind. It does not, so the isDefault scan has stopped seeing declarations it owns.'); - } - }); - for (const set of bleed.unbound) { - check(() => { - if (c.bindings.some((x) => x.position === 'everyone' && x.set === set)) { - problems.push(`regression control fired again: \`everyone -> ${set}\` was reported as a junction binding,` - + ` but ${set} declares no \`isDefault\`. The isDefault look-ahead is reading past the declaration's own` - + ' object literal again -- see CONTROLS.isDefaultBleed.'); - } - }); - } - - for (const ac of AUDIT_CONTROLS) { - let result; - try { - result = auditImplicitGrants(ac.input); - } catch (e) { - problems.push(`audit control threw: ${ac.label} -- ${e?.message}`); - continue; - } - check(() => { - if (!ac.expect(result)) { - problems.push(`audit control FAILED: ${ac.label}\n got: ${JSON.stringify(result)}`); - } - }); - } - - if (problems.length > 0) { - process.stderr.write(`x measure-position-name-fold-census self-test FAILED\n\n${ - problems.map((p) => ` - ${p}`).join('\n\n')}\n\n`); - return 1; - } - if (!quiet) { - // -- The floor: the declared battery RAN, and ran its cases (#13489) ---- - // - // Evaluated after every site has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered cases EQUALS the set declared. - // - // ⭐ It lives INSIDE the `!quiet` branch on purpose. `main()` gates the - // PRODUCTION path on `selfTest({ quiet: true })`, which prints no verdict - // and therefore makes no claim for a floor to guard; evaluating the floor - // there would let a roster edit change what a census run outputs. The floor - // belongs to the `--self-test` verdict, which is the line that would - // otherwise claim controls hold that never ran. - // - // It reports in this file's own idiom -- a stderr block and a non-zero - // return -- and returns BEFORE `selfTestReachedVerdict` is set, so a breach - // reds through the #13798 handshake as well as through the exit code. - const floorFailures = []; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorFailures.push( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` - + `${SELF_TEST_BATTERY_FLOOR} -- a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of batterySeen) { - if (declaredBatteries.includes(name)) continue; - floorFailures.push( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` - + 'SELF_TEST_BATTERIES -- a case attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = batterySeen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorFailures.push( - count === 0 - ? `self-test battery "${name}" DID NOT RUN -- 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` - + 'The verdict below would have claimed those controls hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` - + `${SELF_TEST_BATTERIES[name]} -- cases that used to run no longer do.`, - ); - } - if (floorFailures.length > 0) { - process.stderr.write(`x measure-position-name-fold-census self-test floor (${floorFailures.length} breach(es))\n\n${ - floorFailures.map((f) => ` - ${f}`).join('\n\n')}\n\n A battery at or below its floor means cases STOPPED RUNNING -- the battery is the bug, not the number. Find what stopped registering (an early return, a deleted block, a guard that now skips) and restore it.\n\n`); - return 1; - } - process.stdout.write( - `✓ measure-position-name-fold-census self-test: junction anchor (${a.position} -> ${a.set}) classified as` - + ` a junction binding and absent from the name-dependency list; name-fold anchor (${b.name}) detected` - + ` against the composed artifact; negative control (${n.position}) clear; second-binder control` - + ` (${sb.position} -> ${sb.set}) seen and not inert; ${CONTROLS.builtinPositions.length}` - + ` + ${CONTROLS.builtinPermissionSets.length} parse-integrity anchors present;` - + ` ${AUDIT_CONTROLS.length} audit-generator controls pass\n`, - ); - } - selfTestReachedVerdict = true; - return 0; -} - -/* ------------------------------------------------------------------------- * - * Reporting - * ------------------------------------------------------------------------- */ - -function cite(d) { - return d.line ? `${d.file}:${d.line}` : d.file; -} - -function report() { - const c = census(); - const out = []; - out.push('# Position -> permission-set NAME-FOLD census (#13419 执行要点 1)'); - out.push(''); - out.push(`Declared position names: ${c.stats.positionNames}`); - out.push(`Declared permission-set names: ${c.stats.permissionSetNames}`); - out.push(`Junction bindings created in-repo: ${c.stats.junctionBindings}`); - out.push(''); - - out.push('## NAME-FOLD DEPENDENCIES -- grants in force with NO junction row'); - out.push(''); - if (c.nameFolds.length === 0) { - out.push(' (none over DECLARATIONS in this repository -- see NOT MEASURED below;'); - out.push(' this is not a statement about any deployment.)'); - } - for (const f of c.nameFolds) { - out.push(` ${f.name} [${f.collision}]`); - for (const p of f.positions) out.push(` position ${p.scope} ${p.kind} -- ${cite(p)}`); - for (const s of f.permissionSets) out.push(` set ${s.scope} ${s.kind} -- ${cite(s)}`); - out.push(''); - } - - out.push('## JUNCTION BINDINGS -- the governed channel, already materialised'); - out.push(''); - for (const b of c.bindings) { - out.push(` ${b.position} -> ${b.set} (${b.via})`); - out.push(` ${cite(b)}`); - } - out.push(''); - - out.push('## INERT POSITIONS -- no junction row, no name collision, no grant'); - out.push(''); - out.push(' 要点 3\'s collision warning must NOT fire on these.'); - out.push(''); - for (const i of c.inert) { - out.push(` ${i.name} -- ${i.declarations.map(cite).join(', ')}`); - } - out.push(''); - - out.push('## NOT MEASURED -- the half this instrument cannot reach'); - out.push(''); - out.push(' 1. Positions and permission sets created at RUNTIME (Setup, admin UI,'); - out.push(' `sys_position` / `sys_permission_set` rows) are invisible to a static'); - out.push(' census. An operator naming a position after an existing permission set'); - out.push(' creates a name-fold no scan of this repository can see.'); - out.push(' 2. Marketplace app artifacts that are not vendored here. The in-repo HotCRM'); - out.push(' artifact carries that app\'s PERMISSION SETS only -- its POSITION roster is'); - out.push(' not in this repository, so the four HotCRM sets with no in-repo position of'); - out.push(' the same name are NOT MEASURED, not zero.'); - out.push(' 3. Per-organization state of any kind. Organizations are a deployment concept.'); - out.push(''); - out.push(' Both gaps are measurable only against a real deployment: `--audit FILE`'); - out.push(' consumes that export and refuses to read an empty one as a zero.'); - out.push(' `--audit-schema` prints the export shape and the SQL for each table.'); - out.push(''); - process.stdout.write(`${out.join('\n')}\n`); - return 0; -} - -function reportAudit(file) { - let input; - try { - input = JSON.parse(fs.readFileSync(file, 'utf8')); - } catch (e) { - process.stderr.write(`x --audit could not read \`${file}\`: ${e?.message}\n` - + ' Run --audit-schema for the expected shape.\n'); - return 2; - } - const result = auditImplicitGrants(input); - const out = []; - out.push('# Implicit-grant audit report (#13419 执行要点 4)'); - out.push(''); - out.push(`Source: ${result.source ?? '(UNDECLARED -- an export with no provenance)'}`); - out.push(`Overall: ${result.status}${result.reason ? ` -- ${result.reason}` : ''}`); - out.push(''); - for (const org of result.organizations) { - out.push(`## organization ${org.organization} -- ${org.status}`); - if (org.status === 'NOT_MEASURED') { - out.push(` ${org.reason}`); - out.push(''); - continue; - } - out.push(` read ${org.counted.positions} position(s), ${org.counted.permissionSets} permission set(s),` - + ` ${org.counted.junctionRows} junction row(s)`); - if (org.implicitGrants.length === 0) { - out.push(' MEASURED ZERO implicit grants -- every name collision in this organization is'); - out.push(' carried by a junction row, or there is no collision.'); - } - for (const g of org.implicitGrants) { - out.push(` IMPLICIT GRANT ${g.name}`); - out.push(` position ${g.positionId} -> permission set ${g.permissionSetId}, no junction row`); - if (g.reach) out.push(` reach: ${g.reach}`); - if (g.principalsHolding !== undefined) out.push(` principals holding the position today: ${g.principalsHolding}`); - } - if (!org.blastRadiusMeasured) { - out.push(' (no `assignments` in the export -- grants are named, blast radius is NOT MEASURED)'); - } - out.push(''); - } - process.stdout.write(`${out.join('\n')}\n`); - return 0; -} - -function main(argv) { - if (argv.includes('--audit-schema')) { - process.stdout.write(`${AUDIT_SCHEMA}\n`); - return 0; - } - if (argv.includes('--self-test')) { - const selfTestCode = selfTest(); - if (!selfTestReachedVerdict) { - console.error( - '\n✗ measure-position-name-fold-census self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } - return selfTestCode; - } - - // ⚠️ Every reading is gated on the controls. The ruling asked for a census - // WITH positive controls, and a number printed by an instrument that has not - // just proven it fires on known samples is not a reading -- it is a number. - const gate = selfTest({ quiet: true }); - if (gate !== 0) { - process.stderr.write(' Refusing to report: the controls above must pass before any number is printed.\n'); - return gate; - } - - const auditIdx = argv.indexOf('--audit'); - if (auditIdx !== -1) { - const file = argv[auditIdx + 1]; - if (!file) { - process.stderr.write('x --audit needs a file. Run --audit-schema for the expected shape.\n'); - return 2; - } - return reportAudit(file); - } - if (argv.includes('--json')) { - const c = census(); - process.stdout.write(`${JSON.stringify({ - stats: c.stats, - nameFolds: c.nameFolds, - junctionBindings: c.bindings, - junctionOnSameName: c.junctionOnSameName, - inertPositions: c.inert, - }, null, 2)}\n`); - return 0; - } - return report(); -} - -process.exitCode = main(process.argv.slice(2)); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index e252ceeb9ec..1275350484d 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -5098,16 +5098,17 @@ const IDENTIFIER_TOKEN = /[A-Za-z_$][\w$]*/g; * destructured default puts braces in the SIGNATURE, and counting those closes * the body before it opens. `release-rehearsal-clone.mjs` writes two of them * among its fixture builders — and, the half this docblock asserted backwards - * once, THREE self-test ENTRY POINTS in the `scripts/` tree carry one TODAY, so + * once, self-test ENTRY POINTS in the `scripts/` tree carry one TODAY, so * this repairs live files rather than guarding against a future spelling: * * `scripts/check-test-completeness.mjs#selfTest` - * `scripts/measure-position-name-fold-census.mjs#selfTest` - * both `function selfTest({ quiet = false } = {})` + * `function selfTest({ quiet = false } = {})` * `scripts/workspace-enumerator.mjs#selfTest` * `export function selfTest({ root = null } = {})` * - * Measured at 6193e576d — bytes `maskSelfTests` changes in each file, this + * At 6193e576d there were three; the third, the position-name fold census + * (also `function selfTest({ quiet = false } = {})`), was deleted with the fold + * in #24757. Measured there — bytes `maskSelfTests` changes in each file, this * module against a staged copy of the one on `origin/main`: 30 -> 14848, * 30 -> 3961, 34 -> 6294. Thirty bytes is the destructured parameter and * nothing else; the entire self-test body was surviving the mask. The control diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index df9b7d3855b..30a0e7db053 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -3861,52 +3861,6 @@ export function selfTest({ tier = 'full' } = {}) { ); } - // ── The name-fold loader gate's WALKED roots, declared (#24037) ─────────── - // - // `check-position-name-fold-loaders.mjs` reads every text file under its - // roots, and kept them as a private list of bare words, which the extractor - // admits as nothing. A card editing a file under those roots therefore - // derived no command for it: PR #24028 reconciled its local union complete - // and met this gate red in CI, over a line it added to - // `scripts/cross-package-test-inputs.mjs`. The gate now declares the roots - // with the ROOT_DIR_WATCH_HINTS idiom and derives its walk from that literal, - // with no change to the extractor. - // - // Pinned against the LIVE tree, for the reason the scripts/** case above - // states. The probes are read off the family's own extracted hints rather - // than retyped here, so every root the gate declares gets one, and the first - // probe is the exact path the measured miss edited. The ablation strips the - // subtree hints and the verdict must leave MATCHED, so these cases cannot pass - // through another literal that happens to cover a probe. - const NAME_FOLD = 'scripts/check-position-name-fold-loaders.mjs'; - const nameFoldFamilies = discoverFamilies().byCheck; - for (const key of [NAME_FOLD, `${NAME_FOLD} --self-test`]) { - const entry = nameFoldFamilies.get(key); - t(`${key} is discovered at all — the pins below mean nothing without this`, Boolean(entry)); - const roots = (entry?.hints ?? []).filter((h) => h.endsWith('/**')).map((h) => h.slice(0, -'/**'.length)); - // The floor: the five roots the walk reads at this commit. A loop over an - // emptied hint set runs zero cases and prints nothing, which reads as a pass. - t(`${key} declares at least the five subtree roots its walk reads — read: [${roots.join(', ')}]`, roots.length >= 5); - const probes = ['scripts/cross-package-test-inputs.mjs', ...roots.map((r) => `${r}/the-one-nobody-has-written-yet.ts`)]; - for (const probe of probes) { - const verdict = entry ? classifyEntry(entry, [probe]).verdict : null; - t(`a change to ${probe}, under a root this gate walks, derives ${key} — read: ${verdict}`, verdict === 'matched'); - } - const undeclared = entry ? { ...entry, hints: entry.hints.filter((h) => !h.endsWith('/**')) } : null; - const residual = undeclared - ? ['scripts/cross-package-test-inputs.mjs', 'packages/the-one-nobody-has-written-yet/src/index.ts'] - .map((p) => classifyEntry(undeclared, [p]).verdict) - : []; - t( - `…and it is the subtree declaration doing it: strip it and ${key} is NOT MATCHED for the measured miss ` - + `or a brand-new package file — read: [${residual.join(', ')}]`, - Boolean(undeclared) - && undeclared.hints.length < entry.hints.length - && residual.length === 2 - && residual.every((v) => ['silent', 'undetermined'].includes(v)), - ); - } - // ── The MANIFEST population of check:merge-driver (#15501) ──────────────── // // That family's `git-merge-regen --self-test` refuses a generator with no