Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion packages/lint/src/authoring-rule-input-tier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

Expand Down Expand Up @@ -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([]);
});
});
46 changes: 45 additions & 1 deletion packages/lint/src/validate-security-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading