diff --git a/.changeset/position-name-fold-warning.md b/.changeset/position-name-fold-warning.md new file mode 100644 index 0000000000..e799605093 --- /dev/null +++ b/.changeset/position-name-fold-warning.md @@ -0,0 +1,11 @@ +--- +"@objectstack/plugin-security": patch +--- + +Say the position-name fold out loud: a permission set granted only because a POSITION of the same name resolved by name, with no `sys_position_permission_set` row behind it, now emits a `position_name_fold_grant` warning (#13419 执行要点 3, warning half). + +Permission-set resolution requests `[...positions, ...explicitPermissionSets]`, so a position called `sales_rep` resolves a permission set called `sales_rep` — no junction row, no audit line, and nothing declaring that it happens. An operator inspecting `sys_position_permission_set` sees "no bindings" while bindings are in force. The maintainer ruling (2026-08-31) makes the junction table the one governed channel; this reports the ungoverned grants until the fold itself is retired. + +The warning names the pair `(position N, set N)` **specifically**. A position bound to some *other* set is still folded onto its own name, so a report keyed on "is this position bound to anything?" would miss real folds while looking complete. It stays silent for a position already carrying that set through the governed channel (a junction row or a direct assignment), for baseline sets, and for any position with no same-named set — which is every built-in identity (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `guest`). It is emitted once per position name per process, so it stays loud instead of becoming per-request volume operators filter away. + +⛔ Resolution results are unchanged. Nothing is granted, revoked, accepted or rejected differently — the warning is purely additive, per the ruling's 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 245a237887..167862bdc3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -520,6 +520,27 @@ jobs: - name: Stack-collection enumerations answerable to stack.zod.ts 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 + 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/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index dbef79ec23..bc430a921d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -87,14 +87,14 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1585` | -| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2511` (the step 3.5 block), skipped by `:1585` | -| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4314` | -| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4465` | -| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4543` | -| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1412`, `:1434` | +| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1615` | +| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2541` (the step 3.5 block), skipped by `:1615` | +| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4344` | +| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4495` | +| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4573` | +| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1442`, `:1464` | | 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` | -| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3827` | +| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | | 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1288` | 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 new file mode 100644 index 0000000000..6e11a8d9a2 --- /dev/null +++ b/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13419 执行要点 3, warning half] The position-name fold, said out loud. + * + * ## What is pinned + * + * `resolvePermissionSetsForContextUnmemoized` requests + * `[...positions, ...explicitPermissionSets]`, so a POSITION name resolves a + * same-named PERMISSION SET with no `sys_position_permission_set` row behind + * it. The maintainer ruling (2026-08-31, 「同意」) makes the junction table the + * one governed channel; 要点 5 permits a warning and nothing else until the fold + * itself is deleted — 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」. + * + * ## 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. + * + * ⚠️ `sales_rep` and `sales_manager` appear in BOTH the fold list and the + * junction 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. + * + * ## The expensive failure mode, pinned first + * + * ⛔ A false positive on a built-in identity. `platform_admin`, `org_owner`, + * `org_admin`, `org_member` and `guest` are positions every deployment carries; + * warning on them would train operators to filter the very token this warning + * exists to be found by. `org_admin` sits one underscore from the real + * permission set `organization_admin`, so the near-miss is pinned explicitly + * rather than assumed. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SecurityPlugin } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; +import type { ISecurityService } from '@objectstack/spec/contracts'; + +/** The stable event token. Asserted as a LITERAL, never imported: an imported + * constant renames itself along with the source and the pin never notices. */ +const EVENT = 'position_name_fold_grant'; + +/** + * The census's `NAME-FOLD DEPENDENCIES` block — grants in force with no + * junction row. 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; + +/** + * The census's `JUNCTION BINDINGS` block — 13 rows, the governed channel. Every + * one binds a position to a DIFFERENTLY named set, which is why none of them is + * a fold: the fold is about a position's own name. + */ +const CENSUS_JUNCTION_BINDINGS: ReadonlyArray = [ + ['sales_rep', 'crm_sales_user'], + ['sales_manager', 'crm_sales_user'], + ['finance_approver', 'crm_sales_user'], + ['contributor', 'showcase_contributor'], + ['manager', 'showcase_manager'], + ['exec', 'showcase_executive'], + ['auditor', 'showcase_auditor'], + ['ops', 'showcase_ops'], + ['field_ops_delegate', 'showcase_field_ops_delegate'], + ['client_liaison', 'showcase_client_liaison'], + ['client_portal_user', 'showcase_guest_portal'], + ['everyone', 'member_default'], + ['everyone', 'showcase_member_default'], +]; + +/** + * The census's `INERT POSITIONS` block, printed under a heading that states the + * obligation in terms: "要点 3's collision warning must NOT fire on these." + */ +const CENSUS_INERT_POSITIONS = [ + 'platform_admin', + 'org_owner', + 'org_admin', + 'org_member', + 'guest', + 'finance', + 'legal', +] as const; + +function set(name: string): PermissionSet { + return { name, label: name, objects: {}, fields: {}, systemPermissions: [], tabPermissions: {} } as any; +} + +/** + * The permission-set universe these cases resolve against: every junction + * TARGET, the two same-name sets the HotCRM artifact contributes, the platform + * baseline, and `organization_admin` — the near-miss that must not be credited + * to the `org_admin` position. + * + * ⛔ No set is named after any inert or non-folding position, which is the + * repository's own state and the reason those positions are inert. The + * MUST-FIRE cases below are what stop that absence from making the MUST-NOT + * cases pass trivially: the same universe, the same predicate, two verdicts. + */ +const UNIVERSE: PermissionSet[] = [ + ...new Set([...CENSUS_JUNCTION_BINDINGS.map(([, s]) => s), ...CENSUS_NAME_FOLDS, 'member_default', 'organization_admin']), +].map(set); + +function boot(universe: PermissionSet[] = UNIVERSE) { + const ql: any = { + registerMiddleware: () => {}, + getSchema: () => null, + find: async () => [], + }; + const metadata: any = { get: async () => null, list: async () => universe }; + const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; + const warn = vi.fn(); + const ctx: any = { + logger: { info: vi.fn(), warn, error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { plugin: new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any), ctx, warn }; +} + +/** Resolve through the registered service handle, as every real consumer does. */ +async function resolveWith(context: Record, universe: PermissionSet[] = UNIVERSE) { + const { plugin, ctx, warn } = boot(universe); + await plugin.init(ctx); + await plugin.start(ctx); + const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial; + const sets = await svc.resolvePermissionSetsForContext?.(context as any); + const events = warn.mock.calls + .filter((c) => typeof c[0] === 'string' && c[0].includes(EVENT)) + .map((c) => c[1]); + return { sets: (sets ?? []).map((s) => s.name), events, warn, svc }; +} + +describe('[#13419] MUST FIRE — a position folded onto its own same-name set with no junction row', () => { + for (const position of CENSUS_NAME_FOLDS) { + it(`warns for '${position}', the census's own cross_scope fold`, async () => { + // The measured shape: the position IS junction-bound — to `crm_sales_user`, + // not to itself — so `permissions` carries that other set. The grant on + // the same-name set has no junction row behind it at all. + const { sets, events } = await resolveWith({ + userId: 'u1', + positions: [position], + permissions: ['crm_sales_user'], + }); + + // Reported once, naming both halves of the pair. + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ event: EVENT, position, permissionSet: position }); + + // ⛔ Purely additive: the fold still grants exactly what it granted before. + // 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()); + }); + } + + it('names the ungoverned grant and the two ways out, not just the collision', 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))!; + expect(message).toContain('sys_position_permission_set'); + expect(message).toContain('ungoverned'); + expect(message).toMatch(/rename/i); + }); + + it('is LOUD ONCE per position, not once per request', async () => { + const { plugin, ctx, warn } = boot(); + await plugin.init(ctx); + await plugin.start(ctx); + 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. + for (let i = 0; i < 3; i++) { + await svc.resolvePermissionSetsForContext?.({ userId: `u${i}`, positions: ['sales_rep'] } as any); + } + expect(warn.mock.calls.filter((c) => String(c[0]).includes(EVENT))).toHaveLength(1); + }); +}); + +describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects', () => { + it.each(CENSUS_INERT_POSITIONS.map((p) => [p]))( + 'stays silent for the inert position %s (a built-in-identity false positive is the most expensive failure here)', + 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. + expect(sets).toEqual(['member_default']); + }, + ); + + it('stays silent for org_admin even though the set organization_admin exists (near-miss, not a collision)', async () => { + const { events } = await resolveWith({ userId: 'u1', positions: ['org_admin'] }); + expect(events).toEqual([]); + }); + + it.each(CENSUS_JUNCTION_BINDINGS.map(([p, s]) => [p, s]))( + 'stays silent for the junction binding %s -> %s', + async (position, boundSet) => { + const { events } = await resolveWith({ userId: 'u1', positions: [position], permissions: [boundSet] }); + // ⚠️ `sales_rep` and `sales_manager` are in this list too, and they DO + // warn — above, on their own name. What is pinned here is that binding a + // position to some other set never warns ABOUT THAT BINDING: the reported + // pair is always (position N, set N). + expect(events.map((e: any) => e.position)).not.toContain(boundSet); + if (!(CENSUS_NAME_FOLDS as readonly string[]).includes(position)) expect(events).toEqual([]); + }, + ); + + it('stays silent once 要点 2 materialises the pair (position N, set N)', async () => { + // The exact row the ruling's 要点 2 would create. This is the forward pin: + // when materialisation lands, the warning must retire itself for the pairs + // it covers rather than needing a second edit. + const { events, sets } = await resolveWith({ + userId: 'u1', + positions: ['sales_rep'], + permissions: ['sales_rep', 'crm_sales_user'], + }); + expect(events).toEqual([]); + 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 () => { + const { events } = await resolveWith({ userId: 'u1', positions: ['member_default'] }); + expect(events).toEqual([]); + }); + + it('stays silent for a context with no positions at all', async () => { + const { events } = await resolveWith({ userId: 'u1', permissions: ['crm_sales_user'] }); + expect(events).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0cd1e1cd16..52365cebda 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -612,6 +612,22 @@ interface SecurityReportSink { * ASSIGNS `ctx.logger` over this field, above both of its early bail-outs * (#10706), so a degraded boot reports through the host too. */ +/** + * [#13419 执行要点 3] 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 ungoverned same-name grant. + * + * 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. + * + * snake_case per the precedent beside it (`platform_owner_wall_bypass`): this + * is a data value, not an error code (ADR-0112 governs those). + */ +const POSITION_NAME_FOLD_EVENT = 'position_name_fold_grant'; + const CONSOLE_SECURITY_SINK: SecurityReportSink = Object.freeze({ warn: (...a: any[]) => { console.warn(...a); }, error: (...a: any[]) => { console.error(...a); }, @@ -971,6 +987,20 @@ 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 + * once per request. + * + * Deduplication is what keeps it loud. This resolution runs on every request + * whose context misses {@link permissionSetMemo}, so an undeduplicated warn + * emits per request forever; that is the volume operators build a filter for, + * and a filtered warning is a silent one. The set is per plugin INSTANCE, so + * a restart re-reports — the condition is a deployment fact, and a fact worth + * saying is worth saying again on the next boot. + */ + private readonly nameFoldWarned = new Set(); + constructor(options: SecurityPluginOptions = {}) { this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets; @@ -4681,9 +4711,14 @@ export class SecurityPlugin implements Plugin { // 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[] = []; 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( @@ -4693,6 +4728,9 @@ export class SecurityPlugin implements Plugin { this.dbLoaderForContext(context), { logger: this.logger }, ); + // [#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); // 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. @@ -4714,6 +4752,98 @@ export class SecurityPlugin implements Plugin { return permissionSets; } + /** + * [#13419 执行要点 3, warning half] Report a permission-set name that collides + * with a POSITION name and has NO junction row — the ungoverned half of the + * position→set binding, said out loud. + * + * ## 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` — with no + * `sys_position_permission_set` row, no audit line, and nothing declaring + * that it happens. The maintainer ruling (2026-08-31, 「同意」) makes the + * junction table the one governed channel and retires the fold; the fold's + * DELETION is not this method (see the boundary note below), so until it + * lands the grant is at least no longer silent. + * + * ## 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. Junction + * rows 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) + * + * ⛔ Purely additive. The resolution result is READ, never rewritten — 要点 5 + * of the ruling: 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」, and a + * warning is the permitted half. ⛔ Deleting the fold is NOT this change: the + * vendored HotCRM artifact carries that app's permission sets but not its + * position roster, so HotCRM's own same-name folds are NOT MEASURED rather + * than zero, and deleting the fold would silently revoke them. + */ + private reportNameFoldCollisions( + positions: unknown, + explicitPermissionSets: unknown, + baselineApplied: string[], + resolved: PermissionSet[], + ): void { + if (!Array.isArray(positions) || positions.length === 0) return; + const governed = new Set( + (Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []).filter( + (n): n is string => typeof n === 'string', + ), + ); + 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'), + ); + for (const position of positions) { + if (typeof position !== 'string' || position.length === 0) continue; + if (!resolvedNames.has(position)) continue; + if (governed.has(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 + // git log and no ADR to resolve `#NNNN` against (`check:doc-authoring`; maintainer + // 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}' was granted ` + + `because a POSITION of the same name resolved by name — there is no ` + + `sys_position_permission_set row binding them. This grant is in force and ungoverned: ` + + `it appears in no junction table an operator can inspect. Bind it explicitly ` + + `(sys_position_permission_set: position '${position}' -> permission set '${position}') ` + + `or rename one of the two.`, + { + event: POSITION_NAME_FOLD_EVENT, + position, + permissionSet: position, + }, + ); + } + } + /** * [ADR-0106 D7] Resolve the configured baseline permission set(s) on their * own — the deployment's answer to "what does a caller with NO resolved sets diff --git a/scripts/check-position-name-fold-loaders.mjs b/scripts/check-position-name-fold-loaders.mjs new file mode 100644 index 0000000000..472d0fb1b3 --- /dev/null +++ b/scripts/check-position-name-fold-loaders.mjs @@ -0,0 +1,424 @@ +#!/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. All of them must SPELL the + * file or the fixtures directory somewhere in source, so a text scan over the + * tracked tree is a sound instrument for the question. 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, 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__'; + +/** 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', +]; + +const SCAN_ROOTS = ['packages', 'examples', 'apps', 'scripts', 'tools', '.github']; + +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 repo-relative fixtures directory — a config or workflow path; + * 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. + */ +export function referencesArtifact(text) { + if (text.includes(ARTIFACT_BASENAME) || text.includes(FIXTURES_DIR)) return true; + for (const line of text.split('\n')) { + 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; + } + if (!referencesArtifact(text)) continue; + const rel = relative(root, abs).split(sep).join('/'); + 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.', + ); +} + +function selfTest() { + let failed = 0; + let cases = 0; + const check = (label, actual, expected) => { + 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. + 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. + 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. + check('matcher sees the basename', referencesArtifact(`join(HERE, '__fixtures__/${ARTIFACT_BASENAME}')`), true); + check('matcher sees a fixtures-directory glob', referencesArtifact(`glob('${FIXTURES_DIR}/*.artifact.json')`), true); + check('matcher ignores an unrelated hotcrm mention', referencesArtifact("// the HotCRM shape from hotcrm#788"), false); + check('matcher sees a bare glob sweep of the fixtures dir', referencesArtifact("await glob('**/__fixtures__/*.artifact.json')"), true); + check( + 'matcher does NOT credit __fixtures__ and hotcrm on separate lines', + referencesArtifact("import x from './__fixtures__/widget.json';\n// unrelated: hotcrm#788\n"), + false, + ); + + // ── 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' }] }), + [], + ); + + 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.`); +} + +function main() { + if (process.argv.includes('--self-test')) return selfTest(); + + 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/pm/bare-root-worklist.mjs b/scripts/pm/bare-root-worklist.mjs index 42562b33be..7c66ff49b7 100644 --- a/scripts/pm/bare-root-worklist.mjs +++ b/scripts/pm/bare-root-worklist.mjs @@ -394,6 +394,31 @@ const TRIAGE = new Map([ + 'no arbitrary file at the top of packages/ is reached — which is what this verdict says', }], // ── Refused: the population is the whole root, and the root is saturated ── + ['scripts/check-position-name-fold-loaders.mjs SCAN_ROOTS packages', { + verdict: 'REFUSE-WIDE', + why: 'every text file under the root is walked and any one of them could name the #13419 ' + + 'fixture — 5562 of 5625 (99%), measured 2026-08-31. The population is not a subset of the ' + + 'root that a narrower glob could describe; it IS the root. A true declaration here would ' + + 'name this gate on every card touching a package, which is the precision trade this ' + + 'verdict refuses', + }], + ['scripts/check-position-name-fold-loaders.mjs SCAN_ROOTS examples', { + verdict: 'REFUSE-WIDE', + why: '240 of 243 (99%). Refused with its packages half rather than split from it: the loader ' + + 'this gate hunts is a COMPOSITION site, and nothing says one lands under examples/ rather ' + + 'than packages/ — declaring only the small root would read as a claim about where that ' + + 'happens, which is exactly what is not known', + }], + ['scripts/check-position-name-fold-loaders.mjs SCAN_ROOTS apps', { + verdict: 'REFUSE-WIDE', + why: '36 of 40 (90%) — same trade, same reason as the examples half', + }], + ['scripts/check-position-name-fold-loaders.mjs SCAN_ROOTS scripts', { + verdict: 'REFUSE-WIDE', + why: '295 of 298 (99%). This root is where the gate own allowed readers live ' + + '(DECLARED_INSTRUMENTS), so it is walked wholesale for the same reason as the others: a ' + + 'new reader is found, then classified, never assumed absent', + }], ['check:authz-resolver SCAN_ROOTS packages', { verdict: 'REFUSE-WIDE', why: 'walks every non-test TS source under the root — 1898 of 4903 (39%). True, and it would '