Skip to content

Commit fc9dcef

Browse files
committed
lint(security-posture): record which intakes can reach security-owd-alias
`sharingModel` / `externalSharingModel` are closed enums (ADR-0090 D4 / D11), so on every door that parses before the registry runs the alias branches are unreachable by construction. Measured: `defineStack`, the `os validate` / `os compile` schema step, and `saveMetaItem`'s object-schema step all refuse the alias first; `os lint` on a raw object-literal config, `strict: false`, the docs gate and a direct call hand it to the rule, which fires. Annotates the rule's docblock and both alias branches with that intake table, and pins every leg (with its parsed-door control) in `authoring-rule-input-tier.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
1 parent c383352 commit fc9dcef

2 files changed

Lines changed: 159 additions & 2 deletions

File tree

packages/lint/src/authoring-rule-input-tier.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,20 @@
3232
// to see pre-parse evidence".
3333

3434
import { describe, expect, it, vi } from 'vitest';
35-
import { defineStack, normalizeStackInput } from '@objectstack/spec';
35+
import {
36+
ObjectStackSchema,
37+
applyConversionsToStoredItem,
38+
defineStack,
39+
normalizeStackInput,
40+
} from '@objectstack/spec';
41+
import { getMetadataTypeSchema } from '@objectstack/spec/kernel';
3642

3743
import { validateListViewMode } from './validate-list-view-mode.js';
3844
import { validateViewContainers } from './validate-view-containers.js';
3945
import { validateVisibilityPredicates } from './validate-visibility-predicates.js';
4046
import { runAuthoringRules } from './authoring-rules.js';
47+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
48+
import { SECURITY_OWD_ALIAS } from './validate-security-posture.js';
4149

4250
type AnyRec = Record<string, unknown>;
4351

@@ -374,3 +382,108 @@ describe('what `normalized` DOES buy: findings survive a schema error that stops
374382
expect(rules.has('view-container-shape')).toBe(true);
375383
});
376384
});
385+
386+
// ───────────────────────────────────────────────────────────────────────────
387+
// #16109 — `security-owd-alias` reaches the rule ONLY through the unparsed doors.
388+
//
389+
// `ObjectSchema.sharingModel` / `externalSharingModel` are closed enums
390+
// (ADR-0090 D4 / D11): every alias the rule names is refused with
391+
// `invalid_value` on any door that parses before the registry runs, so on a
392+
// `defineStack`-authored app the rule is dead by construction — the card's own
393+
// hotcrm measurement. It is NOT dead: `os lint` never parses, `loadConfig`
394+
// hands a raw object-literal config on as authored, and the ADR-0087 stored-row
395+
// conversion for these aliases is `retiredFromLoadPath`, so the alias survives
396+
// `normalizeStackInput` and the rule is the only diagnostic that door gets.
397+
// Each leg below is paired with the parsed-door control taken in the same run;
398+
// the module docblock's "## Intake" table in `validate-security-posture.ts`
399+
// is the prose form of these pins.
400+
describe('security-owd-alias reaches the rule only through the unparsed doors (#16109)', () => {
401+
const owdObject = (sharingModel: string) => ({
402+
name: 'tier_owd',
403+
label: 'OWD',
404+
sharingModel,
405+
fields: { title: { type: 'text', label: 'Title' } },
406+
});
407+
const rawStack = (sharingModel: string) => ({ manifest, objects: [owdObject(sharingModel)] });
408+
const aliasFindings = (findings: readonly { rule: string; path: string }[]) =>
409+
findings.filter((f) => f.rule === SECURITY_OWD_ALIAS).map((f) => f.path);
410+
/** Every key of the rule's `OWD_ALIAS_FIX` map. */
411+
const ALIASES = ['read', 'read_write', 'full', 'public'] as const;
412+
413+
it.each(ALIASES)('CONTROL — defineStack (strict default) refuses %s at load, before any rule runs', (alias) => {
414+
const { error } = quietly(() => defineStack(rawStack(alias) as never));
415+
expect(error).toBeDefined();
416+
expect(error!.message).toContain('objects.0.sharingModel');
417+
});
418+
419+
it.each(ALIASES)('CONTROL — the os validate / os compile schema step refuses %s on a raw config', (alias) => {
420+
const parsed = ObjectStackSchema.safeParse(normalizeStackInput(rawStack(alias)));
421+
expect(parsed.success).toBe(false);
422+
const issue = parsed.success ? undefined : parsed.error.issues.find((i) => i.path.join('.') === 'objects.0.sharingModel');
423+
expect(issue?.code).toBe('invalid_value');
424+
});
425+
426+
it.each(ALIASES)('INTAKE — os lint on a raw object-literal config hands %s to the rule intact, and the rule fires', (alias) => {
427+
// Exactly the call `lint.ts` makes: `loadConfig` (no parse) → `normalizeStackInput`
428+
// → `runAuthoringRules('lint', { normalized })`. No conversion notice fires:
429+
// `owd-legacy-read-aliases` is retired from the load path, and `full` /
430+
// `public` never had one.
431+
const notices: string[] = [];
432+
const normalized = normalizeStackInput(rawStack(alias), {
433+
onConversionNotice: (n) => notices.push(n.conversionId),
434+
}) as AnyRec;
435+
expect((normalized.objects as AnyRec[])[0].sharingModel).toBe(alias);
436+
expect(notices).toEqual([]);
437+
expect(aliasFindings(runAuthoringRules('lint', { normalized }))).toEqual(['objects[0].sharingModel']);
438+
});
439+
440+
it('INTAKE — defineStack(x, { strict: false }) skips the parse, so the alias reaches os lint too', () => {
441+
const loose = quietly(() => defineStack(rawStack('read_write') as never, { strict: false })).value as AnyRec;
442+
expect((loose.objects as AnyRec[])[0].sharingModel).toBe('read_write');
443+
expect(aliasFindings(runAuthoringRules('lint', { normalized: normalizeStackInput(loose) as AnyRec }))).toEqual([
444+
'objects[0].sharingModel',
445+
]);
446+
});
447+
448+
it('CONTROL — the rule is silent on a canonical value through the same unparsed door', () => {
449+
const normalized = normalizeStackInput(rawStack('public_read')) as AnyRec;
450+
expect(aliasFindings(runAuthoringRules('lint', { normalized }))).toEqual([]);
451+
});
452+
453+
it.each(ALIASES)('CONTROL — the runtime publish door refuses %s with the object schema before the gate runs', (alias) => {
454+
// `saveMetaItem` runs `getMetadataTypeSchema('object').safeParse` and 422s
455+
// BEFORE `runRuntimeAuthoringRules`; the gate never sees this item.
456+
const schema = getMetadataTypeSchema('object');
457+
expect(schema).toBeDefined();
458+
const parsed = schema!.safeParse(owdObject(alias));
459+
expect(parsed.success).toBe(false);
460+
expect(parsed.success ? undefined : parsed.error.issues.find((i) => i.path.join('.') === 'sharingModel')?.code).toBe('invalid_value');
461+
});
462+
463+
it('INTAKE — runRuntimeAuthoringRules called directly with an unparsed item fires (the exported API is a door)', () => {
464+
const result = runRuntimeAuthoringRules({
465+
type: 'object',
466+
item: owdObject('full'),
467+
context: { objects: [], permissions: [], books: [], datasets: [], pages: [] },
468+
});
469+
expect(aliasFindings(result.errors)).toEqual(['objects.tier_owd.sharingModel']);
470+
});
471+
472+
it('CONTROL — a pre-D4 stored sibling does not surface: read/read_write fold on rehydration, and the gate diff cancels the rest', () => {
473+
// The stored-row chain replays retired conversions, so `read` / `read_write`
474+
// come back canonical…
475+
expect((applyConversionsToStoredItem('object', owdObject('read')) as AnyRec).sharingModel).toBe('public_read');
476+
expect((applyConversionsToStoredItem('object', owdObject('read_write')) as AnyRec).sharingModel).toBe('public_read_write');
477+
// …`full` / `public` have no conversion and come back as authored…
478+
const storedFull = applyConversionsToStoredItem('object', owdObject('full')) as AnyRec;
479+
expect(storedFull.sharingModel).toBe('full');
480+
// …and even so, a sibling in the gate's universe produces the finding in
481+
// BOTH the baseline and the candidate pass, so it never leaves the gate.
482+
const result = runRuntimeAuthoringRules({
483+
type: 'object',
484+
item: { ...owdObject('private'), name: 'tier_other' },
485+
context: { objects: [storedFull], permissions: [], books: [], datasets: [], pages: [] },
486+
});
487+
expect(aliasFindings(result.errors)).toEqual([]);
488+
});
489+
});

packages/lint/src/validate-security-posture.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* | Rule | Origin |
1010
* |-----------------------------------------|---------------------------------|
1111
* | security-owd-unset (error) | objectui#2348 leave_request 事故 |
12-
* | security-owd-alias (error) | ADR-0090 D4 canonical enum |
12+
* | security-owd-alias (error) | ADR-0090 D4 canonical enum — UNPARSED intakes only, see § Intake |
1313
* | security-external-wider (error) | ADR-0090 D11 external ≤ internal|
1414
* | security-wildcard-vama (error) | ADR-0066 superuser wildcard |
1515
* | 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 @@
7575
* Directive #12). Here it also silently downgraded a NAMED rejection into an
7676
* inert branch — and an inert branch in a security linter reads, to the next
7777
* author, as a gate that is watching (#4984, #5009, #5017).
78+
*
79+
* ## Intake — which doors can reach `security-owd-alias` at all (#16109)
80+
*
81+
* `sharingModel` and `externalSharingModel` are CLOSED enums on `ObjectSchema`
82+
* (ADR-0090 D4 / D11): every value `OWD_ALIAS_FIX` names, and every
83+
* non-canonical string, is refused by the schema with `invalid_value`. So on
84+
* any door that PARSES before the registry runs, this rule's alias branches
85+
* are unreachable by construction — the object never arrives. Measured on
86+
* this package's dist (the pins live in `authoring-rule-input-tier.test.ts`,
87+
* "security-owd-alias reaches the rule only through the unparsed doors"):
88+
*
89+
* | door | alias reaches the rule? |
90+
* |-------------------------------------------------------------|-------------------------|
91+
* | `defineStack(x)` (strict default) — every TS config that | no — refused at load |
92+
* | `os init` scaffolds, hence `os validate`/`os build`/ | |
93+
* | `os lint` on such a config | |
94+
* | `os validate` / `os compile` schema step on a RAW config | no — stops before rules |
95+
* | `saveMetaItem` (Studio / REST `/meta` / MCP) — the runtime | no — 422 before the gate|
96+
* | publish gate runs AFTER `getMetadataTypeSchema('object')` | |
97+
* | a pre-D4 stored `sys_metadata` sibling in the gate's universe | no — cancels in the diff|
98+
* | **`os lint` on a RAW object-literal config** (never parses; | **yes — fires** |
99+
* | `loadConfig` returns the default export as authored, and | |
100+
* | `owd-legacy-read-aliases` is `retiredFromLoadPath`, so | |
101+
* | `normalizeStackInput` leaves the alias intact) | |
102+
* | **`defineStack(x, { strict: false })`** | **yes — fires** |
103+
* | **`check:doc-security-posture`** (docs gate: statically | **yes — fires**; its |
104+
* | evaluated `ObjectSchema.create({...})` literals, no parse) | self-test asserts it |
105+
* | **`runRuntimeAuthoringRules` / `validateSecurityPosture` | **yes — fires** |
106+
* | called directly with an unparsed item** (exported API) | |
107+
*
108+
* Read the two alias branches below accordingly: they are NOT a second
109+
* opinion on the enum, and they are dead on the parsed doors on purpose. They
110+
* exist so the UNPARSED doors — `os lint` first, the docs gate second — name
111+
* the canonical replacement instead of letting a retired spelling ride to
112+
* `os build`, where the enum's generic `invalid_value` is the only message. A
113+
* consumer crediting this rule id as live `error` coverage on a
114+
* `defineStack`-authored app is crediting the wrong gate: on that door the
115+
* credit belongs to the schema's closed enum.
78116
*/
79117

80118
import { describeAnchorForbiddenBits } from '@objectstack/spec/security';
@@ -346,6 +384,10 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
346384
`'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children).`,
347385
});
348386
} else if (typeof owd === 'string' && OWD_ALIAS_FIX[owd]) {
387+
// Reachable ONLY through the unparsed doors (`os lint` on a raw
388+
// config, `strict: false`, the docs gate, a direct call) — the D4 enum
389+
// refuses this value on every parsed door before the registry runs.
390+
// See "## Intake" in this module's docblock (#16109).
349391
findings.push({
350392
severity: 'error',
351393
rule: SECURITY_OWD_ALIAS,
@@ -462,6 +504,8 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
462504
// external ≤ internal. controlled_by_parent inherits the master's pair.
463505
if (typeof external === 'string') {
464506
if (OWD_ALIAS_FIX[external]) {
507+
// Same intake note as the `sharingModel` alias branch above: the D11
508+
// enum is closed, so only the unparsed doors can deliver this value.
465509
findings.push({
466510
severity: 'error',
467511
rule: SECURITY_OWD_ALIAS,

0 commit comments

Comments
 (0)