diff --git a/.changeset/security-posture-surface-reason-measured.md b/.changeset/security-posture-surface-reason-measured.md new file mode 100644 index 0000000000..26fc25cea2 --- /dev/null +++ b/.changeset/security-posture-surface-reason-measured.md @@ -0,0 +1,46 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): `validateSecurityPosture`'s `surfaceReason` claimed a coverage the ADR-0094 gate does not give it — 1 of 13 rules (#7576) + +`AUTHORING_RULES` is a registry of self-describing entries, and the +`validateSecurityPosture` block's self-describing field was the least +trustworthy thing in it. Its `surfaceReason` — the written answer to "why does +this rule not run at the runtime publish gate?" — read: + +> Already gated at this surface by a DIFFERENT mechanism: plugin-security +> registers an ADR-0094 authoring gate on `object` (`registerAuthoringGate`) +> that enforces **the same OWD posture rules** on every runtime write. Running +> the linter here as well would double-report one refusal in two vocabularies. + +Both halves were false, and they were load-bearing: twelve of the block's +thirteen rules were enforced at no runtime door while the registry said +otherwise, and the write path is the only door a Studio tenant, a REST `/meta` +client or an MCP/AI author has. + +- **Coverage.** `object-posture-gate.ts` reads exactly `sharingModel` and + `externalSharingModel` through a local `OWD_WIDTH`, and never `fields`, + `permissions`, `books` or `data`. It covers ONE rule id — + `security-external-wider-than-internal`, its R2. Its other half, R1 + (env-tighten-only, ADR-0086 D1), corresponds to no lint rule, so it is not + coverage in the other direction either. +- **Double-reporting.** It cannot happen, structurally rather than by luck. + `saveMetaItem` runs `assertRuntimeAuthoringRules` (this table, 422 + `invalid_metadata`) *before* `runAuthoringGate` (the ADR-0094 gate, 403 + `owd_external_wider`), and both refuse by throwing. The first to fire ends the + write, so an author earns one refusal either way. + +The reason now states what was measured, including the two things that actually +block the move — a strictness rollout on `object` writes, and a per-write +snapshot that does not carry the collections three of the rules compare against. +`validate-security-posture.runtime-surface.test.ts` keeps those numbers +executable so the reason cannot rot back into prose. + +**No behaviour change.** The block stays `surfaces: CLI_ONLY`, runs on the same +three commands, and finds the same things. One latent defect is corrected +alongside it: the runtime gate mapped the `seed` metadata type to a stack key +`seeds`, which no stack has and no rule reads (seeds live on `data`). Nothing +declares `seed` in `runtimeTypes` today, so the correction is inert now — it +stops the gate from silently judging an empty collection for whoever declares it +first. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 66f9aa218f..e32266e04d 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1069,6 +1069,64 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // a runtime enforcement point (fail-closed OWD default, canonical enum, anchor // binding gate, vocabulary freeze), moving the failure from a runtime deny to // an author-time fix-it. Per ADR-0049 this is not advisory security. + // + // [#7576] The `surfaceReason` below is MEASURED. Its predecessor was not, and + // was false in both halves — it read: "Already gated at this surface by a + // DIFFERENT mechanism: plugin-security registers an ADR-0094 authoring gate on + // `object` (`registerAuthoringGate`) that enforces the same OWD posture rules + // on every runtime write. Running the linter here as well would double-report + // one refusal in two vocabularies." + // + // - COVERAGE. `object-posture-gate.ts` reads exactly `sharingModel` and + // `externalSharingModel` through a local `OWD_WIDTH`, and never touches + // `fields`, `permissions`, `books` or `data`. Of the THIRTEEN rule ids this + // block carries it covers ONE — `security-external-wider-than-internal` + // (its R2). The gate's other half, R1 (env-tighten-only, ADR-0086 D1), + // corresponds to no lint rule at all, so it is not coverage in the other + // direction either. Twelve rules were enforced at no runtime door while + // this field said they were. + // - DOUBLE-REPORTING. It cannot happen, and not by luck: `saveMetaItem` runs + // `assertRuntimeAuthoringRules` (this table, 422 `invalid_metadata`) BEFORE + // `runAuthoringGate` (the ADR-0094 gate, 403 `owd_external_wider`), and + // both refuse by THROWING. The first to fire ends the write, so an author + // 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 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` fail for a different, structural reason. The gate + // carries `objects` as resolution context and nothing else + // (`RuntimeStackContext`), so the three cross-collection rules judge a + // snapshot missing the collection they compare against. Measured: one + // simulated runtime write per shipped permission set produces 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 reads as ungranted. + // `security-private-no-readscope` and `security-book-audience-unknown-set` + // 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. { name: 'validateSecurityPosture', tier: 'gating', @@ -1076,10 +1134,18 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/validate-security-posture.ts', surfaces: CLI_ONLY, - surfaceReason: 'Already gated at this surface by a DIFFERENT mechanism: plugin-security registers an ADR-0094 ' - + 'authoring gate on `object` (`registerAuthoringGate`) that enforces the same OWD posture rules on ' - + 'every runtime write. Running the linter here as well would double-report one refusal in two ' - + 'vocabularies. Consolidating the two onto this table is P2 (#4463), and is a merge, not a hole.', + 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.', 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 fadb81b9f1..23b26fb213 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -74,7 +74,21 @@ const TYPE_TO_STACK_KEY: Readonly> = { dashboard: 'dashboards', agent: 'agents', hook: 'hooks', - seed: 'seeds', + // [#7576] `data`, NOT `seeds`. The metadata TYPE is `seed`; the stack KEY that + // holds seeds is `data` (`ObjectStackDefinitionSchema.data: z.array(SeedSchema)`) + // — a stack has no `seeds` key at all, and `PLURAL_TO_SINGULAR` declares no + // mapping onto one either. + // + // The wrong spelling was INERT rather than harmless, and it is the #4449 shape + // one surface over: the wiring guard asks only that a declared type HAS a + // mapping, never that the mapping names a key some rule reads. So it would + // 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. + seed: 'data', }; /** Everything the gate needs from the host runtime to build a snapshot. */ diff --git a/packages/lint/src/validate-security-posture.runtime-surface.test.ts b/packages/lint/src/validate-security-posture.runtime-surface.test.ts new file mode 100644 index 0000000000..eed84363cf --- /dev/null +++ b/packages/lint/src/validate-security-posture.runtime-surface.test.ts @@ -0,0 +1,239 @@ +// 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. +// +// 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. +// +// ## Why the assertions 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 +// 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 +// would not do. +// +// ## 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". +// 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 INVENTS findings` — the positive control for the +// other blocker. The three cross-collection rules compare against a +// collection `RuntimeStackContext` does not carry, so the per-write verdict +// 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. + +import { describe, it, expect } from 'vitest'; + +import { AUTHORING_RULES } from './authoring-rules.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor, stackKeyForType } from './runtime-gate.js'; +import { + SECURITY_DELEGATION_MISSING_REASON, + SECURITY_GRANT_EXPIRED_AT_AUTHORING, + SECURITY_MASTER_DETAIL_UNGRANTED, + SECURITY_OWD_UNSET, + validateSecurityPosture, + type SecurityFinding, +} from './validate-security-posture.js'; + +type AnyRec = Record; + +const ENTRY = AUTHORING_RULES.find((r) => r.name === 'validateSecurityPosture')!; + +/** 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}`; + +/** + * What the runtime publish gate WOULD attribute to one write landing on + * `stackKey`, against `contextObjects` as the live object universe. + * + * Mirrors `runRuntimeAuthoringRules`: same baseline/candidate construction, + * same replace-not-erase rule when the written type IS the context collection, + * same set difference. Pinned against the real function below. + * + * Takes the stack KEY rather than the metadata type because two of the + * collections this block reads have no `TYPE_TO_STACK_KEY` entry at all — see + * `the gate cannot address every collection this block reads`, which measures + * that rather than papering over it with a speculative mapping. + */ +function wouldGateAdd(stackKey: string, item: AnyRec, contextObjects: AnyRec[] = []): SecurityFinding[] { + const itemName = typeof item.name === 'string' ? item.name : undefined; + const writesIntoContext = stackKey === 'objects'; + const baselineObjects = writesIntoContext + ? contextObjects.filter((o) => !itemName || o?.name !== itemName) + : contextObjects; + const baseline: AnyRec = { objects: baselineObjects }; + const candidate: AnyRec = writesIntoContext + ? { objects: [...baselineObjects, item] } + : { objects: baselineObjects, [stackKey]: [item] }; + const before = new Set(validateSecurityPosture(baseline).map(fingerprint)); + return validateSecurityPosture(candidate).filter((f) => !before.has(fingerprint(f))); +} + +/** Two permission sets that between them grant the detail — the ordinary shape. */ +const TWO_SET_STACK = { + objects: [ + { name: 'shop_invoice', label: 'Invoice', sharingModel: 'private', fields: { title: { type: 'text', label: 'T' } } }, + { + name: 'shop_invoice_line', + label: 'Invoice Line', + sharingModel: 'controlled_by_parent', + fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'shop_invoice', required: true } }, + }, + ], + permissions: [ + { name: 'shop_billing', label: 'Billing', objects: { shop_invoice: { allowRead: true, readScope: 'org' } } }, + { + name: 'shop_clerk', + label: 'Clerk', + objects: { + shop_invoice: { allowRead: true, readScope: 'org' }, + shop_invoice_line: { allowRead: true, allowCreate: true }, + }, + }, + ], +}; + +describe('validateSecurityPosture at the runtime publish surface (#7576)', () => { + it('the mirror still matches the real gate', () => { + // 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 + // shows up here rather than as a silently wrong measurement below. + const brokenFlow = { + name: 'leave_approval', + nodes: [ + { id: 'start', type: 'start' }, + { id: 'approve', type: 'approval', config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] } }, + ], + }; + const objects = [{ name: 'leave_request', fields: { owner: { type: 'text' } } }]; + const real = runRuntimeAuthoringRules({ type: 'flow', item: brokenFlow, context: { objects } }); + expect(real.errors.length, 'the gate must find something, or the comparison is vacuous').toBeGreaterThan(0); + 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']) { + expect( + runtimeAuthoringRulesFor(type).map((r) => r.name), + `no rule gates '${type}' — if this changed, the surfaceReason below it must be re-measured`, + ).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('the gate cannot address every collection this block reads', () => { + // A second, independent way the move is not a one-field edit: `permission` + // and `book` are runtime-creatable metadata types, and the gate's + // type→stack-key table does not name them at all. Declaring either in + // `runtimeTypes` without adding the mapping fails the wiring guard's + // `every runtime-gated metadata type maps to a stack key` case — which is + // the guard working, since without a mapping `runRuntimeAuthoringRules` + // returns an empty verdict and the rules would be wired onto nothing. + expect(stackKeyForType('permission')).toBeNull(); + expect(stackKeyForType('book')).toBeNull(); + // The two the block CAN reach today, one of them corrected under this card. + expect(stackKeyForType('object')).toBe('objects'); + expect(stackKeyForType('seed')).toBe('data'); + }); + + it('an OWD-less object write WOULD be refused — the strictness this card escalates', () => { + // `METADATA_CREATE_SEEDS.object` is exactly this body: name, label, + // pluralLabel, fields — and no `sharingModel`. + const added = wouldGateAdd('objects', { 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. + expect( + wouldGateAdd('objects', { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }), + ).toEqual([]); + }); + + it('a permission-set write INVENTS a finding the whole-stack run does not', () => { + // Whole stack: `shop_clerk` grants the detail, so nothing is ungranted. + expect( + validateSecurityPosture(TWO_SET_STACK).filter((f) => f.rule === SECURITY_MASTER_DETAIL_UNGRANTED), + ).toEqual([]); + + // One write of the set that does NOT grant it, against the same objects: + // the snapshot cannot see `shop_clerk`, so the detail reads as ungranted by + // anyone. The verdict is not narrower than the whole-stack one — it is + // different, and wrong. + const added = wouldGateAdd('permissions', TWO_SET_STACK.permissions[0], TWO_SET_STACK.objects); + expect(added.map((f) => f.rule)).toEqual([SECURITY_MASTER_DETAIL_UNGRANTED]); + expect(added[0].severity).toBe('warning'); + expect(added[0].where).toBe('object "shop_invoice_line"'); + }); + + it('the ADR-0091 seed pair is snapshot-ready, 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. + expect(stackKeyForType('seed')).toBe('data'); + + const expiredGrant = { + 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'); + + 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'); + + // 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. + const onTheOldKey = validateSecurityPosture({ objects: [], seeds: [expiredGrant] }); + expect(onTheOldKey, 'a seed on `seeds` reaches no rule — that was the defect').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. + expect(wouldGateAdd('data', { object: 'crm_account', records: [{ name: 'a' }] })).toEqual([]); + expect( + wouldGateAdd('objects', { + name: 'shop_line', + label: 'Line', + sharingModel: 'controlled_by_parent', + fields: { note: { type: 'text', label: 'N' } }, + }).map((f) => `${f.rule}/${f.severity}`), + 'controlled_by_parent with no relation is judged from the object body alone', + ).toEqual(['security-controlled-by-parent-no-relation/error']); + }); +});