Skip to content

Commit 0b795da

Browse files
os-zhuangzhuangjianguoclaude
authored
fix(approvals): hold the record lock for predicate (multi) updates (#4778) (#4838)
The ADR-0019 record lock only ran for updates carrying an `input.id`, which the engine extracts from a SCALAR `where.id` alone. Any other predicate is a multi-row write that routes to `updateMany` and reached the hook with no id, so `if (!id) return` read "no row was resolved" as "there is nothing to authorize" when the truth was "nothing was ever queried" — the #4757 / #4630 fail-open shape, here reachable with no privilege at all: rewriting the same edit as `multi: true` bypassed the lock without admin, `isSystem`, `lockRecord: false` or a whitelisted field. The hook now resolves the rows a write touches before deciding. By-id writes are unchanged. A predicate write is decided by intersecting the caller's predicate with the object's LOCKED records, so the query is bounded by pending approvals rather than by the update's match set; an unscoped whole-table `multi` update reaches every locked row and is refused while any is held. Past 1 000 locked records, or if the intersection query fails, the write fails closed. Every exemption moves with the guard — `isSystem`, admin, the `approvalStatusField` mirror, `lockRecord: false` and the owning run's `flowRunId` (#3456 / #3712) — each pinned on both predicate shapes, plus a real-engine integration test that reproduces the issue's three lines. Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0657f6b commit 0b795da

4 files changed

Lines changed: 767 additions & 18 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): the record lock now holds for predicate (`multi`) updates (#4778)
6+
7+
The ADR-0019 record lock — "while a record has a pending `sys_approval_request`,
8+
block edits to it" — was enforced only for updates that reach the hook with an
9+
`input.id`. The engine extracts that id from a **scalar** `where.id` alone; an
10+
operator object (`{ $in: [...] }`) or any other predicate is a multi-row write
11+
that routes to `updateMany` and arrives with no id. The hook opened with
12+
`if (!id) return`, so it read *"no row was resolved"* as *"there is nothing to
13+
authorize"* when the truth was *"nothing was ever queried"*.
14+
15+
Rewriting the very same edit as `multi: true` therefore walked straight past the
16+
lock:
17+
18+
```ts
19+
// rec_1 carries a pending approval, lockRecord is not disabled
20+
await ql.update('crm_opportunity', { amount: 999 }, { where: { id: 'rec_1' } }); // RECORD_LOCKED
21+
await ql.update('crm_opportunity', { amount: 999 }, { where: { id: { $in: ['rec_1'] } }, multi: true }); // went through
22+
await ql.update('crm_opportunity', { amount: 999 }, { where: { name: 'x' }, multi: true }); // went through
23+
```
24+
25+
No privilege was needed for that bypass — not an `admin` role, not `isSystem`,
26+
not `lockRecord: false`, not a whitelisted `approvalStatusField`. Every caller
27+
shape that can spell a predicate (SDK, ObjectQL, a flow's `update_record`) could
28+
produce it. It is the same fail-open reasoning fixed for `sys_attachment`
29+
(#4757) and `sys_comment` (#4630), in the one place where it needed no
30+
privilege at all.
31+
32+
**The hook now resolves the rows a write touches before deciding.** By-id writes
33+
are unchanged (the driver writes by primary key, so the rest of `where` must not
34+
narrow the verdict). A predicate write is decided by intersecting the caller's
35+
predicate with the records that are actually locked — which is also what keeps
36+
it cheap: the query is bounded by the object's **pending approvals**, never by
37+
the update's match set, so a mass update of 50 000 unlocked rows costs one
38+
bookkeeping probe and is allowed. An unscoped `multi` update over the whole
39+
table reaches every locked row of the object and is refused while any is held.
40+
41+
**Fail-closed, both ways.** Past 1 000 locked records — the bound the attachment
42+
and comment guards use — or if the intersection query fails, the write is
43+
refused rather than allowed: the lock could not prove the write misses a locked
44+
row. The approvals bookkeeping being unreadable at all stays the one fail-open,
45+
as before: this hook is global over every object, so a kernel without
46+
`sys_approval_request` would otherwise refuse every update in the deployment.
47+
Both the bookkeeping and the match-set resolution are read under a **system**
48+
context — a guard's own input must never be narrowed by the caller's
49+
visibility, since a locked row you cannot read is still a row you may not write.
50+
51+
**Every exemption moved with the guard**, which is the other way this class of
52+
fix goes wrong — a guard extended to more rows that carries only its deny rules
53+
turns a fail-open into a false-positive. `isSystem`, the `admin` override, the
54+
`approvalStatusField` status mirror, `lockRecord: false` and the owning run's
55+
`flowRunId` (#3456 / #3712) all decide a predicate write exactly as they decide
56+
a by-id write, each pinned by tests on both predicate shapes. Refusals now name
57+
the record and object that are locked.

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ function makeFakeEngine() {
2727
if (!(v as any[]).some(sub => matches(row, sub))) return false;
2828
continue;
2929
}
30+
// The record lock intersects the caller's predicate with the locked ids
31+
// (#4778), so the fake has to compose branches the way a driver does.
32+
if (k === '$and') {
33+
if (!(v as any[]).every(sub => matches(row, sub))) return false;
34+
continue;
35+
}
3036
const rv = row[k];
3137
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
3238
if (!(v as any).$in.includes(rv)) return false;
@@ -47,12 +53,16 @@ function makeFakeEngine() {
4753

4854
/** Every `update` the service made, with the context it presented (#3783). */
4955
const writes: Array<{ object: string; data: any; context: any }> = [];
56+
/** Every `find` anyone made — pins what a guard does NOT query (#4778). */
57+
const finds: Array<{ object: string; options: any }> = [];
5058

5159
return {
5260
_tables: tables,
5361
_hooks: hooks,
5462
_writes: writes,
63+
_finds: finds,
5564
async find(object: string, options?: any) {
65+
finds.push({ object, options });
5666
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
5767
if (options?.orderBy?.[0]) {
5868
// Canonical SortNode key only (spec/data/query.zod.ts): a sloppy
@@ -1914,6 +1924,215 @@ describe('record-lock hook (node era)', () => {
19141924
});
19151925
});
19161926

1927+
// ── #4778: the lock has to survive a PREDICATE (multi) update ────────────
1928+
//
1929+
// `engine.update()` extracts `input.id` only from a SCALAR `where.id`; every
1930+
// other predicate is a multi-row write that routes to `updateMany` and reaches
1931+
// the hook with NO id. The hook used to open with `if (!id) return`, reading
1932+
// "no row was resolved" as "nothing to authorize" when the truth was "nothing
1933+
// was ever queried" (the #4757 / #4630 fail-open shape). Rewriting the very
1934+
// same edit as `multi: true` then walked past the lock with NO privilege at
1935+
// all — no admin, no isSystem, no `lockRecord: false`, no whitelisted field.
1936+
//
1937+
// Both halves are pinned here, because extending a guard to more rows fails
1938+
// the other way just as easily: the refusals AND every exemption, on both
1939+
// predicate shapes.
1940+
describe('record-lock hook — predicate (multi) updates (#4778)', () => {
1941+
let engine: ReturnType<typeof makeFakeEngine>;
1942+
let svc: ApprovalService;
1943+
let n = 0;
1944+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
1945+
1946+
/** The two shapes that carry no `input.id` and used to bypass the lock. */
1947+
const SHAPES: Array<[string, any]> = [
1948+
['an id-operator predicate', { id: { $in: ['opp1'] } }],
1949+
['a non-id predicate', { stage: 'new' }],
1950+
];
1951+
1952+
const USER = { isSystem: false, positions: [], userId: 'u1' };
1953+
1954+
/** A `multi: true` update, i.e. the ctx the engine builds for `updateMany`. */
1955+
const predicateUpdate = (
1956+
where: any,
1957+
data: Record<string, unknown>,
1958+
rest: Record<string, unknown> = {},
1959+
) =>
1960+
engine.fire('beforeUpdate', {
1961+
object: 'opportunity',
1962+
input: { data, options: { ...(where === undefined ? {} : { where }), multi: true } },
1963+
session: USER,
1964+
...rest,
1965+
});
1966+
1967+
/** Re-open the pending request with a different node-config snapshot. */
1968+
const reopenWith = async (configExtra: Record<string, any>) => {
1969+
engine._tables['sys_approval_request'] = [];
1970+
engine._tables['sys_approval_action'] = [];
1971+
await svc.openNodeRequest(
1972+
openInput(['u9'], {}, { approvalStatusField: 'approval_status', ...configExtra }),
1973+
CTX,
1974+
);
1975+
};
1976+
1977+
beforeEach(async () => {
1978+
engine = makeFakeEngine();
1979+
n = 0;
1980+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
1981+
bindApprovalLockHook(engine as any);
1982+
await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
1983+
// `opp1` carries the pending request; `opp2` is an unlocked neighbour that
1984+
// the same predicates also match.
1985+
engine._tables['opportunity'] = [
1986+
{ id: 'opp1', amount: 100, stage: 'new' },
1987+
{ id: 'opp2', amount: 100, stage: 'new' },
1988+
];
1989+
});
1990+
1991+
// ── the hole itself ───────────────────────────────────────────────
1992+
1993+
it.each(SHAPES)('blocks %s that reaches the locked record', async (_label, where) => {
1994+
await expect(predicateUpdate(where, { amount: 999 })).rejects.toThrow(/RECORD_LOCKED/);
1995+
});
1996+
1997+
it('blocks an unscoped whole-table update — no predicate at all', async () => {
1998+
// `updateMany` gets an AST of `{ object }`, i.e. every row, so every locked
1999+
// row of the object is in reach. "No predicate" is the widest write there
2000+
// is; it must not be the one that reads as "nothing to authorize".
2001+
await expect(predicateUpdate(undefined, { amount: 999 })).rejects.toThrow(/RECORD_LOCKED/);
2002+
});
2003+
2004+
it('names the locked record and its object in the refusal', async () => {
2005+
await expect(predicateUpdate({ stage: 'new' }, { amount: 999 }))
2006+
.rejects.toThrow(/record 'opp1' of 'opportunity' is locked/);
2007+
});
2008+
2009+
// ── and it must not over-block: a lock is a PER-ROW verdict ────────
2010+
2011+
it('allows a predicate that reaches only unlocked rows', async () => {
2012+
await expect(predicateUpdate({ id: { $in: ['opp2'] } }, { amount: 999 })).resolves.toBeUndefined();
2013+
});
2014+
2015+
it('allows a non-id predicate that matches no locked row', async () => {
2016+
// `opp1` is `stage: 'new'`, so this predicate misses it. Resolving the row
2017+
// set is what makes the difference between refusing this write and
2018+
// refusing every bulk update on an object that has any approval open.
2019+
await expect(predicateUpdate({ stage: 'closed' }, { amount: 999 })).resolves.toBeUndefined();
2020+
});
2021+
2022+
it('never scans an object that has no pending approval at all', async () => {
2023+
engine._finds.length = 0;
2024+
await expect(
2025+
engine.fire('beforeUpdate', {
2026+
object: 'other_object',
2027+
input: { data: { amount: 999 }, options: { where: { stage: 'new' }, multi: true } },
2028+
session: USER,
2029+
}),
2030+
).resolves.toBeUndefined();
2031+
// One bookkeeping probe, and nothing else: the bound is on locked records,
2032+
// so a mass update of unlocked rows costs a single query.
2033+
expect(engine._finds.map(f => f.object)).toEqual(['sys_approval_request']);
2034+
});
2035+
2036+
// ── fail closed when the row set cannot be decided ─────────────────
2037+
2038+
it('fails closed past the 1000-record bound', async () => {
2039+
for (let i = 0; i < 1001; i++) {
2040+
engine._tables['sys_approval_request'].push({
2041+
id: `extra_${i}`,
2042+
object_name: 'opportunity',
2043+
record_id: `bulk_${i}`,
2044+
status: 'pending',
2045+
node_config_json: JSON.stringify({ lockRecord: true }),
2046+
});
2047+
}
2048+
await expect(predicateUpdate({ stage: 'new' }, { amount: 999 }))
2049+
.rejects.toThrow(/RECORD_LOCKED.*more than 1000/s);
2050+
});
2051+
2052+
it('fails closed when the match set cannot be resolved', async () => {
2053+
const realFind = engine.find.bind(engine);
2054+
engine.find = (async (object: string, options?: any) => {
2055+
if (object === 'opportunity') throw new Error('driver unavailable');
2056+
return realFind(object, options);
2057+
}) as typeof engine.find;
2058+
await expect(predicateUpdate({ stage: 'new' }, { amount: 999 }))
2059+
.rejects.toThrow(/RECORD_LOCKED.*cannot determine which rows/s);
2060+
});
2061+
2062+
// ── every exemption moves with the guard (the other failure mode) ──
2063+
2064+
it.each(SHAPES)('allows engine self-writes (system session) via %s', async (_label, where) => {
2065+
await expect(
2066+
predicateUpdate(where, { amount: 999 }, { session: { isSystem: true, positions: [] } }),
2067+
).resolves.toBeUndefined();
2068+
});
2069+
2070+
it.each(SHAPES)('allows an admin override via %s', async (_label, where) => {
2071+
await expect(
2072+
predicateUpdate(where, { amount: 999 }, { session: { isSystem: false, roles: ['admin'] } }),
2073+
).resolves.toBeUndefined();
2074+
});
2075+
2076+
it.each(SHAPES)('allows a status-mirror write via %s', async (_label, where) => {
2077+
await expect(predicateUpdate(where, { approval_status: 'approved' })).resolves.toBeUndefined();
2078+
});
2079+
2080+
it.each(SHAPES)('allows the OWNING run to write its own target record via %s', async (_label, where) => {
2081+
await expect(
2082+
predicateUpdate(where, { amount: 999 }, { provenance: { flowRunId: 'run_1' } }),
2083+
).resolves.toBeUndefined();
2084+
});
2085+
2086+
it.each(SHAPES)('allows the write when the node opted out of the lock, via %s', async (_label, where) => {
2087+
await reopenWith({ lockRecord: false });
2088+
await expect(predicateUpdate(where, { amount: 999 })).resolves.toBeUndefined();
2089+
});
2090+
2091+
// ── …and the exemptions stay as narrow as on the by-id path ────────
2092+
2093+
it('still blocks a DIFFERENT run on the predicate path', async () => {
2094+
await expect(
2095+
predicateUpdate({ stage: 'new' }, { amount: 999 }, { provenance: { flowRunId: 'run_other' } }),
2096+
).rejects.toThrow(/RECORD_LOCKED/);
2097+
});
2098+
2099+
it('still blocks a mirror write that changes anything else too', async () => {
2100+
await expect(
2101+
predicateUpdate({ stage: 'new' }, { approval_status: 'approved', amount: 999 }),
2102+
).rejects.toThrow(/RECORD_LOCKED/);
2103+
});
2104+
2105+
it('still blocks an identity-less caller with no provenance at all', async () => {
2106+
await expect(
2107+
engine.fire('beforeUpdate', {
2108+
object: 'opportunity',
2109+
input: { data: { amount: 999 }, options: { where: { stage: 'new' }, multi: true } },
2110+
}),
2111+
).rejects.toThrow(/RECORD_LOCKED/);
2112+
});
2113+
2114+
it('judges a multi-row write by EACH request it reaches', async () => {
2115+
// Two records, two independent approvals: one opted out of the lock, one
2116+
// did not. A predicate spanning both is refused by the one that locks.
2117+
engine._tables['sys_approval_request'].push({
2118+
id: 'req_2',
2119+
object_name: 'opportunity',
2120+
record_id: 'opp2',
2121+
status: 'pending',
2122+
flow_run_id: 'run_2',
2123+
node_config_json: JSON.stringify({ lockRecord: false }),
2124+
});
2125+
await expect(predicateUpdate({ id: { $in: ['opp2'] } }, { amount: 999 })).resolves.toBeUndefined();
2126+
await expect(predicateUpdate({ id: { $in: ['opp1', 'opp2'] } }, { amount: 999 }))
2127+
.rejects.toThrow(/record 'opp1'/);
2128+
});
2129+
2130+
it('ignores a request that is no longer pending', async () => {
2131+
engine._tables['sys_approval_request'][0].status = 'approved';
2132+
await expect(predicateUpdate({ stage: 'new' }, { amount: 999 })).resolves.toBeUndefined();
2133+
});
2134+
});
2135+
19172136
// ── #3456 recovery half: release records held by a dead approval run ──
19182137
//
19192138
// The prevention half above stops a run from dying on its own lock. This sweep

0 commit comments

Comments
 (0)