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
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand Down Expand Up @@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand Down Expand Up @@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading