diff --git a/.changeset/security-posture-seed-pair-runtime-publish.md b/.changeset/security-posture-seed-pair-runtime-publish.md new file mode 100644 index 0000000000..07d3145c54 --- /dev/null +++ b/.changeset/security-posture-seed-pair-runtime-publish.md @@ -0,0 +1,36 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): the ADR-0091 seed pair now gates runtime `seed` publishes (#8307) + +`validateSecurityPosture`'s `surfaceReason` (#7576) named the ADR-0091 seed +pair — `security-grant-expired-at-authoring`, `security-delegation-missing-reason` +— as the one slice of the block ready to cross the runtime publish gate: both +rules read only `stack.data[]`, need no cross-collection resolution context, and +were measured trip-free on the shipped corpus (showcase, CRM, todo, blank). + +The `validateSecurityPosture` registry entry now declares +`surfaces: ['cli', 'runtime-publish']` with `runtimeTypes: ['seed']`. A runtime +`seed` publish — Studio, REST `/meta`, MCP/AI authors — is now refused with +`422 INVALID_METADATA` when an authored grant row on `sys_user_position` / +`sys_user_permission_set`: + +- carries a `valid_until` already in the past (or unparseable) at authoring + time — the row would never resolve (ADR-0091 D2, fail-closed); +- carries a `delegated_from` with no `reason` — the dual-audit trail ADR-0091 D3 + requires. + +The other eleven rule ids this ONE registry entry also carries (`object` / +`permission` / `book` posture, `security-role-word`, …) are **not** declared — +that remains #8310, still blocked on a strictness rollout for `object` and +`RUNTIME_NEEDS_FULL_SNAPSHOT` for `permission`/`book`. Declaring +`runtimeTypes: ['seed']` on the whole entry rather than splitting it is safe +because the runtime 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 identically in both passes and cancels in the +diff — pinned in `validate-security-posture.runtime-surface.test.ts`. + +Nothing changes for `os validate` / `os build` / `os lint`. Escape hatch +`OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration hatch for a stack +that authored one of these defects before this landed. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index db2849ed0d..14edd31d56 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1143,35 +1143,44 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // fail identically. That is RUNTIME_NEEDS_FULL_SNAPSHOT (#4463 P2), and it // is a snapshot change in the protocol package, not a `runtimeTypes` edit. // - // The residue that IS ready: 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. `runtime-gate.ts`'s `seed` stack key was corrected - // to `data` under this card so that slice is a one-line `runtimeTypes` edit - // when the rollout card takes it. `security-role-word` is deliberately NOT in - // that 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. + // 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. + // + // `object` / `permission` / `book` remain UNDECLARED here (that is #8310, + // still blocked): (a) declaring `object` makes `security-owd-unset` refuse + // every OWD-less runtime object publish — 26 refusals across 8 files of + // `@objectstack/metadata-protocol`'s own suite, and `METADATA_CREATE_SEEDS.object` + // carries no `sharingModel` — so it is a strictness rollout (#4001), not a + // wiring fix; (b) `permission` / `book` need a second collection the per-write + // snapshot does not carry, and were measured inventing findings without it (38 + // vs 4 over the shipped corpus) — RUNTIME_NEEDS_FULL_SNAPSHOT, #4463 P2. 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). { name: 'validateSecurityPosture', tier: 'gating', input: 'parsed', commands: ALL, source: 'packages/lint/src/validate-security-posture.ts', - surfaces: CLI_ONLY, - surfaceReason: - 'MEASURED, not inherited (#7576). The ADR-0094 `object` posture gate covers 1 of this block\'s 13 ' - + 'rule ids (`security-external-wider-than-internal`, its R2) — the previous reason claimed all of ' - + 'them, and its double-reporting worry was unreal: the two gates both THROW and this table runs ' - + 'first, so a write earns one refusal either way. The move is blocked by two other things. (a) ' - + 'Declaring `object` makes `security-owd-unset` refuse every OWD-less runtime object publish — 26 ' - + 'refusals across 8 files of metadata-protocol\'s own suite, and `METADATA_CREATE_SEEDS.object` ' - + 'carries no `sharingModel` — so it is a strictness rollout (#4001), not a wiring fix. (b) ' - + '`permission` / `book` need a second collection the per-write snapshot does not carry, and were ' - + 'measured inventing findings without it (38 vs 4 over the shipped corpus) — ' - + 'RUNTIME_NEEDS_FULL_SNAPSHOT, #4463 P2. The four shipped stacks themselves are clean at both ' - + 'surfaces; the ADR-0091 seed pair is snapshot-ready and crosses as a whole sub-family when (a) does.', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['seed'], run: (stack) => validateSecurityPosture(stack), }, // ADR-0105 D6 — the org tree is a REPORTING dimension. An RLS policy or diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index 23b26fb213..e1f7217104 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -85,9 +85,11 @@ const TYPE_TO_STACK_KEY: Readonly> = { // have stayed green while the gate built `{ objects, seeds: [item] }` for // every seed write and every rule reading `stack.data` saw nothing — wired, // and running on nothing, with `rulesRun` reporting the rules as having run. - // Nothing declares `seed` in `runtimeTypes` today, so correcting it changes no - // behaviour now; it is corrected here, with the measurement that found it - // (#7576), rather than left for the rollout card to trip over. + // Nothing declared `seed` in `runtimeTypes` at the time, so correcting it + // changed no behaviour then; it was corrected here, with the measurement + // that found it (#7576), rather than left for the rollout card to trip + // over. The ADR-0091 seed pair now DOES declare `seed` (#8307), so this + // mapping is load-bearing today, not merely inert-and-correct. seed: 'data', }; 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 7bd8c48130..83901bb8d3 100644 --- a/packages/lint/src/validate-security-posture.runtime-surface.test.ts +++ b/packages/lint/src/validate-security-posture.runtime-surface.test.ts @@ -1,22 +1,27 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #7576 — the MEASUREMENT behind `validateSecurityPosture`'s `surfaceReason`, -// kept executable so the reason cannot rot into prose. +// #7576 — the MEASUREMENT behind `validateSecurityPosture`'s runtime-surface +// wiring, kept executable so the registry entry's shape cannot rot into prose. // // The registry entry used to say the ADR-0094 `object` authoring gate // "enforces the same OWD posture rules", so putting this block on the runtime // 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. The reason is now a measured one, and -// every load-bearing number in it is asserted below. +// 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. // -// ## Why the assertions run a MIRROR of the gate +// ## Why cases 1-3 still run a MIRROR of the gate // -// No rule declares `object` / `permission` / `book` / `seed` in `runtimeTypes` -// today — that is the state the card is about — so `runRuntimeAuthoringRules` -// cannot be asked what this block would find there: it filters the registry by -// declared type and correctly returns nothing. The snapshot construction is +// 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. The snapshot construction is // therefore mirrored from `runtime-gate.ts` in `wouldGateAdd()` below, and // `the mirror still matches the real gate` pins the two together against a type // that IS wired, so the mirror cannot drift into measuring something the gate @@ -24,8 +29,9 @@ // // ## What each case is evidence FOR // -// 1. `the gate runs none of this block today` — the state, stated once, so the -// cases below are read as "what would happen", not "what happens". +// 1. `object` / `permission` / `book` / `position` / `app` still reach no rule +// here — the residual state #8310 will change, stated once so the mirrored +// 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 @@ -38,20 +44,32 @@ // is not a narrower version of the whole-stack verdict: it is a different // and wrong one. Over the shipped corpus this was 38 findings against the // whole-stack run's 4; the fixture reproduces the mechanism in miniature. -// 4. `the ADR-0091 seed pair is snapshot-ready` — the residue that IS ready, -// and the pin on the `seed` → `data` stack key corrected under this card. +// 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. import { describe, it, expect } from 'vitest'; import { getMetadataCreateSeed } from '@objectstack/spec/kernel'; import { AUTHORING_RULES } from './authoring-rules.js'; -import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor, stackKeyForType } from './runtime-gate.js'; +import { + runRuntimeAuthoringRules, + runtimeAuthoringRulesFor, + runtimeGatedTypes, + stackKeyForType, +} from './runtime-gate.js'; import { SECURITY_DELEGATION_MISSING_REASON, SECURITY_GRANT_EXPIRED_AT_AUTHORING, SECURITY_MASTER_DETAIL_UNGRANTED, SECURITY_OWD_UNSET, + SECURITY_ROLE_WORD, validateSecurityPosture, type SecurityFinding, } from './validate-security-posture.js'; @@ -114,8 +132,8 @@ const TWO_SET_STACK = { ], }; -describe('validateSecurityPosture at the runtime publish surface (#7576)', () => { - it('the mirror still matches the real gate', () => { +describe('validateSecurityPosture at the runtime publish surface (#7576, crossed for `seed` under #8307)', () => { + 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. // A snapshot change in `runtime-gate.ts` that this mirror did not follow @@ -133,19 +151,30 @@ describe('validateSecurityPosture at the runtime publish surface (#7576)', () => expect(stackKeyForType('flow')).toBe('flows'); }); - it('the gate runs none of this block today — the state the card measured', () => { - expect(ENTRY.surfaces).toEqual(['cli']); - expect(ENTRY.runtimeTypes ?? []).toEqual([]); - for (const type of ['object', 'permission', 'book', 'position', 'app', 'seed']) { + 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']) { expect( runtimeAuthoringRulesFor(type).map((r) => r.name), - `no rule gates '${type}' — if this changed, the surfaceReason below it must be re-measured`, + `no rule gates '${type}' yet — that is #8310, not this card`, ).not.toContain('validateSecurityPosture'); } - // The reason must keep NAMING its measurement, not merely be long enough to - // satisfy the wiring guard's 40-character floor. - expect(ENTRY.surfaceReason).toContain('#7576'); - expect(ENTRY.surfaceReason).toContain('1 of'); + }); + + 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('the gate cannot address every collection this block reads', () => { @@ -163,7 +192,7 @@ describe('validateSecurityPosture at the runtime publish surface (#7576)', () => expect(stackKeyForType('seed')).toBe('data'); }); - it('an OWD-less object write WOULD be refused — the strictness this card escalates', () => { + it('an OWD-less object write WOULD be refused — the strictness #8310 would escalate', () => { // The body `METADATA_CREATE_SEEDS.object` carried BEFORE #8308: name, // label, pluralLabel, fields — and no `sharingModel`. Kept literal as the // refusal's positive control. @@ -207,7 +236,7 @@ describe('validateSecurityPosture at the runtime publish surface (#7576)', () => expect(added[0].where).toBe('object "shop_invoice_line"'); }); - it('the ADR-0091 seed pair is snapshot-ready, on the corrected `data` stack key', () => { + it('[#8307] the ADR-0091 seed pair is REFUSED at the real runtime gate, on the corrected `data` stack key', () => { // The correction itself: the metadata TYPE is `seed`, the stack KEY is // `data`. `seeds` was the spelling before #7576, and the block reads // `stack.data` — so the gate would have judged an empty collection. @@ -217,17 +246,27 @@ describe('validateSecurityPosture at the runtime publish surface (#7576)', () => object: 'sys_user_position', records: [{ user_id: 'u1', position: 'field_ops', valid_until: '2020-01-01T00:00:00Z' }], }; - const added = wouldGateAdd(stackKeyForType('seed')!, expiredGrant); - expect(added.map((f) => f.rule)).toEqual([SECURITY_GRANT_EXPIRED_AT_AUTHORING]); - expect(added[0].severity).toBe('error'); + // 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. + expect(wouldGateAdd(stackKeyForType('seed')!, expiredGrant).map((f) => f.rule)).toEqual( + real.errors.map((f) => f.rule), + ); const undocumentedDelegation = { object: 'sys_user_permission_set', records: [{ user_id: 'u1', permission_set: 'billing', delegated_from: 'u2' }], }; - const delegated = wouldGateAdd(stackKeyForType('seed')!, undocumentedDelegation); - expect(delegated.map((f) => f.rule)).toEqual([SECURITY_DELEGATION_MISSING_REASON]); - expect(delegated[0].severity).toBe('error'); + const delegatedReal = runRuntimeAuthoringRules({ type: 'seed', item: undocumentedDelegation }); + expect(delegatedReal.errors.map((f) => f.rule)).toEqual([SECURITY_DELEGATION_MISSING_REASON]); + expect(delegatedReal.errors[0].severity).toBe('error'); // The pre-#7576 spelling, shown to be the inert state it was: the seed // lands on a key no rule reads, and the gate reports a clean write. @@ -235,6 +274,52 @@ describe('validateSecurityPosture at the runtime publish surface (#7576)', () => expect(onTheOldKey, 'a seed on `seeds` reaches no rule — that was the defect').toEqual([]); }); + it('[#8307] a clean seed write is REALLY clean at the real gate — the trip-free floor', () => { + // The positive control for the crossing: an authored grant/delegation row + // that satisfies both ADR-0091 checks earns zero errors and zero + // advisories at the real door — the crossing refuses defects, not seed + // writes as such. + const cleanGrant = { + object: 'sys_user_position', + records: [{ user_id: 'u1', position: 'field_ops', valid_until: '2099-01-01T00:00:00Z' }], + }; + const cleanDelegation = { + object: 'sys_user_permission_set', + records: [{ user_id: 'u1', permission_set: 'billing', delegated_from: 'u2', reason: 'vacation stand-in' }], + }; + for (const item of [cleanGrant, cleanDelegation]) { + const real = runRuntimeAuthoringRules({ type: 'seed', item }); + expect(real.errors).toEqual([]); + expect(real.advisories).toEqual([]); + } + }); + + 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. + const trippyContext = [ + { name: 'sales_role', label: 'Sales Role', fields: {} }, // no sharingModel + reserved word + ]; + const cleanGrant = { + 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]); + + 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