diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index da232cf21b..87513e0cbb 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -32,12 +32,20 @@ // to see pre-parse evidence". import { describe, expect, it, vi } from 'vitest'; -import { defineStack, normalizeStackInput } from '@objectstack/spec'; +import { + ObjectStackSchema, + applyConversionsToStoredItem, + defineStack, + normalizeStackInput, +} from '@objectstack/spec'; +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; import { validateListViewMode } from './validate-list-view-mode.js'; import { validateViewContainers } from './validate-view-containers.js'; import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; import { runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { SECURITY_OWD_ALIAS } from './validate-security-posture.js'; type AnyRec = Record; @@ -374,3 +382,120 @@ describe('what `normalized` DOES buy: findings survive a schema error that stops expect(rules.has('view-container-shape')).toBe(true); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// #16109 — `security-owd-alias` reaches the rule ONLY through the unparsed doors. +// +// `ObjectSchema.sharingModel` / `externalSharingModel` are closed enums +// (ADR-0090 D4 / D11): every alias the rule names is refused with +// `invalid_value` on any door that parses before the registry runs, so on a +// `defineStack`-authored app the rule is dead by construction — the card's own +// hotcrm measurement. It is NOT dead: `os lint` never parses, `loadConfig` +// hands a raw object-literal config on as authored, and the ADR-0087 stored-row +// conversion for these aliases is `retiredFromLoadPath`, so the alias survives +// `normalizeStackInput` and the rule is the only diagnostic that door gets. +// Each leg below is paired with the parsed-door control taken in the same run; +// the module docblock's "## Intake" table in `validate-security-posture.ts` +// is the prose form of these pins. +describe('security-owd-alias reaches the rule only through the unparsed doors (#16109)', () => { + const owdObject = (sharingModel: string) => ({ + name: 'tier_owd', + label: 'OWD', + sharingModel, + fields: { title: { type: 'text', label: 'Title' } }, + }); + const rawStack = (sharingModel: string) => ({ manifest, objects: [owdObject(sharingModel)] }); + const aliasFindings = (findings: readonly { rule: string; path: string }[]) => + findings.filter((f) => f.rule === SECURITY_OWD_ALIAS).map((f) => f.path); + /** Every key of the rule's `OWD_ALIAS_FIX` map, with the canonical value its fix-it names. */ + const ALIAS_FIX = { + read: 'public_read', + read_write: 'public_read_write', + full: 'public_read_write', + public: 'public_read_write', + } as const; + const ALIASES = Object.keys(ALIAS_FIX) as (keyof typeof ALIAS_FIX)[]; + + it.each(ALIASES)('CONTROL — defineStack (strict default) refuses %s at load, before any rule runs', (alias) => { + const { error } = quietly(() => defineStack(rawStack(alias) as never)); + expect(error).toBeDefined(); + expect(error!.message).toContain('objects.0.sharingModel'); + }); + + it.each(ALIASES)('CONTROL — the os validate / os compile schema step refuses %s on a raw config', (alias) => { + const parsed = ObjectStackSchema.safeParse(normalizeStackInput(rawStack(alias))); + expect(parsed.success).toBe(false); + const issue = parsed.success ? undefined : parsed.error.issues.find((i) => i.path.join('.') === 'objects.0.sharingModel'); + expect(issue?.code).toBe('invalid_value'); + }); + + it.each(ALIASES)('INTAKE — os lint on a raw object-literal config hands %s to the rule intact, and the rule fires', (alias) => { + // Exactly the call `lint.ts` makes: `loadConfig` (no parse) → `normalizeStackInput` + // → `runAuthoringRules('lint', { normalized })`. No conversion notice fires: + // `owd-legacy-read-aliases` is retired from the load path, and `full` / + // `public` never had one. + const notices: string[] = []; + const normalized = normalizeStackInput(rawStack(alias), { + onConversionNotice: (n) => notices.push(n.conversionId), + }) as AnyRec; + expect((normalized.objects as AnyRec[])[0].sharingModel).toBe(alias); + expect(notices).toEqual([]); + const findings = runAuthoringRules('lint', { normalized }).filter((f) => f.rule === SECURITY_OWD_ALIAS); + expect(findings.map((f) => f.path)).toEqual(['objects[0].sharingModel']); + // The fix-it is what this door gets that the enum's `invalid_value` does not + // — and it is the ALIAS branch's own contribution: the sibling + // "not canonical" branch names the same rule id at the same path but no + // replacement, so this line is what tells the two apart. + expect(findings[0].hint).toContain(`sharingModel: '${ALIAS_FIX[alias]}'`); + }); + + it('INTAKE — defineStack(x, { strict: false }) skips the parse, so the alias reaches os lint too', () => { + const loose = quietly(() => defineStack(rawStack('read_write') as never, { strict: false })).value as AnyRec; + expect((loose.objects as AnyRec[])[0].sharingModel).toBe('read_write'); + expect(aliasFindings(runAuthoringRules('lint', { normalized: normalizeStackInput(loose) as AnyRec }))).toEqual([ + 'objects[0].sharingModel', + ]); + }); + + it('CONTROL — the rule is silent on a canonical value through the same unparsed door', () => { + const normalized = normalizeStackInput(rawStack('public_read')) as AnyRec; + expect(aliasFindings(runAuthoringRules('lint', { normalized }))).toEqual([]); + }); + + it.each(ALIASES)('CONTROL — the runtime publish door refuses %s with the object schema before the gate runs', (alias) => { + // `saveMetaItem` runs `getMetadataTypeSchema('object').safeParse` and 422s + // BEFORE `runRuntimeAuthoringRules`; the gate never sees this item. + const schema = getMetadataTypeSchema('object'); + expect(schema).toBeDefined(); + const parsed = schema!.safeParse(owdObject(alias)); + expect(parsed.success).toBe(false); + expect(parsed.success ? undefined : parsed.error.issues.find((i) => i.path.join('.') === 'sharingModel')?.code).toBe('invalid_value'); + }); + + it('INTAKE — runRuntimeAuthoringRules called directly with an unparsed item fires (the exported API is a door)', () => { + const result = runRuntimeAuthoringRules({ + type: 'object', + item: owdObject('full'), + context: { objects: [], permissions: [], books: [], datasets: [], pages: [] }, + }); + expect(aliasFindings(result.errors)).toEqual(['objects.tier_owd.sharingModel']); + }); + + it('CONTROL — a pre-D4 stored sibling does not surface: read/read_write fold on rehydration, and the gate diff cancels the rest', () => { + // The stored-row chain replays retired conversions, so `read` / `read_write` + // come back canonical… + expect((applyConversionsToStoredItem('object', owdObject('read')) as AnyRec).sharingModel).toBe('public_read'); + expect((applyConversionsToStoredItem('object', owdObject('read_write')) as AnyRec).sharingModel).toBe('public_read_write'); + // …`full` / `public` have no conversion and come back as authored… + const storedFull = applyConversionsToStoredItem('object', owdObject('full')) as AnyRec; + expect(storedFull.sharingModel).toBe('full'); + // …and even so, a sibling in the gate's universe produces the finding in + // BOTH the baseline and the candidate pass, so it never leaves the gate. + const result = runRuntimeAuthoringRules({ + type: 'object', + item: { ...owdObject('private'), name: 'tier_other' }, + context: { objects: [storedFull], permissions: [], books: [], datasets: [], pages: [] }, + }); + expect(aliasFindings(result.errors)).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 91013eb678..d616b72f1d 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -9,7 +9,7 @@ * | Rule | Origin | * |-----------------------------------------|---------------------------------| * | security-owd-unset (error) | objectui#2348 leave_request 事故 | - * | security-owd-alias (error) | ADR-0090 D4 canonical enum | + * | security-owd-alias (error) | ADR-0090 D4 canonical enum — UNPARSED intakes only, see § Intake | * | 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 — declared `everyone` suggestions (`isDefault: true`) only; a `guest`-bound set is outside a package-time linter's sight and is the bind-time gate's alone (#16110) | @@ -75,6 +75,44 @@ * Directive #12). Here it also silently downgraded a NAMED rejection into an * inert branch — and an inert branch in a security linter reads, to the next * author, as a gate that is watching (#4984, #5009, #5017). + * + * ## Intake — which doors can reach `security-owd-alias` at all (#16109) + * + * `sharingModel` and `externalSharingModel` are CLOSED enums on `ObjectSchema` + * (ADR-0090 D4 / D11): every value `OWD_ALIAS_FIX` names, and every + * non-canonical string, is refused by the schema with `invalid_value`. So on + * any door that PARSES before the registry runs, this rule's alias branches + * are unreachable by construction — the object never arrives. Measured on + * this package's dist (the pins live in `authoring-rule-input-tier.test.ts`, + * "security-owd-alias reaches the rule only through the unparsed doors"): + * + * | door | alias reaches the rule? | + * |-------------------------------------------------------------|-------------------------| + * | `defineStack(x)` (strict default) — every TS config that | no — refused at load | + * | `os init` scaffolds, hence `os validate`/`os build`/ | | + * | `os lint` on such a config | | + * | `os validate` / `os compile` schema step on a RAW config | no — stops before rules | + * | `saveMetaItem` (Studio / REST `/meta` / MCP) — the runtime | no — 422 before the gate| + * | publish gate runs AFTER `getMetadataTypeSchema('object')` | | + * | a pre-D4 stored `sys_metadata` sibling in the gate's universe | no — cancels in the diff| + * | **`os lint` on a RAW object-literal config** (never parses; | **yes — fires** | + * | `loadConfig` returns the default export as authored, and | | + * | `owd-legacy-read-aliases` is `retiredFromLoadPath`, so | | + * | `normalizeStackInput` leaves the alias intact) | | + * | **`defineStack(x, { strict: false })`** | **yes — fires** | + * | **`check:doc-security-posture`** (docs gate: statically | **yes — fires**; its | + * | evaluated `ObjectSchema.create({...})` literals, no parse) | self-test asserts it | + * | **`runRuntimeAuthoringRules` / `validateSecurityPosture` | **yes — fires** | + * | called directly with an unparsed item** (exported API) | | + * + * Read the two alias branches below accordingly: they are NOT a second + * opinion on the enum, and they are dead on the parsed doors on purpose. They + * exist so the UNPARSED doors — `os lint` first, the docs gate second — name + * the canonical replacement instead of letting a retired spelling ride to + * `os build`, where the enum's generic `invalid_value` is the only message. A + * consumer crediting this rule id as live `error` coverage on a + * `defineStack`-authored app is crediting the wrong gate: on that door the + * credit belongs to the schema's closed enum. */ import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; @@ -346,6 +384,10 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } `'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children).`, }); } else if (typeof owd === 'string' && OWD_ALIAS_FIX[owd]) { + // Reachable ONLY through the unparsed doors (`os lint` on a raw + // config, `strict: false`, the docs gate, a direct call) — the D4 enum + // refuses this value on every parsed door before the registry runs. + // See "## Intake" in this module's docblock (#16109). findings.push({ severity: 'error', rule: SECURITY_OWD_ALIAS, @@ -462,6 +504,8 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } // external ≤ internal. controlled_by_parent inherits the master's pair. if (typeof external === 'string') { if (OWD_ALIAS_FIX[external]) { + // Same intake note as the `sharingModel` alias branch above: the D11 + // enum is closed, so only the unparsed doors can deliver this value. findings.push({ severity: 'error', rule: SECURITY_OWD_ALIAS,