Skip to content

Commit 65849e4

Browse files
committed
fix(plugin-security)!: split controlled_by_parent write refusals by true semantics (#7474)
`assertControlledByParentWrite` funnelled SIX distinct conditions through one `deny()` helper, so all six answered `403 PERMISSION_DENIED` with one sentence — "requires edit access to its master record". Three of them are genuine authorization verdicts. The other three are not verdicts at all, and the shared sentence was a false statement carrying a false remedy: "ask whoever owns the parent record" cannot fix a null master FK, a deleted row, or an object that declares `controlled_by_parent` with no `master_detail` relation to derive access from. Per the maintainer ruling of 2026-08-11 on #7474, the three genuine legs (no object-level `update` on the master / master row outside the write RLS / no `edit`-level share grant) keep `403 PERMISSION_DENIED` and their exact wording. The three non-verdict legs get envelopes of their own, all drawn from the existing ADR-0112 vocabulary — no new error code: - `controlled_by_parent` with no `master_detail` → 422 INVALID_METADATA - target detail row does not exist → 404 RECORD_NOT_FOUND - detail's master reference is empty → 422 MISSING_REQUIRED_FIELD Each new message opens with a prefix of its own rather than `[Security] Access denied`: that exact prefix is a MATCHER at both transports, so borrowing it would re-flatten the split back to 403 on the wire. The explanation lives in `message` and never in `details`, which is not a carrier the client can rely on (#7450). Throwing directly (instead of routing through a `never`-returning helper) also retires the non-null assertions the metadata-defect branch used to need — `deny()` returning `never` only by throwing was the load-bearing half of the same defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sXg2v6khHim6XdmRoAXje
1 parent 211abdb commit 65849e4

4 files changed

Lines changed: 466 additions & 19 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
Split `controlled_by_parent` write refusals by true semantics: three of the six legs stop answering `403 PERMISSION_DENIED`
6+
7+
A by-id write to a `controlled_by_parent` detail is refused for six distinct reasons, and all six used to answer with one envelope and one sentence — `403 PERMISSION_DENIED: … requires edit access to its master record`. Only three of them are authorization verdicts. The other three said something untrue and prescribed a remedy that could not work: "ask whoever owns the parent record" cannot fix a null master reference, a deleted row, or an object that declares `controlled_by_parent` with no `master_detail` relation to derive access from.
8+
9+
Unchanged — the three genuine verdicts keep `403 PERMISSION_DENIED` and their exact wording:
10+
11+
- the caller holds no object-level `update` on the master
12+
- the master row lies outside the caller's write RLS
13+
- the master carries no `edit`-level share grant
14+
15+
Changed — the three non-verdict conditions now answer for what they are:
16+
17+
| condition | before | after |
18+
|---|---|---|
19+
| `controlled_by_parent` declared with no `master_detail` relation | `403 PERMISSION_DENIED` | `422 INVALID_METADATA` |
20+
| the target detail row does not exist | `403 PERMISSION_DENIED` | `404 RECORD_NOT_FOUND` |
21+
| the detail's master reference is empty | `403 PERMISSION_DENIED` | `422 MISSING_REQUIRED_FIELD` |
22+
23+
Each carries a message written for the app author, naming the object, the operation and the remedy. The metadata-defect case is the one that matters most: it is a precisely detectable authoring defect that was disguised as routine RBAC noise, so nobody ever investigated it — and a false 403 steers debugging, human or agent, toward permission changes when the truth is broken metadata.
24+
25+
The 404 does not widen what a caller can learn. The detail row is probed under a system context, so a row hidden from the caller by row-level security is still found and falls through to the authorization legs; object-level CRUD and the row-level write pre-image check both run before this gate. Absence there is real absence.
26+
27+
All codes come from the existing ADR-0112 vocabulary — no new error code is introduced.

packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts

Lines changed: 251 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ const CONTACT_SCHEMA = {
6565
},
6666
};
6767

68+
/**
69+
* [#7474] The same detail, AUTHORED WRONG: `controlled_by_parent` with nothing
70+
* to derive access from — no master_detail field, and no required lookup for
71+
* `resolveCbpRelation` to fall back to. The gate's metadata-defect leg is the
72+
* only thing between this object and a write that answers a false 403.
73+
*/
74+
const CONTACT_SCHEMA_NO_MASTER_DETAIL = {
75+
name: 'crm_contact',
76+
sharingModel: 'controlled_by_parent',
77+
fields: {
78+
id: { name: 'id', type: 'text' },
79+
name: { name: 'name', type: 'text' },
80+
// A lookup, but OPTIONAL — the third fallback `resolveCbpRelation` tries
81+
// requires `required: true`, so this object resolves to no relation at all.
82+
account: { name: 'account', type: 'lookup', reference: 'crm_account' },
83+
},
84+
};
85+
6886
const SHARE_SCHEMA = {
6987
name: 'sys_record_share',
7088
isSystem: true,
@@ -106,10 +124,10 @@ type Row = Record<string, unknown>;
106124
* security plugin itself uses, so a filter this suite asserts on is a filter
107125
* that was really applied rather than one merely inspected.
108126
*/
109-
function makeStore(rows: Record<string, Row[]>) {
127+
function makeStore(rows: Record<string, Row[]>, brokenDetail = false) {
110128
const schemas: Record<string, unknown> = {
111129
crm_account: ACCOUNT_SCHEMA,
112-
crm_contact: CONTACT_SCHEMA,
130+
crm_contact: brokenDetail ? CONTACT_SCHEMA_NO_MASTER_DETAIL : CONTACT_SCHEMA,
113131
sys_record_share: SHARE_SCHEMA,
114132
};
115133
return {
@@ -160,11 +178,24 @@ interface BootOptions {
160178
shareLevel?: 'read' | 'edit' | null;
161179
/** `'none'` boots a deployment WITHOUT plugin-sharing; `'throws'` a broken one. */
162180
sharing?: 'real' | 'none' | 'throws';
181+
/**
182+
* [#7474] `'no-master-detail'` swaps the detail's schema for the AUTHORING
183+
* DEFECT the metadata-defect leg exists to report: `controlled_by_parent`
184+
* declared on an object with no relation to derive access from.
185+
*/
186+
detail?: 'valid' | 'no-master-detail';
187+
/** [#7474] Replace the detail rows — e.g. one whose master FK is null. */
188+
contacts?: Row[];
189+
/** [#7474] Replace the caller's permission set (the master-CRUD / master-RLS legs). */
190+
sets?: PermissionSet[];
163191
}
164192

165193
async function boot(options: BootOptions = {}) {
166194
const shareLevel = options.shareLevel === undefined ? 'edit' : options.shareLevel;
167-
const store = makeStore(fixtureRows(shareLevel));
195+
const fixture = fixtureRows(shareLevel);
196+
if (options.contacts) fixture.crm_contact = options.contacts;
197+
const store = makeStore(fixture, options.detail === 'no-master-detail');
198+
const sets = options.sets ?? [REP_SET];
168199

169200
let middleware: any;
170201
const ql = {
@@ -179,7 +210,7 @@ async function boot(options: BootOptions = {}) {
179210
const services: Record<string, unknown> = {
180211
manifest: { register: vi.fn() },
181212
objectql: ql,
182-
metadata: { get: async (n: string) => store.getSchema(n), list: async () => [REP_SET] },
213+
metadata: { get: async (n: string) => store.getSchema(n), list: async () => sets },
183214
};
184215
if ((options.sharing ?? 'real') === 'real') {
185216
// The REAL sharing service over the same store — the point of the fix is
@@ -206,7 +237,7 @@ async function boot(options: BootOptions = {}) {
206237
},
207238
};
208239
const plugin = new SecurityPlugin({
209-
defaultPermissionSets: [REP_SET],
240+
defaultPermissionSets: sets,
210241
fallbackPermissionSet: 'crm_rep',
211242
});
212243
await plugin.init(ctx);
@@ -266,6 +297,18 @@ async function boot(options: BootOptions = {}) {
266297
return out;
267298
};
268299

300+
/** [#7474] The INSERT face — the one leg that reads the master FK off the body. */
301+
const insertContact = async (data: Row): Promise<void> => {
302+
const opCtx: any = {
303+
object: 'crm_contact',
304+
operation: 'insert',
305+
data,
306+
options: {},
307+
context: repContext(),
308+
};
309+
await middleware(opCtx, async () => {});
310+
};
311+
269312
return {
270313
store,
271314
ctx,
@@ -274,6 +317,7 @@ async function boot(options: BootOptions = {}) {
274317
visibleContacts,
275318
analyticsVisibleContacts,
276319
updateContact,
320+
insertContact,
277321
writableContacts,
278322
};
279323
}
@@ -467,3 +511,205 @@ describe('[#5815] getReadFilter enforces the same read scope as the engine middl
467511
expect(await h.analyticsVisibleContacts(delegated)).toEqual([]);
468512
});
469513
});
514+
515+
// ---------------------------------------------------------------------------
516+
517+
/**
518+
* [#7474] SIX conditions refuse a write in `assertControlledByParentWrite`, and
519+
* until the maintainer ruling of 2026-08-11 they answered with ONE sentence and
520+
* ONE code: `403 PERMISSION_DENIED — requires edit access to its master record`.
521+
*
522+
* Three of them are genuine authorization verdicts and keep exactly that. Three
523+
* are not verdicts at all — a broken `master_detail` declaration, a row that
524+
* does not exist, a null master FK — and the shared sentence was a false
525+
* statement with a false remedy: "ask whoever owns the parent record" cannot
526+
* fix any of them. Worse for the app author, the metadata defect is a precisely
527+
* detectable AUTHORING error that was wearing the costume of routine RBAC
528+
* noise, which is the class of thing nobody ever investigates.
529+
*
530+
* ## Why these cases assert `code` and `status`, never just a throw
531+
*
532+
* The defect is the ENVELOPE, not the refusal: every one of these six threw
533+
* before this change too. A `rejects.toThrow()` — or a message-only assertion —
534+
* carries one bit where the defect has two, so it stays green on precisely the
535+
* behaviour the issue reported. The minimum here is therefore the ADR-0112 pair
536+
* (`code` + `status`), plus the message where the WORDING is the contract: the
537+
* three authorization legs must keep their sentence verbatim, because that
538+
* sentence is what a user reads and what consumers already pin.
539+
*
540+
* The pinned pairs are the split itself:
541+
*
542+
* | leg | status | code |
543+
* |----------------------------------------|--------|-------------------------|
544+
* | no object-level `update` on the master | 403 | PERMISSION_DENIED |
545+
* | master row outside the write RLS | 403 | PERMISSION_DENIED |
546+
* | no `edit`-level share grant | 403 | PERMISSION_DENIED |
547+
* | controlled_by_parent, no master_detail | 422 | INVALID_METADATA |
548+
* | target record not found | 404 | RECORD_NOT_FOUND |
549+
* | detail has no master reference | 422 | MISSING_REQUIRED_FIELD |
550+
*/
551+
describe('[#7474] the six refusal legs answer with six envelopes, not one', () => {
552+
/** The thrown error itself — `rejects.toThrow` cannot see `code` / `status`. */
553+
const refusalOf = async (run: Promise<unknown>): Promise<any> => {
554+
try {
555+
await run;
556+
} catch (e) {
557+
return e;
558+
}
559+
throw new Error('expected the write to be refused, but it resolved');
560+
};
561+
562+
/** REP_SET with the master's object-level `update` withheld. */
563+
const NO_MASTER_EDIT: PermissionSet = {
564+
name: 'crm_rep',
565+
label: 'CRM Rep',
566+
objects: {
567+
crm_account: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: true },
568+
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
569+
},
570+
} as unknown as PermissionSet;
571+
572+
/** REP_SET plus a write RLS on the MASTER that matches no row in the fixture. */
573+
const MASTER_WRITE_RLS: PermissionSet = {
574+
name: 'crm_rep',
575+
label: 'CRM Rep',
576+
objects: {
577+
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
578+
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
579+
},
580+
rowLevelSecurity: [
581+
{ object: 'crm_account', operation: 'update', using: "name = 'No Such Corp'" },
582+
],
583+
} as unknown as PermissionSet;
584+
585+
// ── the three GENUINE authorization verdicts — unchanged, verbatim ────────
586+
587+
it('403 PERMISSION_DENIED: the caller holds no object-level update on the master', async () => {
588+
const h = await boot({ shareLevel: 'edit', sets: [NO_MASTER_EDIT] });
589+
const err = await refusalOf(h.updateContact('ct_own'));
590+
expect(err.code).toBe('PERMISSION_DENIED');
591+
expect(err.statusCode).toBe(403);
592+
// The sentence is contract on this leg — a user reads it, and consumers
593+
// pin it. It must survive the split byte-for-byte.
594+
expect(err.message).toContain("requires edit access to its master record");
595+
expect(err.message).toContain("no edit permission on master 'crm_account'");
596+
});
597+
598+
it('403 PERMISSION_DENIED: the master row is outside the caller\'s write RLS', async () => {
599+
const h = await boot({ shareLevel: 'edit', sets: [MASTER_WRITE_RLS] });
600+
const err = await refusalOf(h.updateContact('ct_own'));
601+
expect(err.code).toBe('PERMISSION_DENIED');
602+
expect(err.statusCode).toBe(403);
603+
expect(err.message).toContain('requires edit access to its master record');
604+
expect(err.message).toContain('row-level security');
605+
});
606+
607+
it('403 PERMISSION_DENIED: the master carries no edit-level share grant', async () => {
608+
const h = await boot({ shareLevel: 'read' });
609+
const err = await refusalOf(h.updateContact('ct_us'));
610+
expect(err.code).toBe('PERMISSION_DENIED');
611+
expect(err.statusCode).toBe(403);
612+
expect(err.message).toContain('requires edit access to its master record');
613+
expect(err.message).toContain('record sharing');
614+
});
615+
616+
// ── the three NON-VERDICT legs — split by true semantics ──────────────────
617+
618+
it('422 INVALID_METADATA: controlled_by_parent declared with no master_detail relation', async () => {
619+
const h = await boot({ shareLevel: 'edit', detail: 'no-master-detail' });
620+
const err = await refusalOf(h.updateContact('ct_own'));
621+
expect(err.code).toBe('INVALID_METADATA');
622+
expect(err.status).toBe(422);
623+
expect(err.statusCode).toBe(422);
624+
// The remedy has to be IN the message: `details` is not a carrier the
625+
// client can rely on (#7450), so the sentence is the whole fix-it.
626+
expect(err.message).toContain('no master_detail relation');
627+
expect(err.message).toContain('Declare a required master_detail field');
628+
// …and it must NOT wear the 403's costume: that prefix is a MATCHER at both
629+
// transports, so borrowing it would re-flatten this to PERMISSION_DENIED.
630+
expect(err.message).not.toContain('[Security] Access denied');
631+
expect(err.message).not.toContain('requires edit access to its master record');
632+
});
633+
634+
it('404 RECORD_NOT_FOUND: the by-id write targets a detail row that does not exist', async () => {
635+
const h = await boot({ shareLevel: 'edit' });
636+
const err = await refusalOf(h.updateContact('ct_deleted_concurrently'));
637+
expect(err.code).toBe('RECORD_NOT_FOUND');
638+
expect(err.status).toBe(404);
639+
expect(err.statusCode).toBe(404);
640+
expect(err.message).toContain('ct_deleted_concurrently');
641+
expect(err.message).not.toContain('requires edit access to its master record');
642+
});
643+
644+
it('404 is real absence, not an RLS-hidden row: the probe reads under a SYSTEM context', async () => {
645+
// The one thing a 404 must never become is an oracle. `ct_eu` exists but
646+
// its master is unreachable to the caller — the row is read as system, so
647+
// it is FOUND here and falls through to the authorization leg. Both halves
648+
// asserted together: this is what makes the 404 above mean "absent".
649+
const h = await boot({ shareLevel: 'edit' });
650+
const hidden = await refusalOf(h.updateContact('ct_eu'));
651+
expect(hidden.code).toBe('PERMISSION_DENIED');
652+
expect(hidden.statusCode).toBe(403);
653+
expect(hidden.message).toContain('requires edit access to its master record');
654+
});
655+
656+
it('422 MISSING_REQUIRED_FIELD: the stored detail row has no master reference', async () => {
657+
const h = await boot({
658+
shareLevel: 'edit',
659+
contacts: [{ id: 'ct_orphan', name: 'Orphan contact', account: null }],
660+
});
661+
const err = await refusalOf(h.updateContact('ct_orphan'));
662+
expect(err.code).toBe('MISSING_REQUIRED_FIELD');
663+
expect(err.status).toBe(422);
664+
expect(err.statusCode).toBe(422);
665+
// The stored-row wording names the row, because the caller cannot fix this
666+
// one by sending a different payload.
667+
expect(err.message).toContain("record 'ct_orphan' has no value in 'account'");
668+
expect(err.message).not.toContain('requires edit access to its master record');
669+
});
670+
671+
it('422 MISSING_REQUIRED_FIELD: an insert that omits the master reference', async () => {
672+
const h = await boot({ shareLevel: 'edit' });
673+
const err = await refusalOf(h.insertContact({ name: 'New contact' }));
674+
expect(err.code).toBe('MISSING_REQUIRED_FIELD');
675+
expect(err.status).toBe(422);
676+
// Same condition, same code, and the wording says which shape it is: the
677+
// REQUEST omitted the FK, so the remedy is to send it.
678+
expect(err.message).toContain("did not supply 'account'");
679+
expect(err.message).not.toContain('requires edit access to its master record');
680+
});
681+
682+
// ── the split itself ─────────────────────────────────────────────────────
683+
684+
it('the non-verdict legs are DISTINGUISHABLE from the verdicts and from each other', async () => {
685+
const authorization = await refusalOf(
686+
(await boot({ shareLevel: 'read' })).updateContact('ct_us'),
687+
);
688+
const metadataDefect = await refusalOf(
689+
(await boot({ shareLevel: 'edit', detail: 'no-master-detail' })).updateContact('ct_own'),
690+
);
691+
const missingRow = await refusalOf(
692+
(await boot({ shareLevel: 'edit' })).updateContact('ct_gone'),
693+
);
694+
const nullMaster = await refusalOf(
695+
(await boot({
696+
shareLevel: 'edit',
697+
contacts: [{ id: 'ct_orphan', name: 'Orphan contact', account: null }],
698+
})).updateContact('ct_orphan'),
699+
);
700+
701+
// Four conditions, four envelopes. Before the split these were one:
702+
// `403 PERMISSION_DENIED` with a single sentence, which is exactly what an
703+
// assertion set that only checked "it threw" could not see.
704+
const envelope = (e: any) => `${e.status ?? e.statusCode}/${e.code}`;
705+
expect([authorization, metadataDefect, missingRow, nullMaster].map(envelope)).toEqual([
706+
'403/PERMISSION_DENIED',
707+
'422/INVALID_METADATA',
708+
'404/RECORD_NOT_FOUND',
709+
'422/MISSING_REQUIRED_FIELD',
710+
]);
711+
// The two 422s share a status and must still be told apart by `code` —
712+
// which is the field ADR-0112 makes the branch point.
713+
expect(metadataDefect.code).not.toBe(nullMaster.code);
714+
});
715+
});

0 commit comments

Comments
 (0)