Skip to content

Commit e6f4cad

Browse files
committed
harden owner_id guard + bulk-write seam from adversarial review (#3004, #2982)
Adversarial code review of the fix surfaced several edge holes; close them: engine.ts (#2982 seam): - delete() gains update()'s scalar-id guard, so an id-list bulk delete ({ id: { $in: [...] } }, multi) routes to the scoped deleteMany instead of a garbage single-id driver.delete — closing the same #2982 hole on delete. - both multi branches now consume the seeded opCtx.ast and fail CLOSED (throw) if it is absent, instead of rebuilding an unscoped { object, where } fallback that would silently drop every composed row filter (AGENTS.md PD #12). plugin-security step 3.5 (owner_id guard): - own-property (hasOwnProperty) membership test — a polluted prototype can no longer spoof an ownership write. - owner_id must be a non-empty SCALAR; a non-scalar (array/object) is denied, not String()-coerced (which let owner_id:[self] pass and corrupt the anchor). - owner_id:undefined on update is now denied (mongo $set would persist null = an ungated disown). - array-shaped update change-sets fail CLOSED instead of silently bypassing. - the no-op-echo pre-image is read under the CALLER's context, not isSystem: threads the open transaction (no spurious in-tx denial) and closes the owner-enumeration oracle a system read opened. - update short-circuits the field-set lookup when the change-set omits owner_id. plugin-sharing composeAnd: preserve sibling top-level keys of a { $and:[...], k:v } filter (was dropping k) — prevents a caller's AND-ed predicate from silently widening a bulk write. spec: allowTransfer describe text corrected — it is enforced NOW via the insert/update owner_id door, not only the pending M2 transfer op. Follow-ups filed for out-of-scope findings: public-form owner_id forge (#3022) and cascade set_null vs the disown guard on sys_user deletion (#3023).
1 parent 7150f1f commit e6f4cad

7 files changed

Lines changed: 255 additions & 72 deletions

File tree

.changeset/owner-id-anchor-and-bulk-write-scoping.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@objectstack/plugin-security': patch
33
'@objectstack/objectql': patch
4+
'@objectstack/plugin-sharing': patch
45
---
56

67
fix(security): guard the `owner_id` ownership anchor and scope bulk writes to owner-visible rows (#3004, #2982)
@@ -20,11 +21,18 @@ row-level scoping keys off to decide who may update/delete a record.
2021
invisible to their creator), and a supplied foreign owner is denied; on update
2122
a supplied `owner_id` is a transfer/disown and is denied — the unchanged no-op
2223
echo of a form save is tolerated via a pre-image compare, and a bulk
23-
change-set carrying `owner_id` fails closed. Both require the transfer grant
24+
change-set carrying `owner_id` fails closed. A non-scalar `owner_id`
25+
(array/object) is rejected outright rather than string-coerced, and the
26+
change-set membership test uses own-property semantics so a polluted
27+
prototype cannot spoof an ownership write. Both require the transfer grant
2428
(`allowTransfer`, or `modifyAllRecords` which implies it) to proceed. System
25-
context (`ctx.isSystem`) stays fully exempt (import / OAuth provisioning / cron
26-
snapshots / seed claims), and under delegation both principals must hold the
27-
grant (ADR-0090 D10 intersection).
29+
context (`ctx.isSystem`) stays fully exempt (OAuth provisioning / cron
30+
snapshots / seed claims / migrations), and under delegation both principals
31+
must hold the grant (ADR-0090 D10 intersection). Note a REST **import** runs
32+
under the importer's own context (not `isSystem`), so a non-privileged user
33+
importing a CSV whose `owner_id` column names other users is correctly denied
34+
unless they hold the transfer grant — administrators (who carry
35+
`modifyAllRecords`) are unaffected.
2836

2937
- **#2982 — bulk writes skipped owner scoping on OWD-`private` objects.** A
3038
`update({ multi: true })` / bulk delete rebuilt the driver AST from
@@ -34,7 +42,17 @@ row-level scoping keys off to decide who may update/delete a record.
3442
peers'. The engine now seeds `opCtx.ast` from the caller's predicate BEFORE the
3543
chain (the same seam reads use) and hands the middleware-composed AST to
3644
`driver.updateMany` / `driver.deleteMany`, so bulk writes are constrained to the
37-
rows the caller may edit — matching single-id write behavior.
45+
rows the caller may edit — matching single-id write behavior. `delete` now
46+
applies the same scalar-`id` guard `update` already had, so an id-list bulk
47+
delete (`where: { id: { $in: […] } }, multi: true`) is owner-scoped too, and
48+
both multi branches fail CLOSED (throw) rather than silently rebuilding an
49+
unscoped predicate if the row-scoping AST is ever absent.
50+
51+
Consequences of routing bulk writes through the AST: the anti-oracle
52+
predicate guard now also applies to bulk `update`/`delete` (a bulk write
53+
filtering on an FLS-unreadable field is rejected, as reads already are), and a
54+
principal-less (no-`userId`, non-system) bulk write on an owner-scoped object
55+
now correctly affects zero rows instead of all of them.
3856

3957
Proven end-to-end on the real showcase app
4058
(`packages/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts`) and pinned

packages/objectql/src/engine.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,41 @@ describe('ObjectQL Engine', () => {
661661
expect(mockDriver.update).toHaveBeenCalledTimes(1);
662662
expect((mockDriver as any).updateMany).not.toHaveBeenCalled();
663663
});
664+
665+
it('seeds opCtx.ast for an id-LIST bulk delete ({id:{$in}}) so it routes to a scoped deleteMany (#2982 parity)', async () => {
666+
// Regression: delete() lacked update()'s scalar-id guard, so an
667+
// operator-object where.id was mistaken for a single id — skipping
668+
// the seed and routing to driver.delete with a garbage id, never
669+
// reaching the owner-scoped deleteMany path.
670+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
671+
if (opCtx.operation === 'delete' && opCtx.ast) {
672+
opCtx.ast.where = { $and: [opCtx.ast.where, { owner_id: 'u1' }] };
673+
}
674+
await next();
675+
});
676+
677+
await engine.delete('task', { where: { id: { $in: ['a', 'b'] } }, multi: true } as any);
678+
679+
expect((mockDriver as any).deleteMany).toHaveBeenCalledTimes(1);
680+
expect(mockDriver.delete).not.toHaveBeenCalled();
681+
const [, ast] = (mockDriver as any).deleteMany.mock.calls[0];
682+
expect(ast.where).toEqual({ $and: [{ id: { $in: ['a', 'b'] } }, { owner_id: 'u1' }] });
683+
});
684+
685+
it('fails CLOSED (throws) if a hook clears the target id so the multi branch runs without a seeded ast', async () => {
686+
// The only way to reach the multi branch with no seeded ast is a
687+
// beforeUpdate hook clearing input.id after a truthy-id seed skip.
688+
// The old `?? { object, where }` fallback would have silently
689+
// rebuilt an UNSCOPED predicate; we now refuse it.
690+
engine.registerHook('beforeUpdate', async (ctx: any) => {
691+
ctx.input.id = undefined; // force the multi branch, no seeded ast
692+
});
693+
694+
await expect(
695+
engine.update('task', { id: 't1', status: 'done' }, { multi: true } as any),
696+
).rejects.toThrow(/row-scoping AST was not seeded/);
697+
expect((mockDriver as any).updateMany).not.toHaveBeenCalled();
698+
});
664699
});
665700

666701
describe('Expand Related Records', () => {

packages/objectql/src/engine.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2375,9 +2375,12 @@ export class ObjectQL implements IDataEngine {
23752375
// editable-rows filter) never reached the driver, and a bulk write hit
23762376
// every matching row regardless of ownership. Seed the AST with the
23772377
// caller's predicate BEFORE the chain runs — the same seam the read path
2378-
// uses — and let the multi branch consume the composed result.
2378+
// uses — and let the multi branch consume the composed result. Keyed on
2379+
// the SAME falsy-`id` test the multi branch dispatches on (below), so the
2380+
// seed and the branch never disagree. `where` is included only when
2381+
// supplied, mirroring the read path's AST shape.
23792382
if (!id) {
2380-
opCtx.ast = { object, where: options?.where } as QueryAST;
2383+
opCtx.ast = { object, ...(options?.where !== undefined ? { where: options.where } : {}) } as QueryAST;
23812384
}
23822385

23832386
// [#2948] Snapshot the keys the CALLER supplied, BEFORE any middleware /
@@ -2453,10 +2456,20 @@ export class ObjectQL implements IDataEngine {
24532456
if (!opCtx.context?.isSystem) {
24542457
hookContext.input.data = stripReadonlyFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, suppliedKeys, this.logger) as any;
24552458
}
2456-
// [#2982] Use the middleware-composed AST (seeded above from the
2457-
// caller's `where`), so injected row-scoping actually binds the
2458-
// driver operation instead of being rebuilt-away here.
2459-
const ast: QueryAST = (opCtx.ast as QueryAST) ?? { object, where: options.where };
2459+
// [#2982] Consume the middleware-composed AST seeded above, so
2460+
// the injected row-scoping (RLS write filter, sharing's
2461+
// editable-rows filter) actually binds the driver operation. Fail
2462+
// CLOSED if it is somehow absent — rebuilding `{ object, where }`
2463+
// here would silently drop every composed filter and reopen the
2464+
// unscoped-bulk-write hole this fix closes (AGENTS.md PD #12: no
2465+
// lenient fallback that tolerates the broken invariant).
2466+
const ast = opCtx.ast;
2467+
if (!ast) {
2468+
throw new Error(
2469+
`[Security] Refusing bulk update on '${object}': row-scoping AST was not seeded ` +
2470+
`(a hook cleared the target id after the security filter was composed).`,
2471+
);
2472+
}
24602473
result = await driver.updateMany(object, ast, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
24612474
} else {
24622475
throw new Error('Update requires an ID or options.multi=true');
@@ -2617,10 +2630,19 @@ export class ObjectQL implements IDataEngine {
26172630
this.assertWriteAllowed(object, 'delete');
26182631
const driver = this.getDriver(object);
26192632

2620-
// Extract ID logic similar to update
2633+
// Extract ID logic mirroring update(): only a SCALAR `where.id` means
2634+
// "delete one row by primary key". An operator object ({ $in: [...] }, …)
2635+
// is a multi-row predicate — treating it as an id would bind the object
2636+
// literally (driver.delete(object, {$in:[…]})) and both skip the #2982 AST
2637+
// seeding below AND bypass the by-id RLS pre-image check. Leave `id`
2638+
// undefined so the call routes to deleteMany with the scoped AST.
26212639
let id: any = undefined;
26222640
if (options?.where && typeof options.where === 'object' && 'id' in options.where) {
2623-
id = (options.where as Record<string, unknown>).id;
2641+
const whereId = (options.where as Record<string, unknown>).id;
2642+
const t = typeof whereId;
2643+
if (whereId !== null && (t === 'string' || t === 'number' || t === 'bigint')) {
2644+
id = whereId;
2645+
}
26242646
}
26252647

26262648
const opCtx: OperationContext = {
@@ -2634,9 +2656,10 @@ export class ObjectQL implements IDataEngine {
26342656
// `driver.deleteMany` with an AST that used to be rebuilt from
26352657
// `options.where` after the middleware chain, discarding any row-scoping a
26362658
// middleware composed onto `opCtx.ast`. Seed the caller's predicate before
2637-
// the chain; the multi branch consumes the composed result.
2638-
if (id == null) {
2639-
opCtx.ast = { object, where: options?.where } as QueryAST;
2659+
// the chain, keyed on the SAME falsy-`id` test the multi branch dispatches
2660+
// on; the multi branch consumes the composed result.
2661+
if (!id) {
2662+
opCtx.ast = { object, ...(options?.where !== undefined ? { where: options.where } : {}) } as QueryAST;
26402663
}
26412664

26422665
await this.executeWithMiddleware(opCtx, async () => {
@@ -2669,9 +2692,17 @@ export class ObjectQL implements IDataEngine {
26692692
await this.cascadeDeleteRelations(object, hookContext.input.id as string | number, opCtx.context);
26702693
result = await driver.delete(object, hookContext.input.id as string, hookContext.input.options as any);
26712694
} else if (options?.multi && driver.deleteMany) {
2672-
// [#2982] Use the middleware-composed AST (seeded above) so
2673-
// injected row-scoping binds the bulk delete.
2674-
const ast: QueryAST = (opCtx.ast as QueryAST) ?? { object, where: options.where };
2695+
// [#2982] Consume the middleware-composed AST seeded above so the
2696+
// injected row-scoping binds the bulk delete. Fail CLOSED if it
2697+
// is absent rather than rebuilding an unscoped `{ object, where }`
2698+
// (AGENTS.md PD #12).
2699+
const ast = opCtx.ast;
2700+
if (!ast) {
2701+
throw new Error(
2702+
`[Security] Refusing bulk delete on '${object}': row-scoping AST was not seeded ` +
2703+
`(a hook cleared the target id after the security filter was composed).`,
2704+
);
2705+
}
26752706
result = await driver.deleteMany(object, ast, hookContext.input.options as any);
26762707
} else {
26772708
throw new Error('Delete requires an ID or options.multi=true');

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,53 @@ describe('SecurityPlugin', () => {
322322
};
323323
await harness.run(opCtx); // must not throw, no pre-image read needed
324324
});
325+
326+
it('update disowning via owner_id:undefined is denied (mongo $set null hazard)', async () => {
327+
const harness = await boot([memberSet], () => ({ id: 't1', owner_id: 'u1' }));
328+
const opCtx: any = {
329+
object: 'task', operation: 'update', data: { id: 't1', owner_id: undefined },
330+
context: memberCtx(),
331+
};
332+
await expect(harness.run(opCtx)).rejects.toThrow(/owner_id.*system-managed/);
333+
});
334+
335+
it('insert with a NON-SCALAR owner_id (array) is denied, not coerced to self', async () => {
336+
const harness = await boot([memberSet]);
337+
const opCtx: any = {
338+
object: 'task', operation: 'insert', data: { name: 'A', owner_id: ['u1'] },
339+
context: memberCtx(),
340+
};
341+
// String(['u1']) === 'u1' would have passed as self; scalar-only rejects it.
342+
await expect(harness.run(opCtx)).rejects.toThrow(/owner_id.*system-managed/);
343+
});
344+
345+
it('update echo with a NON-SCALAR owner_id equal-by-coercion is denied', async () => {
346+
const harness = await boot([memberSet], () => ({ id: 't1', owner_id: 'u1' }));
347+
const opCtx: any = {
348+
object: 'task', operation: 'update', data: { id: 't1', owner_id: ['u1'] },
349+
context: memberCtx(),
350+
};
351+
await expect(harness.run(opCtx)).rejects.toThrow(/owner_id.*system-managed/);
352+
});
353+
354+
it('array-shaped update change-set carrying owner_id fails closed (no silent bypass)', async () => {
355+
const harness = await boot([memberSet]);
356+
const opCtx: any = {
357+
object: 'task', operation: 'update', data: [{ id: 't1', owner_id: 'attacker' }],
358+
context: memberCtx(),
359+
};
360+
await expect(harness.run(opCtx)).rejects.toThrow(/owner_id.*system-managed/);
361+
});
362+
363+
it('a prototype-chain owner_id (not own property) does not trip the guard', async () => {
364+
const harness = await boot([memberSet]);
365+
const proto = { owner_id: 'attacker' };
366+
const data: any = Object.create(proto);
367+
data.id = 't1';
368+
data.name = 'renamed'; // only own props; owner_id is inherited
369+
const opCtx: any = { object: 'task', operation: 'update', data, context: memberCtx() };
370+
await harness.run(opCtx); // must not throw — owner_id is not an own property
371+
});
325372
});
326373

327374
it('without org-scoping plugin — strips tenant_isolation RLS so find applies no tenant where', async () => {

0 commit comments

Comments
 (0)