diff --git a/.changeset/security-posture-object-permission-book-runtime-publish.md b/.changeset/security-posture-object-permission-book-runtime-publish.md new file mode 100644 index 0000000000..8c87a1f322 --- /dev/null +++ b/.changeset/security-posture-object-permission-book-runtime-publish.md @@ -0,0 +1,37 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): `validateSecurityPosture` now gates `permission` / `book` writes at the runtime publish door; `security-role-word` splits into its own CLI-only rule (#8310) + +The measured half of the #7891 strictness rollout (#4001 pattern): the +`validateSecurityPosture` registry entry declares `runtimeTypes: ['seed', +'permission', 'book']`, so the security-posture rule families run at the +runtime metadata publish gate (Studio, REST `/meta`, MCP/AI authors) for +permission-set and book writes. A class of runtime writes that used to succeed +can now be refused with a 422 — e.g. a permission set granting a `'*'` +wildcard with View/Modify All Data (`security-wildcard-vama`) or a +high-privilege default set (`security-anchor-high-privilege`). Warning/info +findings (the three cross-collection rules' common verdicts) surface as +non-blocking advisories on the save response. + +Measured zero breakage for the shipped set: the full +`@objectstack/metadata-protocol`, `@objectstack/objectql` and +`@objectstack/rest` suites plus a replay of every shipped-corpus +permission/book/seed write (showcase + CRM + todo + blank) produce zero +refusals. + +`object` is deliberately NOT declared: re-measured on the #8308-repaired tree +it still refuses 95 platform-suite writes in `@objectstack/objectql` and +`@objectstack/rest` (all `security-owd-unset` / `security-external-wider`), +including contract pins of the ADR-0094 403 `owd_external_wider` door that +this 422 gate would preempt — escalated on #8310 rather than forced green. + +`security-role-word` does not cross either: it judges six collections +including `positions`/`apps`, which the per-write snapshot does not carry — so +it is split into its own CLI-only registry entry (`validateSecurityRoleWord`, +same rule id, same findings on every CLI command) rather than enforced for a +subset of its collections (#7220: one rule id sits on one side of the wall). +`validateSecurityRoleWord` is a new named export of `@objectstack/lint`; +`validateSecurityPosture` no longer emits `security-role-word` findings — +callers consuming both should run both (the registry does). diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 8714960d3f..7606d973e1 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -122,7 +122,7 @@ import { validateSeedReplaySafety } from './validate-seed-replay-safety.js'; import { validateSeedStateMachine } from './validate-seed-state-machine.js'; import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; import { validatePredicatePathRefs } from './validate-predicate-path-refs.js'; -import { validateSecurityPosture } from './validate-security-posture.js'; +import { validateSecurityPosture, validateSecurityRoleWord } from './validate-security-posture.js'; import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js'; import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js'; import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js'; @@ -1126,65 +1126,68 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // sees one refusal, never two. The stated cost of moving was imaginary; the // reason it has not moved is the measured one below. // - // Why the move is not taken HERE, measured rather than assumed (#7576 stage 1): + // The move IS taken now — the #7891 programme's three slices, in order: // - // - The four shipped stacks (showcase, CRM, todo, the `blank` template — 30 - // objects, 10 permission sets, 1 book, 12 positions, 3 apps, 24 seeds) are - // CLEAN of `error` findings at both surfaces. No shipped app trips. - // - The PLATFORM's own runtime write path does. Declaring `object` here makes - // `security-owd-unset` refuse any object published without an OWD, and that - // is the shape the runtime create door actually emits: it turns 26 writes - // into 422s across 8 files of `@objectstack/metadata-protocol`'s own suite, - // and `METADATA_CREATE_SEEDS.object` — the authoritative minimal create body - // — carries no `sharingModel` either. That is a strictness rollout - // (#4001 pattern), not a registry-honesty fix, and its repair sites are in - // packages this card may not edit. - // - `permission` and `book` failed for a different, structural reason — - // REPAIRED under #8309. The gate used to carry `objects` as resolution - // context and nothing else (`RuntimeStackContext`), so the three - // cross-collection rules judged a snapshot missing the collection they - // compare against. Measured: one simulated runtime write per shipped - // permission set produced 38 `security-master-detail-ungranted` warnings - // where the same rule over the whole stack produces 4 — with one set in - // the snapshot, every detail object the tenant's OTHER sets grant read as - // ungranted. `security-private-no-readscope` and - // `security-book-audience-unknown-set` failed identically. The snapshot - // now carries `permissions` and `books` in both differential passes and - // `TYPE_TO_STACK_KEY` maps both types, so the per-write verdict agrees - // with the whole-stack one (pinned in - // `validate-security-posture.runtime-surface.test.ts`); what remains for - // these two types is ONLY the `runtimeTypes` declaration itself — #8310. + // - #8307: the ADR-0091 seed pair crossed (`runtimeTypes: ['seed']`), with + // the isolation proof that the differential cancels every finding this + // function derives from the sibling collections. + // - #8309: the snapshot repair. The gate used to carry `objects` and + // nothing else, so the three cross-collection rules judged a universe + // missing the collection they compare against (measured: 38 phantom + // `security-master-detail-ungranted` per-write vs 4 whole-stack, + // PR #7886). `RuntimeStackContext` now carries `permissions`/`books` in + // BOTH differential passes and `TYPE_TO_STACK_KEY` maps both types. + // - #8310 (this state): `runtimeTypes` gains `permission` + `book`. + // `object` measured DIRTY and stays behind — see below. // - // The residue that WAS ready, and now crosses (#8307): the two ADR-0091 seed - // rules (`security-grant-expired-at-authoring`, `security-delegation-missing- - // reason`) read only `stack.data[]` and cross the wall together as a whole - // sub-family, with zero measured trips (re-run for this card — see - // `validate-security-posture.runtime-surface.test.ts`). `security-role-word` - // is deliberately NOT in this slice: it judges six collections, and wiring the - // two that need no snapshot would split ONE rule id across the wall — a door - // where a position named `sales_role` is refused and an object named - // `sales_role` is not, which is the #7220 failure this table already refuses - // to build. + // Why `object` is still not declared, re-measured on the #8308-repaired + // tree rather than inherited (#4001: zero breakage is demonstrated, never + // assumed). The OLD blocker is genuinely gone: with `object` declared, the + // full `@objectstack/metadata-protocol` suite passes (the 26-refusal + // measurement predates #8308's `METADATA_CREATE_SEEDS.object` repair) and a + // replay of every shipped-corpus object through the real gate refuses + // nothing. But one package over the same declaration still breaks the + // platform's own write paths — measured on this exact tree: // - // `object` / `permission` / `book` remain UNDECLARED here (that is #8310): - // (a) declaring `object` makes `security-owd-unset` refuse every OWD-less - // runtime object publish — a strictness rollout (#4001), not a wiring fix; - // (b) `permission` / `book` needed a second collection the per-write - // snapshot did not carry, and were measured inventing findings without it - // (38 vs 4 over the shipped corpus). #8309 closed (b) — the snapshot now - // carries `permissions`/`books` and both types map to stack keys — so - // declaring them here is #8310's remaining one-line flip. This - // entry is the WHOLE `validateSecurityPosture` function (all 13 rule ids), not - // a per-rule split: `runtimeTypes: ['seed']` is safe to declare on the whole - // entry ONLY because of `runtime-gate.ts`'s baseline/candidate differential — - // a `seed` write's candidate stack carries `objects` IDENTICAL to the - // baseline (only `data` differs), so every finding this function derives from - // `stack.objects` / `stack.permissions` / `stack.positions` / `stack.apps` / - // `stack.books` is produced, byte-identical, in BOTH passes and cancels in the - // diff — only the ADR-0091 pair's `stack.data[]` reads can differ. Splitting - // this entry into a seed-only registration is not needed for correctness; it - // would only be needed if a future rule in this function read `stack.data[]` - // in a way that also depended on `stack.objects` context (none does today). + // - `@objectstack/objectql`: 83 tests across 13 files fail, every one + // `security-owd-unset` (85 refusals) — the suites publish objects with + // no authored `sharingModel` through `saveMetaItem`. + // - `@objectstack/rest`: 12 tests across 3 files — the same owd-unset + // class, PLUS two genuine CONTRACT collisions no fixture edit can + // honestly settle: `meta-object-owd-gate.test.ts` pins #7674's ADR-0094 + // 403 `owd_external_wider` door, which this 422 gate now PREEMPTS for + // the same defect (`saveMetaItem` runs this table first), and it pins + // that a write with NO OWD keys at all SAVES (ADR-0094 reads absence as + // the D1 `private` default) — which `security-owd-unset` exists to + // refuse (absence must be an authored decision). + // + // So declaring `object` is not a wiring fix and not even only fixture + // repair in two packages outside this card's surface: it is a decision + // about which door answers for OWD defects (403 ADR-0094 vocabulary vs + // 422 lint vocabulary) and whether an unauthored OWD refuses at runtime. + // That decision is escalated on #8310; until it is ruled, `object` stays + // undeclared and the pins in + // `validate-security-posture.runtime-surface.test.ts` record both what + // WOULD happen (via the gate's own snapshot builder) and that it does not. + // + // `security-role-word` is NOT in this entry any more — that is what the + // `validateSecurityRoleWord` entry below records. It judges six collections + // (objects, fields, actions, permission sets, positions, apps — plus books), + // and `positions`/`apps` are neither carried by the per-write snapshot nor + // mapped in `TYPE_TO_STACK_KEY`, so declaring `permission`/`book` on a + // function that still contained it would have enforced ONE rule id for a + // strict subset of its collections: a door where a permission set named + // `role_manager` is refused and a position named `sales_role` walks through + // — the #7220 failure this table refuses to build, in either direction. The + // rule therefore stays behind WHOLE (#8310's explicit call), as its own + // entry. + // + // This entry remains the rest of the D7 block (12 rule ids) as ONE + // registration, not a per-rule split: the baseline/candidate differential is + // what keeps a write of one declared type from leaking the other rules' + // whole-stack findings — every finding derived from a sibling collection is + // produced byte-identically in both passes and cancels in the diff. Only + // findings the written item itself adds are attributed to the write. { name: 'validateSecurityPosture', tier: 'gating', @@ -1192,9 +1195,41 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/validate-security-posture.ts', surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['seed'], + runtimeTypes: ['seed', 'permission', 'book'], run: (stack) => validateSecurityPosture(stack), }, + // [ADR-0090 D3 / #8310] The vocabulary freeze, split out of + // `validateSecurityPosture` the day the rest of that block crossed the + // runtime wall — so that it could stay behind WHOLE rather than cross for + // three of the six collections it judges (#7220: one rule id must sit on ONE + // side of the wall). The split is a surface boundary, not taste: the rule's + // verdict and findings are byte-identical to before on every CLI command + // (both entries run on all three), and the runtime door does not run it for + // ANY type. + // + // The road to crossing is concrete and short, recorded here so the next + // seat prices it correctly: carry `positions`/`apps` in + // `RuntimeStackContext` + `CONTEXT_STACK_KEYS`, map both types in + // `TYPE_TO_STACK_KEY` (both are `allowRuntimeCreate: true`, so the writes + // are real), then declare `runtimeTypes: ['object', 'permission', 'book', + // 'position', 'app']` on THIS entry — all six collections in one edit, the + // #7220 discipline satisfied. + { + name: 'validateSecurityRoleWord', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-security-posture.ts', + surfaces: CLI_ONLY, + surfaceReason: + 'P2 (#4463)/#8310: judges six collections (objects, fields, actions, permission sets, ' + + 'positions, apps — plus books), and the per-write snapshot neither carries nor maps ' + + 'positions/apps. Wiring it for the mapped types alone would enforce one rule id for three of ' + + 'its six collections — the #7220 split (an object named sales_role refused while a position ' + + 'named sales_role walks through). It crosses whole — positions/apps carried, mapped and ' + + 'declared — or stays behind; it stays behind until that wiring exists.', + run: (stack) => validateSecurityRoleWord(stack), + }, // ADR-0105 D6 — the org tree is a REPORTING dimension. An RLS policy or // sharing rule that walks it builds a second permission hierarchy (the // dual-hierarchy mistake ADR-0057 D5 retired) and cannot widen Layer 0 anyway, diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 0321b5f985..956ef06ff9 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -237,6 +237,7 @@ export type { SeedStateMachineFinding, SeedStateMachineSeverity } from './valida export { validateSecurityPosture, + validateSecurityRoleWord, SECURITY_OWD_UNSET, SECURITY_OWD_ALIAS, SECURITY_EXTERNAL_WIDER, diff --git a/packages/lint/src/validate-security-posture.runtime-surface.test.ts b/packages/lint/src/validate-security-posture.runtime-surface.test.ts index 1aa3554168..44c1d025cb 100644 --- a/packages/lint/src/validate-security-posture.runtime-surface.test.ts +++ b/packages/lint/src/validate-security-posture.runtime-surface.test.ts @@ -8,58 +8,67 @@ // publish surface "would double-report one refusal in two vocabularies". That // was a claim about coverage rather than a reading of the gate: the gate reads // exactly `sharingModel` and `externalSharingModel`, which is ONE of the -// thirteen rule ids this block carries. #7576 corrected the reason (measured, -// not inherited); #8307 (this file) is the slice the corrected reason named as -// ready — the ADR-0091 seed pair now DOES run at the door, `runtimeTypes: -// ['seed']`, and this file's job changes with it: cases 1-3 below still measure -// why `object` / `permission` / `book` remain undeclared (mirrored, since -// nothing wires them), and the seed cases now measure the REAL gate instead of -// a mirror of it. +// thirteen rule ids the block carried. #7576 corrected the reason (measured, +// not inherited); the #7891 programme then moved the block in three measured +// slices, and this file records each crossing: // -// ## Why cases about unwired types consume the REAL snapshot builder -// -// No rule declares `object` / `permission` / `book` in `runtimeTypes` today — -// that is the state #8310 is about — so `runRuntimeAuthoringRules` cannot be -// asked what this block would find there: it filters the registry by declared -// type and correctly returns nothing for them. `wouldGateAdd()` below -// therefore drives `buildRuntimeWriteSnapshots` — the gate's OWN construction, -// exported under #8309 — through this block directly. Before #8309 this file -// kept a hand-written mirror of the snapshot logic instead, which is exactly -// the drift surface #8309's snapshot change would have invalidated silently; -// `the mirror still matches the real gate` keeps pinning builder-vs-gate -// parity against a type that IS wired. +// - #8307: the ADR-0091 seed pair crossed (`runtimeTypes: ['seed']`), with +// the isolation proof that the differential cancels sibling-collection +// findings. +// - #8308: blocker A repaired at the producer — `METADATA_CREATE_SEEDS.object` +// now authors `sharingModel: 'private'`, so the platform's own minimal +// create body passes the gate #8310 registers (pinned below, consumed from +// the seed registry rather than re-spelled). +// - #8309: the snapshot repair — `RuntimeStackContext` carries +// `permissions`/`books` in BOTH differential passes and `TYPE_TO_STACK_KEY` +// maps both types, killing the measured phantom findings (38-vs-4, +// PR #7886) that made crossing `permission`/`book` unshippable. +// - #8310 (this state): `runtimeTypes` gains `permission` + `book`, and +// `security-role-word` is split into its own CLI-only entry +// (`validateSecurityRoleWord`) so it stays behind WHOLE rather than cross +// for a strict subset of the six collections it judges — the #7220 +// discipline: one rule id sits on ONE side of the wall. `object` measured +// DIRTY on this exact tree and stays behind, escalated on #8310: the +// #8308 repair really did clean `@objectstack/metadata-protocol` (full +// suite green with `object` declared) and the shipped corpus replays +// clean, but `@objectstack/objectql` (83 tests / 13 files, all +// `security-owd-unset`) and `@objectstack/rest` (12 tests / 3 files, +// owd-unset + external-wider — including #7674's pins that the ADR-0094 +// 403 `owd_external_wider` door answers, which this 422 gate would +// preempt, and that a write with NO OWD keys saves) still refuse. Which +// door answers, and whether an unauthored OWD refuses at runtime, is a +// contract decision — not a fixture repair. // // ## What each case is evidence FOR // -// 1. `object` / `permission` / `book` / `position` / `app` still reach no rule -// here — the residual state #8310 will change, stated once so the builder -// cases below read as "what would happen", not "what happens". -// 2. `an OWD-less object write would be REFUSED` — the positive control for the -// escalation. This is why the `object` half of the move is a strictness -// rollout (#4001) and not a wiring fix: it is the shape the platform's own -// runtime create door emits (`METADATA_CREATE_SEEDS.object` carries no -// `sharingModel`), and declaring the type turned 26 writes into 422s across -// 8 files of `@objectstack/metadata-protocol`'s suite when measured. -// 3. `a permission-set write agrees with the whole-stack verdict` — #8309's -// acceptance. The three cross-collection rules compare against sibling -// collections the snapshot now carries (`permissions`/`books`), so the -// per-write verdict for them equals the whole-stack one. The case ALSO -// reproduces the pre-#8309 defect in miniature (an objects-only context -// still invents the phantom finding — the 38-vs-4 mechanism, PR #7886), -// which is the reverse verification kept live in-tree: deleting the -// enrichment turns the agreement half red, in the predicted direction. -// 4. `the ADR-0091 seed pair now crosses the runtime publish gate (#8307)` — -// the pair enforces for `seed`-typed writes through the REAL gate, on the -// `seed` → `data` stack key corrected under #7886/#8308, and the diff -// mechanism (baseline vs candidate) is proven to isolate the pair's own -// findings from the other eleven rule ids this ONE registry entry also -// carries — so declaring `runtimeTypes: ['seed']` on the whole -// `validateSecurityPosture` entry, rather than splitting it into a -// seed-only entry, is safe. +// 1. `the mirror still matches the real gate (flow)` — non-vacuity for +// `wouldGateAdd` (builder-vs-gate parity on a wired type). +// 2. The crossing pins: `permission`/`book` now reach this block at the +// REAL gate; `object` and `position`/`app` reach no rule (the escalated +// residue and role-word's residue respectively — see 3 and 4). +// 3. `security-role-word` stays behind WHOLE: its entry is CLI-only, no +// runtime-gated type reaches it, and the door does NOT refuse a +// `role_manager`-named permission set for it — while the CLI still +// refuses exactly what it refused before the split (both entries run on +// all three commands; the union of their findings is the pre-split set). +// 4. The `object` residue, kept executable through the gate's OWN snapshot +// builder: an OWD-less object write WOULD be refused, the platform's own +// create seed WOULD be clean (#8308's repair, re-measured), and a clean +// write is not blamed for the context's pre-existing defects — so the +// day the escalation rules, the flip is one array element plus flipping +// these pins to the real gate. +// 5. The #8309 agreement pins, upgraded to the REAL gate now that +// `permission`/`book` are declared: the write agrees with the +// whole-stack verdict against the full context, and the pre-#8309 +// phantom is kept executable as the in-tree reverse verification +// (objects-only context still invents it — deleting the snapshot +// enrichment turns the agreement half red in the predicted direction: +// MORE findings than whole-stack). +// 6. The seed-pair cases (#8307), unchanged. import { describe, it, expect } from 'vitest'; -import { getMetadataCreateSeed } from '@objectstack/spec/kernel'; +import { getMetadataCreateSeed, DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { AUTHORING_RULES } from './authoring-rules.js'; import { @@ -78,12 +87,14 @@ import { SECURITY_OWD_UNSET, SECURITY_ROLE_WORD, validateSecurityPosture, + validateSecurityRoleWord, type SecurityFinding, } from './validate-security-posture.js'; type AnyRec = Record; const ENTRY = AUTHORING_RULES.find((r) => r.name === 'validateSecurityPosture')!; +const ROLE_ENTRY = AUTHORING_RULES.find((r) => r.name === 'validateSecurityRoleWord')!; /** Same identity `runtime-gate.ts` set-differences its two passes on. */ const fingerprint = (f: SecurityFinding) => `${f.rule}\u0000${f.where}\u0000${f.path}\u0000${f.message}`; @@ -93,11 +104,11 @@ const fingerprint = (f: SecurityFinding) => `${f.rule}\u0000${f.where}\u0000${f. * against `context` as the live universe — this block's verdict only. * * [#8309] Drives `buildRuntimeWriteSnapshots`, the gate's OWN baseline/ - * candidate construction, rather than a hand-kept mirror of it: same - * replace-not-erase rule when the written type IS a context collection, same - * set difference. `the mirror still matches the real gate (flow)` pins this - * helper against `runRuntimeAuthoringRules` on a wired type, so the two ways - * of asking cannot drift apart. + * candidate construction, rather than a hand-kept mirror of it. Since #8310 + * declared `object`/`permission`/`book`, most cases ask the REAL + * `runRuntimeAuthoringRules` instead; this helper remains for builder parity + * pins and for measuring this block's verdict in isolation from any other + * rule that may later declare the same types. */ function wouldGateAdd(type: string, item: AnyRec, context: RuntimeStackContext = {}): SecurityFinding[] { const snapshots = buildRuntimeWriteSnapshots({ type, item, context }); @@ -145,7 +156,7 @@ const TWO_SET_CONTEXT: RuntimeStackContext = { books: TWO_SET_STACK.books, }; -describe('validateSecurityPosture at the runtime publish surface (#7576, crossed for `seed` under #8307)', () => { +describe('validateSecurityPosture at the runtime publish surface (#7576 → #8307 → #8309 → #8310)', () => { it('the mirror still matches the real gate (flow)', () => { // Non-vacuity for `wouldGateAdd`, and the drift guard the whole file rests // on: `flow` IS wired, so the real gate has a verdict to compare against. @@ -164,83 +175,171 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed expect(stackKeyForType('flow')).toBe('flows'); }); - it("object / permission / book / position / app still reach no rule here — #8310's residue", () => { - // Only the ADR-0091 seed pair crossed under #8307. The other five metadata - // types this block could in principle judge remain UNDECLARED — #8310, and - // still blocked for the reasons the comment above the registry entry names - // (a strictness rollout on `object`, RUNTIME_NEEDS_FULL_SNAPSHOT on - // `permission`/`book`). Asserted so a future declaration for any of these - // is a deliberate edit to THIS test, not a silent widening. - for (const type of ['object', 'permission', 'book', 'position', 'app']) { + it('[#8310] permission / book now cross — the measured half of the flip, on the whole 12-rule entry', () => { + // The registration this card makes: the `validateSecurityPosture` entry + // (12 rule ids — `security-role-word` is its own entry now, see below) + // declares `permission` and `book` beside `seed`. Both measured ZERO + // refusals across the full `@objectstack/metadata-protocol` suite, the + // `@objectstack/objectql` and `@objectstack/rest` suites, and a replay of + // every shipped-corpus permission set and book through the real gate. + expect(ENTRY.surfaces).toEqual(['cli', 'runtime-publish']); + expect(ENTRY.runtimeTypes).toEqual(['seed', 'permission', 'book']); + for (const type of ['seed', 'permission', 'book']) { expect( runtimeAuthoringRulesFor(type).map((r) => r.name), - `no rule gates '${type}' yet — that is #8310, not this card`, - ).not.toContain('validateSecurityPosture'); + `'${type}' writes must reach this block at the door`, + ).toContain('validateSecurityPosture'); + expect(runtimeGatedTypes()).toContain(type); } }); - it('[#8307] the ADR-0091 seed pair now crosses the runtime publish gate, on the whole registry entry', () => { - // The registration this card makes: the WHOLE `validateSecurityPosture` - // entry (all 13 rule ids) declares `runtimeTypes: ['seed']` — there is no - // per-rule-id split in `authoring-rules.ts`. The isolation cases below are - // what makes that a safe crossing rather than an over-wide one. - expect(ENTRY.surfaces).toEqual(['cli', 'runtime-publish']); - expect(ENTRY.runtimeTypes).toEqual(['seed']); - expect(runtimeAuthoringRulesFor('seed').map((r) => r.name)).toContain('validateSecurityPosture'); - expect(runtimeGatedTypes()).toContain('seed'); + it("[#8310] object still reaches no rule — measured dirty, escalated, NOT silently crossed", () => { + // The re-measurement this card ran (#4001: demonstrated, never assumed): + // with `object` declared, `@objectstack/metadata-protocol` is fully green + // (#8308's seed repair killed the old 26-refusal blocker) and the corpus + // replays clean — but `@objectstack/objectql` fails 83 tests across 13 + // files (every one `security-owd-unset`) and `@objectstack/rest` fails 12 + // across 3, including #7674's pins of the ADR-0094 403 + // `owd_external_wider` door this 422 gate would preempt, and of "a write + // with NO OWD keys at all saves". Declaring `object` is therefore a + // contract decision (which door answers; is an unauthored OWD a refusal), + // escalated on #8310. A future declaration is a deliberate edit to THIS + // test, with that decision in hand. + expect(ENTRY.runtimeTypes).not.toContain('object'); + expect(runtimeAuthoringRulesFor('object')).toEqual([]); + const real = runRuntimeAuthoringRules({ + type: 'object', + item: { name: 'new_object', label: 'New Object', fields: {} }, // would trip owd-unset if gated + }); + expect(real.errors).toEqual([]); + expect(real.rulesRun, 'no rule runs for an object write — clean by absence, not by verdict').toEqual([]); + }); + + it("[#8310] position / app still reach no rule — role-word's residue, not an oversight", () => { + // The two collections only `security-role-word` judges. They stay ungated + // because that rule stays behind WHOLE (next case): gating the types + // without the rule would gate them on nothing, and wiring the rule for + // the other collections alone is the #7220 split. A future declaration + // for either type is a deliberate edit to THIS test. + for (const type of ['position', 'app']) { + expect( + runtimeAuthoringRulesFor(type), + `no rule gates '${type}' — crossing them is role-word's whole-family card, not a drift`, + ).toEqual([]); + expect(stackKeyForType(type)).toBeNull(); + } + }); + + it('[#8310] `security-role-word` stays behind WHOLE — one rule id, one side of the wall (#7220)', () => { + // The explicit call the card demands. The rule judges six collections + // (objects, fields, actions, permission sets, positions, apps — plus + // books); `positions`/`apps` are neither carried by the snapshot nor + // mapped, and BOTH types are runtime-creatable — so wiring the rule for + // the declared types alone would build a door that refuses a permission + // set named `role_manager` while a position named `sales_role` walks + // through. It therefore stays behind whole, as its own CLI-only entry. + expect(ROLE_ENTRY, 'the split entry must exist — role-word may not ride the crossed entry').toBeDefined(); + expect(ROLE_ENTRY.surfaces).toEqual(['cli']); + expect(ROLE_ENTRY.runtimeTypes).toBeUndefined(); + expect(ROLE_ENTRY.surfaceReason).toMatch(/positions\/apps/); + // Whole means whole: NO runtime-gated type reaches it. + for (const type of runtimeGatedTypes()) { + expect( + runtimeAuthoringRulesFor(type).map((r) => r.name), + `role-word must not run for '${type}' writes — that would be the #7220 split`, + ).not.toContain('validateSecurityRoleWord'); + } + // The premise that makes the split load-bearing rather than pedantic: + // position/app writes are REAL at this door (`allowRuntimeCreate: true`), + // so a partial wiring would really have admitted what it refuses elsewhere. + for (const type of ['position', 'app']) { + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type); + expect(entry?.allowRuntimeCreate, `'${type}' is runtime-creatable`).toBe(true); + } + + // The door side, measured on a WIRED type: a permission-set write named + // `role_manager` (nothing else about it trips) is NOT refused at the + // runtime gate — the vocabulary freeze deliberately does not run there. + const real = runRuntimeAuthoringRules({ + type: 'permission', + item: { name: 'role_manager', label: 'Manager', objects: {} }, + }); + expect(real.errors).toEqual([]); + expect(real.rulesRun).toContain('validateSecurityPosture'); + expect(real.rulesRun).not.toContain('validateSecurityRoleWord'); + + // The CLI side, unchanged by the split: both entries run on all three + // commands, and their findings UNION to exactly what the one function + // produced before — same rule id, same shape, nothing lost. + expect(ROLE_ENTRY.commands).toEqual(ENTRY.commands); + const roleWordy = { + objects: [{ name: 'sales_role', label: 'Sales Role', sharingModel: 'private', fields: {} }], + permissions: [{ name: 'role_manager', label: 'Manager', objects: {} }], + }; + const fromPosture = validateSecurityPosture(roleWordy).map((f) => f.rule); + const fromRoleWord = validateSecurityRoleWord(roleWordy).map((f) => f.rule); + expect(fromPosture, 'the crossed entry must no longer carry the vocabulary freeze').not.toContain(SECURITY_ROLE_WORD); + expect(fromRoleWord).toEqual([SECURITY_ROLE_WORD, SECURITY_ROLE_WORD]); // the object AND the set + expect(validateSecurityRoleWord(roleWordy).every((f) => f.severity === 'error')).toBe(true); }); - it('[#8309] the gate can now address `permission` and `book` — the wiring half of the card', () => { - // Until #8309 this case pinned the OPPOSITE: `stackKeyForType` answered - // null for both, so declaring either in `runtimeTypes` would have wired - // the rules onto nothing (the state the wiring guard's `every - // runtime-gated metadata type maps to a stack key` case refuses). The - // mappings now exist AHEAD of the registration — the `seed` order (#7576 → - // #8307) repeated — so #8310's flip is a registry data edit. The entries - // are inert until then: `runRuntimeAuthoringRules` filters by declared - // type before consulting the table, and case 1 above pins that nothing - // declares these types yet. + it('[#8309] the stack-key wiring the flip stands on', () => { + // Landed AHEAD of the registration (#8309), the same order `seed` arrived + // in (#7576 → #8307) — which is what kept #8310 a registry data edit. expect(stackKeyForType('permission')).toBe('permissions'); expect(stackKeyForType('book')).toBe('books'); expect(stackKeyForType('object')).toBe('objects'); expect(stackKeyForType('seed')).toBe('data'); - // The collections this block reads that stay UNMAPPED, measured: only the - // excluded-by-family `security-role-word` reads `positions`/`apps`, so - // #8309 deliberately does not carry or map them (whole family or not at - // all — #8310's call). - expect(stackKeyForType('position')).toBeNull(); - expect(stackKeyForType('app')).toBeNull(); }); - it('an OWD-less object write WOULD be refused — the strictness #8310 would escalate', () => { + it('an OWD-less object write WOULD be refused — the strictness the escalation is about', () => { // The body `METADATA_CREATE_SEEDS.object` carried BEFORE #8308: name, - // label, pluralLabel, fields — and no `sharingModel`. Kept literal as the - // refusal's positive control. + // label, fields — and no `sharingModel`. Kept literal as the would-be + // refusal's positive control, through the gate's OWN snapshot builder + // (the type is not declared, so the real gate cannot be asked). const added = wouldGateAdd('object', { name: 'new_object', label: 'New Object', fields: {} }); expect(added.map((f) => f.rule)).toEqual([SECURITY_OWD_UNSET]); expect(added[0].severity).toBe('error'); expect(added[0].path).toBe('objects[0].sharingModel'); - // And the same write with the OWD authored is clean, so the refusal is - // about the missing decision and not about object writes as such. + // And the same write with the OWD authored would be clean, so the refusal + // is about the missing decision and not about object writes as such. expect( wouldGateAdd('object', { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }), ).toEqual([]); }); - it('[#8308] the REAL create seed is clean at this gate — blocker A repaired', () => { - // The platform's own minimal create body now AUTHORS its OWD + it('[#8308] the REAL create seed would be clean at this gate — blocker A repaired, re-measured', () => { + // The platform's own minimal create body AUTHORS its OWD // (`sharingModel: 'private'` — the measured runtime default, ADR-0090 D1 / - // `effectiveSharingModel` in plugin-sharing), so the gate that #8310 will - // register for `object` refuses nothing on the platform's own create path. - // Consumed from the seed registry, not re-spelled, so a seed regression - // re-opens THIS pin rather than passing silently. + // `effectiveSharingModel` in plugin-sharing), so the gate the escalation + // would register for `object` refuses nothing on the platform's own + // create path — the fallout that was 26 refusals across 8 suite files + // before #8308 is measured ZERO on the repaired tree. Consumed from the + // seed registry, not re-spelled, so a seed regression re-opens THIS pin + // rather than passing silently. const seed = getMetadataCreateSeed('object') as AnyRec; expect(seed.sharingModel).toBe('private'); expect(wouldGateAdd('object', seed)).toEqual([]); }); - it('[#8309] a permission-set write AGREES with the whole-stack verdict — the phantom is gone', () => { + it('a clean object write would not be blamed for the context\'s pre-existing defects', () => { + // The differential's D4 promise, pre-verified for the day `object` + // crosses: a clean object write against a universe that ALREADY carries + // an OWD-less object must not inherit that finding — it fires identically + // in both passes and cancels. Without this, one legacy row would block + // every future publish. + const legacyContext = [{ name: 'legacy_thing', label: 'Legacy', fields: {} }]; // no sharingModel + expect( + wouldGateAdd( + 'object', + { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }, + { objects: legacyContext }, + ), + ).toEqual([]); + }); + + it('[#8309→#8310] a permission-set write AGREES with the whole-stack verdict at the real gate', () => { // Whole stack: `shop_clerk` grants the detail and the book's audience // resolves, so the three cross-collection rules find nothing. expect( @@ -252,47 +351,71 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed // fewer): a context carrying objects only — exactly what the gate used to // build — cannot see `shop_clerk`, so the detail reads as ungranted by // anyone. This is PR #7886's 38-vs-4 mechanism in miniature; deleting the - // snapshot enrichment makes the agreement half below fail INTO this shape. - const phantoms = wouldGateAdd('permission', TWO_SET_STACK.permissions[0], { - objects: TWO_SET_STACK.objects, + // snapshot enrichment makes the agreement pins below fail INTO this shape. + // Now measured through the REAL gate (the type is declared), where the + // warning-tier phantom surfaces as a non-blocking advisory. + const phantoms = runRuntimeAuthoringRules({ + type: 'permission', + item: TWO_SET_STACK.permissions[0], + context: { objects: TWO_SET_STACK.objects }, }); - expect(phantoms.map((f) => f.rule)).toEqual([SECURITY_MASTER_DETAIL_UNGRANTED]); - expect(phantoms[0].severity).toBe('warning'); - expect(phantoms[0].where).toBe('object "shop_invoice_line"'); - - // #8309's acceptance: the same write against the FULL context — the - // snapshot the gate now builds — adds nothing, agreeing with the - // whole-stack run. Both directions: the set that does not grant the - // detail, and the one that does. - expect(wouldGateAdd('permission', TWO_SET_STACK.permissions[0], TWO_SET_CONTEXT)).toEqual([]); - expect(wouldGateAdd('permission', TWO_SET_STACK.permissions[1], TWO_SET_CONTEXT)).toEqual([]); + expect(phantoms.advisories.map((f) => f.rule)).toEqual([SECURITY_MASTER_DETAIL_UNGRANTED]); + expect(phantoms.advisories[0].severity).toBe('warning'); + expect(phantoms.advisories[0].where).toBe('object "shop_invoice_line"'); + expect(phantoms.errors).toEqual([]); + + // #8309's acceptance, upgraded to #8310's production path: the same write + // against the FULL context — the snapshot the gate now builds — adds + // nothing, agreeing with the whole-stack run. Both directions: the set + // that does not grant the detail, and the one that does. + for (const item of TWO_SET_STACK.permissions) { + const real = runRuntimeAuthoringRules({ type: 'permission', item, context: TWO_SET_CONTEXT }); + expect(real.errors).toEqual([]); + expect(real.advisories).toEqual([]); + expect(real.rulesRun).toContain('validateSecurityPosture'); + } }); - it('[#8309] a permission-set UPDATE replaces its stored self — no duplicate-name double set', () => { - // Replace-not-erase now applies to `permissions` the way it always did to - // `objects`: re-publishing `shop_clerk` unchanged must judge a universe - // with ONE `shop_clerk`, not two, and attribute nothing to the write. - expect(wouldGateAdd('permission', { ...TWO_SET_STACK.permissions[1] }, TWO_SET_CONTEXT)).toEqual([]); + it('[#8309→#8310] a permission-set UPDATE replaces its stored self — no duplicate-name double set', () => { + // Replace-not-erase at the real gate: re-publishing `shop_clerk` unchanged + // must judge a universe with ONE `shop_clerk`, not two, and attribute + // nothing to the write. + const real = runRuntimeAuthoringRules({ + type: 'permission', + item: { ...TWO_SET_STACK.permissions[1] }, + context: TWO_SET_CONTEXT, + }); + expect(real.errors).toEqual([]); + expect(real.advisories).toEqual([]); }); - it('[#8309] a book write resolves its audience against the live permission sets', () => { - // The third cross-collection rule. Against the full context the audience - // resolves and the write is clean — agreeing with the whole-stack run. + it('[#8309→#8310] a book write resolves its audience against the live permission sets', () => { + // The third cross-collection rule, at the real gate. Against the full + // context the audience resolves and the write is clean — agreeing with + // the whole-stack run. const book = { name: 'clerk_handbook', label: 'Clerk Handbook', audience: { permissionSet: 'shop_clerk' } }; - expect(wouldGateAdd('book', book, TWO_SET_CONTEXT)).toEqual([]); + const clean = runRuntimeAuthoringRules({ type: 'book', item: book, context: TWO_SET_CONTEXT }); + expect(clean.errors).toEqual([]); + expect(clean.advisories).toEqual([]); // A genuinely dangling audience is still caught — enrichment kills the - // phantom, not the rule. + // phantom, not the rule. Warning-tier, so it advises rather than refuses + // (#4463 P1: only `error` findings block). const dangling = { name: 'ghost_guide', label: 'Ghost Guide', audience: { permissionSet: 'no_such_set' } }; - const added = wouldGateAdd('book', dangling, TWO_SET_CONTEXT); - expect(added.map((f) => f.rule)).toEqual([SECURITY_BOOK_AUDIENCE_UNKNOWN_SET]); - expect(added[0].severity).toBe('warning'); + const caught = runRuntimeAuthoringRules({ type: 'book', item: dangling, context: TWO_SET_CONTEXT }); + expect(caught.advisories.map((f) => f.rule)).toEqual([SECURITY_BOOK_AUDIENCE_UNKNOWN_SET]); + expect(caught.advisories[0].severity).toBe('warning'); + expect(caught.errors).toEqual([]); // And the pre-#8309 shape for books, kept as the reverse-verification // twin: with no `permissions` in the context every `{ permissionSet }` // audience read as unknown, so a CLEAN book write drew the same warning. - const phantoms = wouldGateAdd('book', book, { objects: TWO_SET_STACK.objects }); - expect(phantoms.map((f) => f.rule)).toEqual([SECURITY_BOOK_AUDIENCE_UNKNOWN_SET]); + const phantoms = runRuntimeAuthoringRules({ + type: 'book', + item: book, + context: { objects: TWO_SET_STACK.objects }, + }); + expect(phantoms.advisories.map((f) => f.rule)).toEqual([SECURITY_BOOK_AUDIENCE_UNKNOWN_SET]); }); it('[#8307] the ADR-0091 seed pair is REFUSED at the real runtime gate, on the corrected `data` stack key', () => { @@ -305,16 +428,12 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed object: 'sys_user_position', records: [{ user_id: 'u1', position: 'field_ops', valid_until: '2020-01-01T00:00:00Z' }], }; - // Real gate, not the mirror — `seed` is wired now, so the production - // function under test is `runRuntimeAuthoringRules` itself. const real = runRuntimeAuthoringRules({ type: 'seed', item: expiredGrant }); expect(real.errors.map((f) => f.rule)).toEqual([SECURITY_GRANT_EXPIRED_AT_AUTHORING]); expect(real.errors[0].severity).toBe('error'); expect(real.rulesRun).toContain('validateSecurityPosture'); // The mirror this file used before the crossing must still agree with the - // real gate now that both are askable — this is the mirror/gate parity - // pin `the mirror still matches the real gate (flow)` establishes for - // `flow`, repeated for the type this card actually wires. + // real gate now that both are askable. expect(wouldGateAdd('seed', expiredGrant).map((f) => f.rule)).toEqual( real.errors.map((f) => f.rule), ); @@ -353,15 +472,15 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed } }); - it('[#8307] a seed write leaks NO finding from the other 11 rule ids this ONE entry also carries', () => { - // The claim the registry comment makes: `runtimeTypes: ['seed']` is safe to - // declare on the WHOLE `validateSecurityPosture` entry (not a per-rule-id - // split) because the gate's baseline/candidate differential holds - // `stack.objects` identical across both passes for a `seed` write — so any - // finding this function derives from `stack.objects` fires (if at all) - // IDENTICALLY in both passes and cancels in the diff. Proven here against a - // context objects array that WOULD trip `security-owd-unset` and - // `security-role-word` if this block's other rules leaked through. + it('[#8307] a seed write leaks NO finding from the other rule ids this ONE entry also carries', () => { + // The claim the registry comment makes: declaring several types on the + // WHOLE entry (not a per-rule-id split) is safe because the gate's + // baseline/candidate differential holds every sibling collection + // identical across both passes for a `seed` write — so any finding this + // function derives from `stack.objects` fires (if at all) IDENTICALLY in + // both passes and cancels in the diff. Proven against a context object + // that WOULD trip `security-owd-unset` over the whole stack (and the + // vocabulary freeze in its own function) if the isolation failed. const trippyContext = [ { name: 'sales_role', label: 'Sales Role', fields: {} }, // no sharingModel + reserved word ]; @@ -369,21 +488,22 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed object: 'sys_user_position', records: [{ user_id: 'u1', position: 'field_ops', valid_until: '2099-01-01T00:00:00Z' }], }; - // Sanity: the context alone really would trip two rules over the whole - // stack, or this test would be vacuous. - const wholeStack = validateSecurityPosture({ objects: trippyContext }); - expect(wholeStack.map((f) => f.rule).sort()).toEqual([SECURITY_OWD_UNSET, SECURITY_ROLE_WORD]); + // Sanity: the context alone really would trip both rule sources over the + // whole stack, or this test would be vacuous. (`security-role-word` lives + // in its own function since #8310 — same file, same rule id.) + expect(validateSecurityPosture({ objects: trippyContext }).map((f) => f.rule)).toEqual([SECURITY_OWD_UNSET]); + expect(validateSecurityRoleWord({ objects: trippyContext }).map((f) => f.rule)).toEqual([SECURITY_ROLE_WORD]); const real = runRuntimeAuthoringRules({ type: 'seed', item: cleanGrant, context: { objects: trippyContext } }); expect(real.errors, 'the pre-existing object-body defects must NOT be attributed to this seed write').toEqual([]); expect(real.advisories).toEqual([]); }); - it('neither blocker touches the rules that judge one document', () => { - // The measured division the surfaceReason rests on: the five object-body - // rules and the two seed rules are self-contained, and it is the THREE - // cross-collection rules that need a snapshot the gate does not build. A - // seed write reaches no permission-set rule and vice versa. + it('the rules that judge one document stay self-contained across types', () => { + // The measured division the programme rests on: the object-body rules and + // the two seed rules are self-contained, and the three cross-collection + // rules judge against a snapshot the gate now carries. A seed write + // reaches no permission-set rule and vice versa. expect(wouldGateAdd('seed', { object: 'crm_account', records: [{ name: 'a' }] })).toEqual([]); expect( wouldGateAdd('object', { diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index 036d9d617d..a577adc90e 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -15,6 +15,7 @@ import { ObjectPermissionSchema, PermissionSetSchema } from '@objectstack/spec/s import { SECURITY_FLS_UNQUALIFIED_KEY, validateSecurityPosture, + validateSecurityRoleWord, SECURITY_OWD_UNSET, SECURITY_OWD_ALIAS, SECURITY_EXTERNAL_WIDER, @@ -208,8 +209,11 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { }); // ── Rule: security-role-word (ADR-0090 D3) ────────────────────────── + // [#8310] Its own function (and registry entry) since the rest of the block + // crossed the runtime publish surface — same file, same rule id, same + // findings; see `validateSecurityRoleWord`'s docblock for the #7220 reason. it('errors on "role" in identifiers and labels across kinds', () => { - const findings = validateSecurityPosture({ + const findings = validateSecurityRoleWord({ objects: [ { name: 'user_role', // identifier token @@ -228,7 +232,7 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { it('does not flag words merely containing the letters (payroll, controlled)', () => { expect( - rulesOf({ + validateSecurityRoleWord({ objects: [ { name: 'payroll_run', label: 'Payroll — Controlled Rollout', sharingModel: 'private' }, ], @@ -238,7 +242,7 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { it('skips system objects (better-auth sys_member.role is the documented exception)', () => { expect( - rulesOf({ objects: [{ name: 'sys_member', fields: { role: { name: 'role', label: 'Role' } } }] }), + validateSecurityRoleWord({ objects: [{ name: 'sys_member', fields: { role: { name: 'role', label: 'Role' } } }] }), ).toEqual([]); }); @@ -504,7 +508,7 @@ describe('validateSecurityPosture · controlled_by_parent with no relation (#750 describe('validateSecurityPosture · book audience (ADR-0046 §6.7 / ADR-0090)', () => { it('flags the reserved word in book names and labels', () => { - const findings = validateSecurityPosture({ + const findings = validateSecurityRoleWord({ books: [ { name: 'crm_role_guide', label: 'CRM Guide', groups: [] }, { name: 'crm_admin_guide', label: 'Admin Roles Handbook', groups: [] }, @@ -1008,10 +1012,13 @@ describe('validateSecurityPosture — every branch is reachable without an undec }); it('reaches every `findings.push` site from that corpus', () => { + // [#8310] Both exported rules of this module: `pushedRuleIds()` scans the + // whole source file, so the corpus must drive the whole file too. const emitted = new Set( - REACHABILITY_CORPUS.flatMap(({ stack }) => - validateSecurityPosture(stack, { nowMs: Date.parse('2026-07-10T12:00:00Z') }).map((f) => f.rule), - ), + REACHABILITY_CORPUS.flatMap(({ stack }) => [ + ...validateSecurityPosture(stack, { nowMs: Date.parse('2026-07-10T12:00:00Z') }), + ...validateSecurityRoleWord(stack), + ].map((f) => f.rule)), ); expect( [...new Set(pushedRuleIds())].filter((id) => !emitted.has(id)), diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 597bc34947..e72a8c8779 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -13,7 +13,7 @@ * | security-external-wider (error) | ADR-0090 D11 external ≤ internal| * | security-wildcard-vama (error) | ADR-0066 superuser wildcard | * | security-anchor-high-privilege(error) | ADR-0090 D5/D9 anchors | - * | security-role-word (error) | ADR-0090 D3 vocabulary freeze | + * | security-role-word (error) | ADR-0090 D3 vocabulary freeze — own function/registry entry since #8310 | * | security-book-audience-unknown-set(warn)| ADR-0046 §6.7 { permissionSet } | * | security-private-no-readscope (info) | admin-intent mismatch class | * | security-master-detail-ungranted(warn) | framework#2700 os-tianshun-mtc#43| @@ -430,65 +430,17 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } } } - // ── D3: the word "role" is reserved-forbidden ──────────────────────── - // Scope: security-relevant identifiers/labels (objects, fields, actions, - // permission sets, positions, apps). Pages/views/components are NOT - // scanned — `role` there is HTML/ARIA semantics, not permission vocabulary. - // The sole platform exception (better-auth `sys_member.role`) is a system - // object, which app stacks never author. - const flagRole = (kind: string, name: unknown, label: unknown, where: string, path: string) => { - if (identifierHasRoleToken(name)) { - findings.push({ - severity: 'error', - rule: SECURITY_ROLE_WORD, - where, - path, - message: - `${kind} name "${String(name)}" uses the reserved word "role" — the platform vocabulary ` + - `is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`, - hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`, - }); - } else if (labelHasRoleWord(label)) { - findings.push({ - severity: 'error', - rule: SECURITY_ROLE_WORD, - where, - path: `${path.replace(/\.name$/, '')}.label`, - message: `${kind} label "${String(label)}" uses the reserved word "role" (ADR-0090 D3).`, - hint: `Relabel with 'Position' (distribution) or a domain word — admins must meet ONE vocabulary.`, - }); - } - }; - - for (let i = 0; i < objects.length; i++) { - const obj = objects[i]; - if (!obj || typeof obj !== 'object' || isSystemObject(obj)) continue; - const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; - flagRole('object', obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`); - for (const f of asArray(obj.fields)) { - flagRole('field', f.name, f.label, `field "${objName}.${String(f.name ?? '?')}"`, `objects[${i}].fields.${String(f.name ?? '?')}.name`); - } - for (const [ai, action] of asArray(obj.actions).entries()) { - flagRole('action', action.name, action.label, `action "${objName}.${String(action.name ?? '?')}"`, `objects[${i}].actions[${ai}].name`); - } - } - for (let i = 0; i < permissionSets.length; i++) { - const ps = permissionSets[i]; - if (!ps || typeof ps !== 'object') continue; - flagRole('permission set', ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`); - } - for (const [i, pos] of asArray(stack.positions).entries()) { - flagRole('position', pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`); - } - for (const [i, app] of asArray(stack.apps).entries()) { - flagRole('app', app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`); - } - for (const [i, book] of asArray(stack.books).entries()) { - // Books entered the security-relevant set when `book.audience` became a - // permission-model reference (ADR-0046 §6.7 / ADR-0090): their names and - // labels are access-adjacent UI copy. - flagRole('book', book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`); - } + // ── D3 (`security-role-word`) lives in `validateSecurityRoleWord` below ── + // [#8310] Extracted into its own registry entry when the rest of this block + // crossed onto the runtime publish surface. The rule judges six collections + // (objects — names, fields, actions —, permission sets, positions, apps, + // books), and the per-write snapshot neither carries nor maps `positions` / + // `apps` — so it crosses that wall WHOLE or stays behind (#7220), and it + // stays behind. Keeping it inside this function would have wired it for a + // strict subset of its collections the moment this block's `runtimeTypes` + // widened: a door that refuses a permission set named `role_manager` while + // a position named `sales_role` walks through — the exact split the + // registry's #7220 pin refuses to build. // ── Book audience → permission-set reference must resolve ──────────── // A `{ permissionSet }` book audience names a set the reader must hold @@ -695,3 +647,92 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } return findings; } + +/** + * [ADR-0090 D3] The `security-role-word` vocabulary freeze, as its own rule. + * + * Scope: security-relevant identifiers/labels across SIX collections — objects + * (names, field names, action names), permission sets, positions, apps, books. + * Pages/views/components are NOT scanned — `role` there is HTML/ARIA + * semantics, not permission vocabulary. The sole platform exception + * (better-auth `sys_member.role`) is a system object, which app stacks never + * author. Books entered the security-relevant set when `book.audience` became + * a permission-model reference (ADR-0046 §6.7 / ADR-0090). + * + * ## Why this is a separate function from {@link validateSecurityPosture} + * + * [#8310] Not taste — a surface boundary. When the rest of the D7 block + * crossed onto the runtime publish surface (`runtimeTypes: ['seed', + * 'permission', 'book']` — `object` measured dirty and is escalated, see the + * registry comment), this rule could not go with it: the per-write snapshot + * (`runtime-gate.ts`) neither carries nor maps `positions` / `apps`, both of + * which are `allowRuntimeCreate: true` — so wiring it through the shared + * entry would have enforced ONE rule id for a strict subset of its six + * collections. That is the #7220 failure shape (a door that refuses a + * permission set named `role_manager` while a position named `sales_role` + * walks through), and + * the registry refuses to build it in either direction. The rule therefore + * stays behind WHOLE, on its own CLI-only registry entry, until the snapshot + * carries `positions`/`apps` and both types are gated — at which point it + * crosses whole, in one edit, as its own entry. + */ +export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { + const findings: SecurityFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + const objects = asArray(stack.objects); + const permissionSets = asArray(stack.permissions); + + const flagRole = (kind: string, name: unknown, label: unknown, where: string, path: string) => { + if (identifierHasRoleToken(name)) { + findings.push({ + severity: 'error', + rule: SECURITY_ROLE_WORD, + where, + path, + message: + `${kind} name "${String(name)}" uses the reserved word "role" — the platform vocabulary ` + + `is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`, + hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`, + }); + } else if (labelHasRoleWord(label)) { + findings.push({ + severity: 'error', + rule: SECURITY_ROLE_WORD, + where, + path: `${path.replace(/\.name$/, '')}.label`, + message: `${kind} label "${String(label)}" uses the reserved word "role" (ADR-0090 D3).`, + hint: `Relabel with 'Position' (distribution) or a domain word — admins must meet ONE vocabulary.`, + }); + } + }; + + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + if (!obj || typeof obj !== 'object' || isSystemObject(obj)) continue; + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + flagRole('object', obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`); + for (const f of asArray(obj.fields)) { + flagRole('field', f.name, f.label, `field "${objName}.${String(f.name ?? '?')}"`, `objects[${i}].fields.${String(f.name ?? '?')}.name`); + } + for (const [ai, action] of asArray(obj.actions).entries()) { + flagRole('action', action.name, action.label, `action "${objName}.${String(action.name ?? '?')}"`, `objects[${i}].actions[${ai}].name`); + } + } + for (let i = 0; i < permissionSets.length; i++) { + const ps = permissionSets[i]; + if (!ps || typeof ps !== 'object') continue; + flagRole('permission set', ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`); + } + for (const [i, pos] of asArray(stack.positions).entries()) { + flagRole('position', pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`); + } + for (const [i, app] of asArray(stack.apps).entries()) { + flagRole('app', app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`); + } + for (const [i, book] of asArray(stack.books).entries()) { + flagRole('book', book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`); + } + + return findings; +}