diff --git a/.changeset/owd-alias-fix-provenance-split.md b/.changeset/owd-alias-fix-provenance-split.md new file mode 100644 index 0000000000..0608f31b76 --- /dev/null +++ b/.changeset/owd-alias-fix-provenance-split.md @@ -0,0 +1,13 @@ +--- +"@objectstack/lint": patch +--- + +`security-owd-alias` no longer tells authors that `sharingModel: 'public'` is a retired ADR-0090 D4 alias. It never was one — no shipped schema ever accepted it — and the map that said so is now split along the two histories it was conflating. + +`OWD_ALIAS_FIX` carried four keys under one comment, `[ADR-0090 D4] Legacy alias → canonical fix-it mapping`, while D4 names three: "The legacy aliases `read`, `read_write`, `full` are **removed from the zod enum**". Those three have a retirement behind them — the `owd-legacy-read-aliases` ADR-0087 stored-row conversion for the two `read*` spellings, the `13.owd-full-alias-removed` semantic entry for `full`. `public` has neither, and correctly so: a conversion rewrites a spelling some shipped schema once took, and this one never was taken, so its stored population is zero by construction. The missing conversion was the mislabel's shadow, not a gap. + +- **Two maps, one union.** `OWD_RETIRED_ALIAS_FIX` holds the three D4 aliases; `OWD_WRONG_LAYER_FIX` holds `public`; `OWD_ALIAS_FIX` stays as their union, so every key still earns the same rule id, the same path and the same fix-it. No accept set moves and no value starts or stops being reported. +- **The `public` fix-it is KEPT.** It catches a real authoring mistake: three neighbouring keys on the same `ObjectSchema` take `'public'` legally — `access.default` (`z.enum(['public', 'private'])`, ADR-0066) and `publicSharing.allowedAudiences` (`z.enum(['public', 'link_only', 'signed_in', 'email'])`) — and off-schema so does the sharing runtime's own internal vocabulary, `effectiveSharingModel(): 'private' | 'read' | 'public'`. `sharingModel` is the one neighbour that refuses it, and it fails CLOSED to `private` with no notice on the read path, so this fix-it is the author's only signal. +- **The message says which group it is in.** One shared clause, used by both the `sharingModel` and the `externalSharingModel` branch. A retired alias still reads `is a retired alias (ADR-0090 D4)`; `public` now reads that it is not an OWD value and never was, and names the neighbouring keys that do take it. The fix-it text, severity, rule id and path are byte-identical either way. + +Why the wording mattered enough to change: a diagnostic that credits `public` to D4 sends its reader looking for the conversion and the semantic entry that would exist if the acceptance had happened, and finding them absent reads as a data-fidelity defect in the conversion registry. It is not one. diff --git a/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts b/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts index 7a007fd1e9..73b67a0327 100644 --- a/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts +++ b/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts @@ -122,7 +122,7 @@ export default { objects: [{ name: 'mcpcollision_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], // NOTE: \`mcp\` is deliberately NOT declared. The banner advertises the MCP diff --git a/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts b/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts index df61d29125..b77f5093f0 100644 --- a/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts +++ b/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts @@ -105,7 +105,7 @@ export default { objects: [{ name: 'stdioprobe_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], }; diff --git a/packages/cli/test/serve-node-env-production-default.e2e.test.ts b/packages/cli/test/serve-node-env-production-default.e2e.test.ts index 9350089f47..b74f5d127e 100644 --- a/packages/cli/test/serve-node-env-production-default.e2e.test.ts +++ b/packages/cli/test/serve-node-env-production-default.e2e.test.ts @@ -270,7 +270,7 @@ export default { objects: [{ name: 'nodeenvdefault_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], // Constructed here, by the HOST — not left to os serve's own auto-inject. diff --git a/packages/cli/test/serve-process-child-env.e2e.test.ts b/packages/cli/test/serve-process-child-env.e2e.test.ts index 4578f8d23b..d4ca502140 100644 --- a/packages/cli/test/serve-process-child-env.e2e.test.ts +++ b/packages/cli/test/serve-process-child-env.e2e.test.ts @@ -145,7 +145,7 @@ export default { objects: [{ name: 'childenv_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], }; diff --git a/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts b/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts index da773c2e7c..feced02b1c 100644 --- a/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts +++ b/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts @@ -102,7 +102,7 @@ export default { objects: [{ name: 'stdoutpurity_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], }; diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index d616b72f1d..00069e9f36 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -79,7 +79,9 @@ * ## 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 + * (ADR-0090 D4 / D11): every value `OWD_ALIAS_FIX` names — both the D4 + * aliases a shipped schema once accepted and the wrong-layer spellings it + * never did, two histories declared as two maps below — and every other * 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 @@ -108,8 +110,9 @@ * 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 + * the canonical replacement instead of letting a retired or wrong-layer + * 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. @@ -152,13 +155,45 @@ export interface SecurityFinding { type AnyRec = Record; const CANONICAL_OWD = ['private', 'public_read', 'public_read_write', 'controlled_by_parent'] as const; -/** [ADR-0090 D4] Legacy alias → canonical fix-it mapping. */ -const OWD_ALIAS_FIX: Record = { +/** + * [ADR-0090 D4] The legacy `sharingModel` spellings a shipped schema once + * ACCEPTED, and the canonical value each becomes. D4 names exactly these + * THREE — "The legacy aliases `read`, `read_write`, `full` are **removed from + * the zod enum**" — and only these three have a retirement behind them: the + * `owd-legacy-read-aliases` ADR-0087 stored-row conversion for the two `read*` + * spellings, and the `13.owd-full-alias-removed` semantic entry for `full`. + */ +const OWD_RETIRED_ALIAS_FIX: Record = { read: 'public_read', read_write: 'public_read_write', full: 'public_read_write', +}; + +/** + * Wrong-layer / misspelling fix-its: values NO shipped schema ever accepted + * *here*. They are NOT retired aliases, and nothing sits behind them to + * retire — no ADR-0087 conversion, no semantic-migration entry, and none is + * possible, because a conversion rewrites a spelling some shipped schema once + * took and the stored population for these is zero by construction (#16517 + * read the enum's whole lifetime over complete history; `public` appears in no + * version of it). + * + * `public` is kept here rather than deleted because it catches a real + * authoring mistake. THREE neighbouring keys on the same `ObjectSchema` take + * `'public'` legally — `access.default` (`z.enum(['public', 'private'])`, + * ADR-0066) and `publicSharing.allowedAudiences` + * (`z.enum(['public', 'link_only', 'signed_in', 'email'])`) — and off-schema + * so does the sharing runtime's own internal vocabulary, + * `effectiveSharingModel(): 'private' | 'read' | 'public'`. `sharingModel` is + * the one neighbour that refuses it, and it fails CLOSED to `private` with no + * notice on the read path, so this fix-it is the author's only signal. + */ +const OWD_WRONG_LAYER_FIX: Record = { public: 'public_read_write', }; + +/** Every value this rule offers a fix-it for — both provenance groups. */ +const OWD_ALIAS_FIX: Record = { ...OWD_RETIRED_ALIAS_FIX, ...OWD_WRONG_LAYER_FIX }; /** D11 ordering for external ≤ internal (controlled_by_parent excluded). */ const OWD_WIDTH: Record = { private: 0, @@ -182,6 +217,29 @@ function owdOf(obj: AnyRec): unknown { return obj.sharingModel; } +/** + * The provenance half of an alias finding's message. + * + * The two halves of `OWD_ALIAS_FIX` do not share a history, so one sentence + * cannot serve both: calling `public` "a retired alias (ADR-0090 D4)" asserts + * an acceptance that never happened, and sends the reader looking for the + * conversion and the semantic entry that would exist if it had. The fix-it is + * identical either way; only this clause differs. + * + * The sibling keys are named undotted on purpose: the receiver-coverage + * meta-test in this rule's test file scans the module's CODE text for + * `receiver.key` reads and cannot tell one inside a message string from a real + * read, so a dotted spelling here would present as an undeclared read off a + * receiver that does not exist. + */ +function owdAliasProvenance(value: string): string { + return OWD_RETIRED_ALIAS_FIX[value] + ? `is a retired alias (ADR-0090 D4)` + : `is not an OWD value and never was — ADR-0090 D4 retired 'read', 'read_write' and 'full', ` + + `not this. '${value}' is legal on the neighbouring keys 'access' (its 'default') and ` + + `'publicSharing' (its 'allowedAudiences'), just not on this one`; +} + /** * A platform / system object: one the tenant did not author. * @@ -394,7 +452,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } where: `object "${objName}"`, path: `${objPath}.sharingModel`, message: - `sharingModel '${owd}' is a retired alias (ADR-0090 D4). The runtime fails CLOSED ` + + `sharingModel '${owd}' ${owdAliasProvenance(owd)}. The runtime fails CLOSED ` + `to 'private' on unknown values, so this object is NOT ${owd === 'read' ? 'readable' : 'writable'} org-wide.`, hint: `Replace with the canonical value: sharingModel: '${OWD_ALIAS_FIX[owd]}'.`, }); @@ -511,7 +569,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } rule: SECURITY_OWD_ALIAS, where: `object "${objName}"`, path: `${objPath}.externalSharingModel`, - message: `externalSharingModel '${external}' is a retired alias (ADR-0090 D4).`, + message: `externalSharingModel '${external}' ${owdAliasProvenance(external)}.`, hint: `Replace with the canonical value: externalSharingModel: '${OWD_ALIAS_FIX[external]}'.`, }); } else if (