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
13 changes: 13 additions & 0 deletions .changeset/owd-alias-fix-provenance-split.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export default {
objects: [{
name: 'stdioprobe_task',
label: 'Task',
sharingModel: 'public',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
}],
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/serve-process-child-env.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export default {
objects: [{
name: 'childenv_task',
label: 'Task',
sharingModel: 'public',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
}],
};
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default {
objects: [{
name: 'stdoutpurity_task',
label: 'Task',
sharingModel: 'public',
sharingModel: 'private',
fields: { title: { type: 'text', label: 'Title' } },
}],
};
Expand Down
72 changes: 65 additions & 7 deletions packages/lint/src/validate-security-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -152,13 +155,45 @@ export interface SecurityFinding {
type AnyRec = Record<string, unknown>;

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<string, string> = {
/**
* [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<string, string> = {
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<string, string> = {
public: 'public_read_write',
};

/** Every value this rule offers a fix-it for — both provenance groups. */
const OWD_ALIAS_FIX: Record<string, string> = { ...OWD_RETIRED_ALIAS_FIX, ...OWD_WRONG_LAYER_FIX };
/** D11 ordering for external ≤ internal (controlled_by_parent excluded). */
const OWD_WIDTH: Record<string, number> = {
private: 0,
Expand All @@ -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.
*
Expand Down Expand Up @@ -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]}'.`,
});
Expand Down Expand Up @@ -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 (
Expand Down
Loading