Skip to content

Commit 21888ab

Browse files
os-helpclaude
andauthored
fix(plugin-security): propagate engine faults from readRowById instead of flattening to null (#7697)
`readRowById` answered `null` for three different facts — the row does not exist, the engine threw, and no engine is wired — and every gate that probes with it read all three as "no such row". Its own contract note claimed a `null` "always DENIES downstream"; that was true of one caller and false of the rest, in two opposite directions: - `assertControlledByParentWrite` reported a store outage as 404 RECORD_NOT_FOUND — terminal to an SDK, at the moment the truthful answer was a transient fault to back off on (the leg #7474 made explicit); - the two admin-door provenance gates (ADR-0086 two-doors, ADR-0066 asset ownership) read `null` as "not package/platform-managed" and let the write THROUGH — fail-OPEN for the duration of the fault, on both the by-id and the bulk-filter branch; - the owner-anchor echo caught the throw and answered 403 "changing record ownership": fail-closed, but accusing the caller of something they did not do, on an envelope a client will not retry. Per the maintainer ruling of 2026-08-11 on #7505 the posture is fail-closed and an outage is never a missing record. An engine fault now propagates out of the probe and out of the gate: the write is refused before `next()`, and the error is re-thrown exactly as the engine threw it rather than re-badged, so objectql's DatasourceUnavailableError keeps ERR_DATASOURCE_UNAVAILABLE and reaches the wire as 503. Wrapping it in a security code would relabel a dependency outage as an authorization event and register a second spelling of an existing ADR-0112 ledger entry under a package that does not own it. `null` from the probe now means one thing: the row is genuinely absent. Deliberately unchanged: the master-visibility probe in the same gate still treats a throw as "not visible" and answers 403. The two probes ask different questions — "does this row exist", which an outage leaves unanswered and which must not be answered "no", versus "is this master visible to you under your own write policy", whose fail-closed default genuinely is "not visible". The issue and the ruling both name that probe as the house posture to match. Steady-state behaviour is unchanged at every call site; only the fault path moved. Both directions are pinned per caller, and reverse-verified: reverting the four behaviour changes turns exactly the eight new fault-path cases red and leaves all 36 steady-state cases green. Fixes #7505 Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 79c3145 commit 21888ab

4 files changed

Lines changed: 710 additions & 31 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): propagate engine faults from permission pre-image probes instead of reading them as absent rows (#7505)
6+
7+
`SecurityPlugin`'s shared by-id probe, `readRowById`, answered `null` for three
8+
different facts — the row does not exist, the engine threw (driver down, table
9+
missing, timeout), and no engine is wired — and every gate that probes with it
10+
read all three as "no such row". Its own contract note claimed a `null` "always
11+
DENIES downstream". That was true of one caller and false of the rest, in two
12+
opposite directions:
13+
14+
- **`assertControlledByParentWrite`** reported a store outage as **`404
15+
RECORD_NOT_FOUND`**. After #7474 split that leg out, the answer was precisely
16+
wrong in a way an SDK acts on: 404 is terminal, so a client drops the record
17+
id and stops retrying at exactly the moment the truthful answer was "come back
18+
in a minute".
19+
- **The two admin-door provenance gates** (`sys_permission_set`'s ADR-0086
20+
two-doors gate and `sys_position` / `sys_capability`'s ADR-0066
21+
asset-ownership gate) read `null` as "this row is not package/platform-managed"
22+
and let the write **through**. For the duration of a store fault, both
23+
boundaries silently stood down — fail-**open**.
24+
- **The owner-anchor echo** caught the throw and answered `403 changing record
25+
ownership`: fail-closed, but with a sentence accusing the caller of an
26+
ownership grab they never attempted, on an envelope a client will not retry.
27+
28+
Per the maintainer ruling of 2026-08-11 the posture is **fail-closed**, and an
29+
outage is never reported as a missing record. An engine fault now propagates out
30+
of the probe and out of the gate, so the write is refused (nothing reaches the
31+
driver) and the caller is told what actually happened. The error is re-thrown as
32+
the engine threw it rather than re-badged: objectql's `DatasourceUnavailableError`
33+
keeps its `ERR_DATASOURCE_UNAVAILABLE` code and reaches the wire as **503**,
34+
which is the answer a client can back off on. Wrapping it in a security code
35+
would have relabelled a dependency outage as an authorization event.
36+
37+
`null` from the probe now means one thing: the row is genuinely absent.
38+
39+
**Steady-state behaviour is unchanged at every call site** — an absent detail
40+
row still answers `404 RECORD_NOT_FOUND`, a package-managed row is still refused
41+
403, an unchanged-owner form echo is still tolerated, and a pre-image the caller
42+
cannot read still denies exactly like one that is not there (the
43+
owner-enumeration oracle is untouched). Only the fault path moved.
44+
45+
Deliberately unchanged: the master-visibility probe inside the same
46+
controlled-by-parent gate still treats a throw as "not visible" and answers 403.
47+
The two probes ask different questions — "does this row exist", which an outage
48+
leaves unanswered and which must not be answered "no", versus "is this master
49+
visible to you under your own write policy", whose fail-closed default genuinely
50+
is "not visible".
51+
52+
You may now see `503 ERR_DATASOURCE_UNAVAILABLE` from a write that previously
53+
returned `404`, `403`, or — at the two provenance gates — succeeded, but only
54+
while the datasource behind the probed object is unavailable.

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

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ type Row = Record<string, unknown>;
124124
* security plugin itself uses, so a filter this suite asserts on is a filter
125125
* that was really applied rather than one merely inspected.
126126
*/
127-
function makeStore(rows: Record<string, Row[]>, brokenDetail = false) {
127+
function makeStore(rows: Record<string, Row[]>, brokenDetail = false, faultOn?: string) {
128128
const schemas: Record<string, unknown> = {
129129
crm_account: ACCOUNT_SCHEMA,
130130
crm_contact: brokenDetail ? CONTACT_SCHEMA_NO_MASTER_DETAIL : CONTACT_SCHEMA,
@@ -139,12 +139,40 @@ function makeStore(rows: Record<string, Row[]>, brokenDetail = false) {
139139
return typeof options?.limit === 'number' ? hits.slice(0, options.limit) : hits;
140140
}),
141141
findOne: vi.fn(async (object: string, options: any = {}) => {
142+
// [#7505] The one thing this double gains: a store that is DOWN for one
143+
// object, so "the row is not there" and "I could not look" stop being the
144+
// same observation.
145+
if (faultOn && object === faultOn) throw datasourceOutage(object);
142146
const all = rows[object] ?? [];
143147
return all.find((r) => matchesFilterCondition(r, options?.where ?? null)) ?? null;
144148
}),
145149
};
146150
}
147151

152+
/**
153+
* [#7505] A driver outage shaped like the real one. Modelled field-for-field on
154+
* `@objectstack/objectql`'s `DatasourceUnavailableError` — `code`, `name`,
155+
* `datasource`, and NO `status`, because that class declares none: `rest`'s
156+
* `mapDataError` routes it to 503 off the CODE (`rest-server.ts`, pinned by
157+
* `rest.test.ts` "maps ERR_DATASOURCE_UNAVAILABLE → 503"). Giving the double a
158+
* `status` the producer does not set would let these cases pass against an
159+
* error no engine can throw.
160+
*
161+
* `plugin-security` does not depend on `objectql` (it is not even a
162+
* devDependency — the plugin talks to the engine through the injected service),
163+
* so the shape is restated here rather than imported.
164+
*/
165+
function datasourceOutage(object: string): Error {
166+
const e = new Error(
167+
`[ObjectQL] Datasource 'primary' configured for object '${object}' is declared but not connected: ` +
168+
`it failed to connect at startup and the server was started with OS_ALLOW_DRIVER_CONNECT_FAILURE.`,
169+
) as Error & { code: string; datasource: string };
170+
e.name = 'DatasourceUnavailableError';
171+
e.code = 'ERR_DATASOURCE_UNAVAILABLE';
172+
e.datasource = 'primary';
173+
return e;
174+
}
175+
148176
/** The fixture rows — identical for every case; only the grant level varies. */
149177
function fixtureRows(shareLevel: 'read' | 'edit' | null): Record<string, Row[]> {
150178
return {
@@ -188,13 +216,18 @@ interface BootOptions {
188216
contacts?: Row[];
189217
/** [#7474] Replace the caller's permission set (the master-CRUD / master-RLS legs). */
190218
sets?: PermissionSet[];
219+
/**
220+
* [#7505] Make `findOne` on this object throw a datasource outage, so a gate
221+
* that probes it gets "could not read" rather than "not there".
222+
*/
223+
faultOn?: string;
191224
}
192225

193226
async function boot(options: BootOptions = {}) {
194227
const shareLevel = options.shareLevel === undefined ? 'edit' : options.shareLevel;
195228
const fixture = fixtureRows(shareLevel);
196229
if (options.contacts) fixture.crm_contact = options.contacts;
197-
const store = makeStore(fixture, options.detail === 'no-master-detail');
230+
const store = makeStore(fixture, options.detail === 'no-master-detail', options.faultOn);
198231
const sets = options.sets ?? [REP_SET];
199232

200233
let middleware: any;
@@ -713,3 +746,131 @@ describe('[#7474] the six refusal legs answer with six envelopes, not one', () =
713746
expect(metadataDefect.code).not.toBe(nullMaster.code);
714747
});
715748
});
749+
750+
// ---------------------------------------------------------------------------
751+
752+
/**
753+
* [#7505] The FIFTH condition the by-id gate can meet, and the one it used to
754+
* answer with somebody else's envelope: the store could not be read at all.
755+
*
756+
* `readRowById` flattened a thrown read into `null`, and `null` on this path
757+
* means "no such row" — so a driver outage arrived at the client as `404
758+
* RECORD_NOT_FOUND`. #7474 made that leg explicit and thereby made the lie
759+
* specific: an SDK treats 404 as TERMINAL (drop the id, stop retrying) exactly
760+
* when the truthful answer was "come back in a minute".
761+
*
762+
* Maintainer ruling of 2026-08-11: fail-closed, and never 404 for an outage.
763+
* The write is still refused — the gate throws before `next()`, so nothing
764+
* reaches the driver — but it is refused with the ENGINE's error, not with a
765+
* verdict this gate invented.
766+
*
767+
* Both directions are pinned per case: a genuinely absent row keeps its 404
768+
* (the steady state #7474 shipped), and only the fault path moved.
769+
*/
770+
describe('[#7505] a store fault is not an absent row', () => {
771+
/** REP_SET plus a write RLS on the MASTER — the leg that runs the master probe. */
772+
const MASTER_WRITE_RLS_SET: PermissionSet = {
773+
name: 'crm_rep',
774+
label: 'CRM Rep',
775+
objects: {
776+
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
777+
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
778+
},
779+
rowLevelSecurity: [
780+
{ object: 'crm_account', operation: 'update', using: "name = 'No Such Corp'" },
781+
],
782+
} as unknown as PermissionSet;
783+
784+
const refusalOf = async (run: Promise<unknown>): Promise<any> => {
785+
try {
786+
await run;
787+
} catch (e) {
788+
return e;
789+
}
790+
throw new Error('expected the write to be refused, but it resolved');
791+
};
792+
793+
it('the detail-row probe faulting propagates ERR_DATASOURCE_UNAVAILABLE, not 404', async () => {
794+
const h = await boot({ shareLevel: 'edit', faultOn: 'crm_contact' });
795+
const err = await refusalOf(h.updateContact('ct_own'));
796+
797+
// The engine's own error, unchanged: this gate is not the producer of a
798+
// datasource outage and re-badging one under a security code would relabel
799+
// a dependency failure as an authorization event. `rest`'s `mapDataError`
800+
// turns this code into 503 (pinned in `rest.test.ts`), which is the answer
801+
// an SDK can actually act on.
802+
expect(err.code).toBe('ERR_DATASOURCE_UNAVAILABLE');
803+
expect(err.name).toBe('DatasourceUnavailableError');
804+
805+
// …and the negative half, which is the whole ruling: NOT the absent-row
806+
// envelope, and not a borrowed 403 either.
807+
expect(err.code).not.toBe('RECORD_NOT_FOUND');
808+
expect(err.status ?? err.statusCode).not.toBe(404);
809+
expect(err.code).not.toBe('PERMISSION_DENIED');
810+
expect(err.message).not.toContain('does not exist');
811+
expect(err.message).not.toContain('requires edit access to its master record');
812+
});
813+
814+
it('an engine error with NO code still never becomes a 404', async () => {
815+
// Not every read failure is a declared-datasource outage — a timeout or a
816+
// dropped socket arrives as a bare `Error`. It has no code for a transport
817+
// to map, so it lands in the 5xx catch-all: still fail-closed, still
818+
// truthful about being OUR problem, and still not "that record is gone".
819+
const h = await boot({ shareLevel: 'edit' });
820+
h.store.findOne.mockImplementation(async (object: string) => {
821+
if (object === 'crm_contact') throw new Error('read ECONNRESET');
822+
return null;
823+
});
824+
const err = await refusalOf(h.updateContact('ct_own'));
825+
expect(err.message).toContain('ECONNRESET');
826+
expect(err.code).toBeUndefined();
827+
expect(err.code).not.toBe('RECORD_NOT_FOUND');
828+
expect(err.name).not.toBe('DetailRecordNotFoundError');
829+
});
830+
831+
it('STEADY STATE: a genuinely absent row still answers 404 RECORD_NOT_FOUND', async () => {
832+
// The other direction, on the same fixture and in the same describe, so
833+
// "fault propagates" can never be satisfied by a probe that simply throws
834+
// on everything. This is #7474's leg, unchanged.
835+
const h = await boot({ shareLevel: 'edit' });
836+
const err = await refusalOf(h.updateContact('ct_deleted_concurrently'));
837+
expect(err.code).toBe('RECORD_NOT_FOUND');
838+
expect(err.status).toBe(404);
839+
});
840+
841+
it('STEADY STATE: the three authorization verdicts are untouched by the change', async () => {
842+
// A fault-path change that quietly moved a real 403 would be a regression
843+
// the case above cannot see, because it never reaches the master legs.
844+
const envelope = (e: any) => `${e.status ?? e.statusCode}/${e.code}`;
845+
const noShare = await refusalOf((await boot({ shareLevel: 'read' })).updateContact('ct_us'));
846+
const hidden = await refusalOf((await boot({ shareLevel: 'edit' })).updateContact('ct_eu'));
847+
expect([envelope(noShare), envelope(hidden)]).toEqual([
848+
'403/PERMISSION_DENIED',
849+
'403/PERMISSION_DENIED',
850+
]);
851+
// And a write that should SUCCEED still does — the fail-closed direction
852+
// must not have swallowed the happy path.
853+
await expect((await boot({ shareLevel: 'edit' })).updateContact('ct_own')).resolves.toBeUndefined();
854+
});
855+
856+
it('the MASTER-visibility probe deliberately still fails closed to 403, not 503', async () => {
857+
// The per-caller half of the ruling, pinned so the asymmetry reads as a
858+
// decision instead of an oversight. Two probes, two questions:
859+
//
860+
// • "does this detail row exist?" — an outage leaves it UNANSWERED, and
861+
// answering "no" is the terminal lie the case above removes;
862+
// • "is the master visible to you under your own write policy?" — an
863+
// outage leaves it unanswered too, but the fail-closed default for a
864+
// visibility question is "not visible", which is precisely what the
865+
// 403 says. The issue and the ruling both name this probe as the house
866+
// posture to MATCH, not a site to change.
867+
//
868+
// Faulting `crm_account` reaches it: the detail read succeeds, so the gate
869+
// gets as far as resolving the master.
870+
const h = await boot({ shareLevel: 'edit', sets: [MASTER_WRITE_RLS_SET], faultOn: 'crm_account' });
871+
const err = await refusalOf(h.updateContact('ct_own'));
872+
expect(err.code).toBe('PERMISSION_DENIED');
873+
expect(err.statusCode).toBe(403);
874+
expect(err.message).toContain('row-level security');
875+
});
876+
});

0 commit comments

Comments
 (0)