Skip to content

Commit c5b9ccc

Browse files
claude[bot]claude
andauthored
fix(objectql): privileged driver-level reads join the ambient transaction (#11435)
* fix(objectql): privileged driver-level reads join the ambient transaction The engine's three privileged read verbs — resolveSecret, resolveSecretField and resolveInternalField — read at DRIVER level on purpose: that is the only layer where a masked or `internal: true`-omitted value still exists, and bypassing hooks, field-level security and sharing is the declared trust each places in its in-process caller. What they also bypassed, not by design, was the connection the surrounding transaction is holding: buildDriverOptions threads the ambient handle (ADR-0034) onto every ordinary read, while these three passed the driver NO options, so their read went to a FRESH pooled connection. Invisible on a roomy pool; a deadlock on a single-connection one. SQLite's knex pool is max=1 (driver-sqlite-wasm and driver-sql/better-sqlite3 both), which encodes SQLite's single-writer model rather than a tuning choice. Where the two met: AuthManager.handleRequest runs SESSION_ERASURE_PATHS inside engine.transaction(...), the vendor's session re-read reaches resolveInternalField through plugin-auth's internal-field readback, and the read waited for a connection that could not be freed until the transaction waiting on the read finished. knex's acquire timeout fired and the route degraded the block into an authentication refusal. Measured on the default datasource before this change: a caller better-auth's own admin gate ADMITS answered 401 after 120,196ms with the target row still present, and a signed-in member got the same 401 after 120,025ms instead of 403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS. After: 200 with the row deleted, 403, and an anonymous caller's 401 unchanged — all prompt. Reads only; the privileged write paths are untouched. The #5351 same-origin gate still decides whether the handle is this object's driver's to use, so a privileged read resolving to a different datasource keeps its own connection. The dogfood admin-route sweep's remove-user carve-out (which accepted UNAUTHENTICATED as an additional denial code) is deleted with the defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * test(objectql,verify): keep the two new test files inside the TEST_DEBT ratchet `check:type-check-debt --re-measure` counts raw `tsc --noEmit` errors per package with the test exclusion lifted, so a package's own green `typecheck` says nothing about the files it hides. Both new files landed inside that hidden layer and drifted the shrink-only ledger: @objectstack/objectql 354 -> 364 (+10) and @objectstack/verify 8 -> 9 (+1). Every one of the 11 was in the new files, so the drift is attributable, not inherited. Fixed rather than re-baselined — raising a shrink-only entry is maintainer-only and hands back what an earlier PR paid to press it down: - registerObject(schema, packageId, ...) requires 2 arguments; the three calls passed 1. Supplied '__test__', the packageId the sibling engine transaction tests already use. - Array.prototype.at sits above the `lib` this program targets. Replaced the seven `.at(-1)!` sites with a local indexed `last()` helper rather than widening the compiler configuration for a test convenience. - './harness' -> './harness.js'; NodeNext needs the explicit extension, and two sibling verify tests already spell it that way. Re-measured after the repair: objectql 354 = ledger 354, verify 8 = ledger 8, zero errors in either new file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 20cecbb commit c5b9ccc

5 files changed

Lines changed: 468 additions & 24 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
**Fix:** the engine's three privileged driver-level reads now JOIN an open ambient transaction instead of asking the connection pool for a second connection — which deadlocked `pool max=1` datasources and made `/admin/remove-user` refuse an entitled, signed-in caller with `401 UNAUTHENTICATED` (#10792).
6+
7+
`resolveSecret`, `resolveSecretField` and `resolveInternalField` read at DRIVER level on purpose: that is the only layer where a masked or `internal: true`-omitted value still exists, and bypassing hooks, field-level security and sharing is the declared trust each of them places in its in-process caller. What they also bypassed — not by design — was the connection the surrounding transaction is holding. `buildDriverOptions` threads the ambient handle (ADR-0034) onto every ordinary read for exactly this reason; these three passed the driver **no options at all**, so their read went to a *fresh* pooled connection.
8+
9+
On a roomy pool that is invisible: the pool simply hands out another connection. On a single-connection pool it is a deadlock. SQLite's knex pool is `max: 1``driver-sqlite-wasm` and `driver-sql`/better-sqlite3 both — and `pool max=1` is not a tuning choice there, it encodes SQLite's single-writer model.
10+
11+
Measured on the erasure path, which is where the two met. `AuthManager.handleRequest` runs the `SESSION_ERASURE_PATHS` routes inside `engine.transaction(...)` so a refused erasure cannot leave the session and account deletes committed. Inside that transaction the vendor's session re-read reaches `resolveInternalField` through plugin-auth's internal-field readback; the read waited for a connection that could not be freed until the transaction waiting on the read finished, knex's acquire timeout fired (`Timeout acquiring a connection. The pool is probably full`), and the route degraded the block into an authentication refusal. On the default `objectstack dev` datasource, before this change: a caller better-auth's own admin gate **admits** was answered `401` after **120,196 ms** with the target row still present, and a signed-in plain member got the same `401` after **120,025 ms** instead of the `403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS` an authorization refusal owes them. After: `200` with the row deleted, `403`, and an anonymous caller's `401` unchanged — all promptly. Postgres and MySQL (`max >= 10`) always conformed and are unaffected; the reach nonetheless mattered because SQLite is the default datasource for `objectstack dev`, the showcase/dogfood boot, and any self-host that has not configured Postgres or MySQL.
12+
13+
Two properties are deliberately **not** widened. The join is reads-only — the privileged write paths are untouched. And the #5351 same-origin gate still decides whether the handle is this object's driver's to use, so a privileged read that resolves to a *different* datasource keeps its own connection rather than executing someone else's statement on the wrong one.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The three PRIVILEGED driver-level read verbs — `resolveSecret`,
4+
// `resolveSecretField`, `resolveInternalField` — must JOIN the open ambient
5+
// transaction (ADR-0034) instead of asking the pool for a second connection.
6+
//
7+
// Why this is a security guard and not a performance one. Each of the three
8+
// reads at DRIVER level on purpose, because that is the only layer where the
9+
// masked/omitted value still exists; that bypasses hooks, field-level security
10+
// and sharing BY DESIGN. What it must not also bypass is the connection the
11+
// surrounding transaction is holding. Until this guard they passed the driver
12+
// NO options at all, so the read went to a FRESH pooled connection — invisible
13+
// on a roomy pool, a DEADLOCK on a single-connection one.
14+
//
15+
// Measured shape of that deadlock, on the erasure path
16+
// (`runSubjectErasureAtomically` wraps better-auth's `/admin/remove-user` in
17+
// `engine.transaction`, whose handler's session read reaches
18+
// `resolveInternalField` through plugin-auth's internal-field readback): the
19+
// privileged read waited for a connection that could not be freed until the
20+
// transaction waiting on the read finished. knex's acquire timeout fired
21+
// ("Timeout acquiring a connection. The pool is probably full"), and the vendor
22+
// route degraded that into an AUTHENTICATION refusal — a signed-in, entitled
23+
// caller answered `401` after ~120s on a route reachable without credentials.
24+
// SQLite's knex pool is `max: 1` (`driver-sqlite-wasm` and `driver-sql`/
25+
// better-sqlite3 both) and SQLite is the default datasource for `objectstack
26+
// dev`, the showcase boot and any unconfigured self-host; Postgres/MySQL run
27+
// `max >= 10` and never exhibited it.
28+
//
29+
// Each arm carries its own REVERSE CONTROL — the same call outside a
30+
// transaction must reach the driver with NO handle. Without it "the driver saw
31+
// a transaction" could be satisfied by a driver that fabricates one, and the
32+
// assertion would measure nothing.
33+
34+
import { describe, it, expect, beforeEach } from 'vitest';
35+
import { ObjectQL } from './engine.js';
36+
37+
/**
38+
* The last recorded find. `Array.prototype.at` sits above the `lib` this
39+
* package's tsc program targets, so index rather than widen the compiler
40+
* configuration for a test convenience.
41+
*/
42+
const last = <T>(rows: T[]): T => rows[rows.length - 1];
43+
44+
const HASH = 'sha256:9f2c';
45+
46+
function makeRecordingDriver(name: string) {
47+
const rows = new Map<string, Map<string, any>>();
48+
/** One entry per driver-level `find`, with the transaction option it was handed. */
49+
const finds: Array<{ object: string; transaction: unknown }> = [];
50+
const storeFor = (o: string) => {
51+
let s = rows.get(o);
52+
if (!s) { s = new Map(); rows.set(o, s); }
53+
return s;
54+
};
55+
const driver: any = {
56+
name,
57+
version: '0.0.0',
58+
supports: {},
59+
async connect() {},
60+
async disconnect() {},
61+
async checkHealth() { return true; },
62+
async execute() { return null; },
63+
async find(object: string, ast: any, options: any) {
64+
finds.push({ object, transaction: options?.transaction });
65+
const all = Array.from(storeFor(object).values());
66+
const id = ast?.where?.id;
67+
if (typeof id === 'string') return all.filter((r) => r.id === id);
68+
if (id && Array.isArray(id.$in)) return all.filter((r) => id.$in.includes(r.id));
69+
return all;
70+
},
71+
async findOne(object: string) {
72+
for (const r of storeFor(object).values()) return r;
73+
return null;
74+
},
75+
async create(object: string, data: Record<string, unknown>) {
76+
const row = { ...data, id: (data.id as string) ?? `r_${storeFor(object).size + 1}` };
77+
storeFor(object).set(row.id, row);
78+
return row;
79+
},
80+
async update(object: string, id: string, data: Record<string, unknown>) {
81+
const s = storeFor(object);
82+
const row = { ...s.get(id), ...data, id };
83+
s.set(id, row);
84+
return row;
85+
},
86+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
87+
async count() { return 0; },
88+
async bulkCreate() { return []; },
89+
async bulkUpdate() { return []; },
90+
async bulkDelete() {},
91+
async beginTransaction() { return { __trx: name, commit: async () => {}, rollback: async () => {} }; },
92+
async commit() {},
93+
async rollback() {},
94+
/** Seed straight into storage — no engine verb, so no find is recorded. */
95+
seed(object: string, row: Record<string, unknown>) { storeFor(object).set(String(row.id), row); },
96+
};
97+
return { driver, finds };
98+
}
99+
100+
describe('privileged driver-level reads join the ambient transaction (#10792)', () => {
101+
let engine: ObjectQL;
102+
let primary: ReturnType<typeof makeRecordingDriver>;
103+
104+
beforeEach(async () => {
105+
engine = new ObjectQL();
106+
primary = makeRecordingDriver('primary');
107+
engine.registerDriver(primary.driver, true);
108+
await engine.init();
109+
engine.registry.registerObject({
110+
name: 'ptest_api_key',
111+
fields: {
112+
name: { type: 'text' },
113+
key: { type: 'text', internal: true },
114+
conn_secret: { type: 'secret' },
115+
},
116+
} as any, '__test__');
117+
engine.registry.registerObject({
118+
name: 'sys_secret',
119+
fields: {
120+
namespace: { type: 'text' }, key: { type: 'text' }, alg: { type: 'text' },
121+
version: { type: 'text' }, ciphertext: { type: 'text' }, kms_key_id: { type: 'text' },
122+
},
123+
} as any, '__test__');
124+
primary.driver.seed('ptest_api_key', { id: 'k1', name: 'k', key: HASH, conn_secret: 'secret:s1' });
125+
primary.driver.seed('sys_secret', {
126+
id: 's1', namespace: 'ptest_api_key', key: 'conn_secret',
127+
alg: 'aes-256-gcm', version: '1', ciphertext: 'ct', kms_key_id: 'local',
128+
});
129+
engine.setCryptoProvider({
130+
async encrypt() { throw new Error('not used'); },
131+
async decrypt() { return 'PLAINTEXT'; },
132+
} as any);
133+
});
134+
135+
it('resolveInternalField — the read the erasure path blocked on', async () => {
136+
// REVERSE CONTROL first: outside a transaction there is no handle to thread,
137+
// so a driver that fabricated one would fail here.
138+
await engine.resolveInternalField('ptest_api_key', ['k1'], 'key');
139+
expect(last(primary.finds).transaction, 'outside a transaction: no handle').toBeUndefined();
140+
141+
let resolved: Map<string, unknown> | undefined;
142+
await engine.transaction(async () => {
143+
resolved = await engine.resolveInternalField('ptest_api_key', ['k1'], 'key');
144+
});
145+
const inside = last(primary.finds);
146+
expect(inside.object).toBe('ptest_api_key');
147+
expect(inside.transaction, 'inside a transaction: the ambient handle').toBeTruthy();
148+
// Still the right answer — joining the transaction is not a degrade.
149+
expect(resolved!.get('k1')).toBe(HASH);
150+
});
151+
152+
it('resolveSecretField', async () => {
153+
await engine.resolveSecretField('ptest_api_key', 'k1', 'conn_secret');
154+
expect(last(primary.finds).transaction, 'outside a transaction: no handle').toBeUndefined();
155+
156+
let plaintext: string | null = null;
157+
await engine.transaction(async () => {
158+
plaintext = await engine.resolveSecretField('ptest_api_key', 'k1', 'conn_secret');
159+
});
160+
// Two reads on this path — the record, then `sys_secret` via resolveSecret.
161+
// BOTH must ride the transaction: either one alone starves a max=1 pool.
162+
const [record, secretRow] = primary.finds.slice(-2);
163+
expect(record.object).toBe('ptest_api_key');
164+
expect(record.transaction).toBeTruthy();
165+
expect(secretRow.object).toBe('sys_secret');
166+
expect(secretRow.transaction).toBeTruthy();
167+
expect(plaintext).toBe('PLAINTEXT');
168+
});
169+
170+
it('resolveSecret — the sys_secret dereference', async () => {
171+
await engine.resolveSecret('secret:s1');
172+
expect(last(primary.finds).transaction, 'outside a transaction: no handle').toBeUndefined();
173+
174+
await engine.transaction(async () => {
175+
await engine.resolveSecret('secret:s1');
176+
});
177+
const inside = last(primary.finds);
178+
expect(inside.object).toBe('sys_secret');
179+
expect(inside.transaction).toBeTruthy();
180+
});
181+
182+
it('the same-origin gate still holds — a handle never reaches a FOREIGN driver', async () => {
183+
// #5351: a transaction handle is a property of ONE driver's connection.
184+
// Handing it to a different driver does not put that driver's statement
185+
// inside the transaction, it executes it on the WRONG CONNECTION. The join
186+
// above must not widen that hole: an object bound to another datasource
187+
// keeps its own connection, which is the pre-existing (correct) behaviour.
188+
const other = makeRecordingDriver('other_db');
189+
engine.registerDriver(other.driver);
190+
engine.setDatasourceMapping([{ objectPattern: 'ptest_foreign', datasource: 'other_db' }]);
191+
engine.registry.registerObject({
192+
name: 'ptest_foreign',
193+
fields: { name: { type: 'text' }, key: { type: 'text', internal: true } },
194+
} as any, '__test__');
195+
other.driver.seed('ptest_foreign', { id: 'f1', name: 'f', key: HASH });
196+
197+
await engine.transaction(async () => {
198+
// The ambient transaction belongs to `primary`; this read resolves to
199+
// `other_db`, so it must arrive with NO handle.
200+
await engine.resolveInternalField('ptest_foreign', ['f1'], 'key');
201+
});
202+
expect(last(other.finds).object).toBe('ptest_foreign');
203+
expect(last(other.finds).transaction, 'a foreign driver must not receive the handle').toBeUndefined();
204+
});
205+
});

packages/objectql/src/engine.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5937,6 +5937,46 @@ export class ObjectQL implements IObjectQLEngine {
59375937
stripSearchCompanion(rows);
59385938
}
59395939

5940+
/**
5941+
* Driver options for a PRIVILEGED, driver-level read so it JOINS the open
5942+
* ambient transaction instead of asking the pool for a second connection.
5943+
*
5944+
* The three privileged read verbs — {@link resolveSecret},
5945+
* {@link resolveSecretField}, {@link resolveInternalField} — deliberately
5946+
* read at DRIVER level, the only layer where the masked/omitted value still
5947+
* exists. That bypasses hooks, field-level security and sharing by design;
5948+
* what it must NOT bypass is the connection the surrounding transaction is
5949+
* holding. {@link buildDriverOptions} threads the ambient handle onto every
5950+
* ordinary read for that reason (ADR-0034); these three passed NO options at
5951+
* all, so their read went to a FRESH pooled connection.
5952+
*
5953+
* On a roomy pool that is invisible — the pool simply hands out a second
5954+
* connection. On a **single-connection pool it is a deadlock**: SQLite's knex
5955+
* pool is `max: 1` (`driver-sqlite-wasm` and `driver-sql`/better-sqlite3
5956+
* both), so the open transaction holds the one connection and the privileged
5957+
* read waits for a connection that cannot be freed until the transaction
5958+
* that is waiting on the read commits. Measured on the erasure path
5959+
* (`runSubjectErasureAtomically` → better-auth `/admin/remove-user` →
5960+
* `reattachInternalFieldsOnRead` → `resolveInternalField`): the read blocked
5961+
* until knex's own acquire timeout fired ("Timeout acquiring a connection.
5962+
* The pool is probably full", from `Transaction_Sqlite.acquireConnection`),
5963+
* and the vendor route degraded that into an authentication refusal — a
5964+
* signed-in caller answered `401` after ~120 s, on a route reachable without
5965+
* credentials. Postgres/MySQL (`max >= 10`) never exhibited it.
5966+
*
5967+
* Reads only, and only the transaction: the same-origin gate (#5351) still
5968+
* decides whether the handle is this object's driver's to use, so a
5969+
* privileged read that resolves to a DIFFERENT datasource keeps its own
5970+
* connection rather than executing on the wrong one. Returns `undefined`
5971+
* when there is no ambient transaction, which is the pre-existing shape.
5972+
*/
5973+
private privilegedReadDriverOptions(object: string): { transaction: unknown } | undefined {
5974+
const tx = this.txStore.getStore()?.transaction;
5975+
if (tx === undefined) return undefined;
5976+
if (!this.transactionCoversDriverFor(object, tx)) return undefined;
5977+
return { transaction: tx };
5978+
}
5979+
59405980
/**
59415981
* Dereference a stored secret ref back to its plaintext. Intended for
59425982
* privileged, server-side consumers (e.g. a datasource connection-pool
@@ -5953,7 +5993,11 @@ export class ObjectQL implements IObjectQLEngine {
59535993
throw new Error('Cannot resolve secret: no CryptoProvider is registered (fail-closed).');
59545994
}
59555995
const secretDriver = this.getDriver('sys_secret');
5956-
const found = await secretDriver.find('sys_secret', { where: { id } });
5996+
const found = await secretDriver.find(
5997+
'sys_secret',
5998+
{ where: { id } },
5999+
this.privilegedReadDriverOptions('sys_secret'),
6000+
);
59576001
const secret: any = Array.isArray(found) ? found[0] : found;
59586002
if (!secret) {
59596003
throw new Error(`Cannot resolve secret: sys_secret row "${id}" not found (fail-closed).`);
@@ -6018,7 +6062,11 @@ export class ObjectQL implements IObjectQLEngine {
60186062
);
60196063
}
60206064
const driver = this.getDriver(object);
6021-
const found = await driver.find(object, { where: { id: recordId } });
6065+
const found = await driver.find(
6066+
object,
6067+
{ where: { id: recordId } },
6068+
this.privilegedReadDriverOptions(object),
6069+
);
60226070
const row: any = Array.isArray(found) ? found[0] : found;
60236071
if (!row) return null;
60246072
return this.resolveSecret(row[field], opts);
@@ -6100,10 +6148,14 @@ export class ObjectQL implements IObjectQLEngine {
61006148
const out = new Map<string, unknown>();
61016149
if (recordIds.length === 0) return out;
61026150
const driver = this.getDriver(object);
6103-
const found = await driver.find(object, {
6104-
where: { id: { $in: [...recordIds] } },
6105-
fields: ['id', field],
6106-
});
6151+
const found = await driver.find(
6152+
object,
6153+
{
6154+
where: { id: { $in: [...recordIds] } },
6155+
fields: ['id', field],
6156+
},
6157+
this.privilegedReadDriverOptions(object),
6158+
);
61076159
for (const row of Array.isArray(found) ? found : [found]) {
61086160
if (!row || typeof row !== 'object') continue;
61096161
const id = (row as Record<string, unknown>).id;

packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -585,26 +585,22 @@ describe('#9482 C9: every derived /admin/ route refuses a non-admin', () => {
585585
// vocabulary — not a validation error, which would mean the request died
586586
// before the gate and this assertion measured nothing.
587587
//
588-
// ⚠️ #10792, found the moment #10349 made this branch executable at all.
589-
// It was guarded by `member.code !== undefined`, and the code WAS
590-
// undefined on every bodyless refusal — so for those routes this check
591-
// had never once run. On the first run where it did, `remove-user` came
592-
// back `401 UNAUTHENTICATED` for a SIGNED-IN member while its siblings
588+
// ⚠️ #10792 CLOSED — `remove-user` used to be carved out here, accepting
589+
// `UNAUTHENTICATED` as an additional code. It was the one erasure-wrapped
590+
// route in this bucket, and inside that transaction the privileged read
591+
// behind the vendor's session re-read asked a `pool max=1` datasource for
592+
// a SECOND connection, blocked until knex's acquire timeout fired, and
593+
// degraded into `401` for a SIGNED-IN member while its unwrapped siblings
593594
// `set-role` and `update-user` answered the same bearer
594-
// `403 YOU_ARE_NOT_ALLOWED_*`: on that path alone the session is re-read
595-
// inside the #7724 erasure transaction and comes back empty, so
596-
// authentication answers a question authorization should have.
595+
// `403 YOU_ARE_NOT_ALLOWED_*`. The privileged read now joins the ambient
596+
// transaction, so this route answers the authorization question like
597+
// every other member of the bucket and needs no exception.
597598
//
598-
// Recorded as an ADDITIONAL accepted code for that one route, never as a
599-
// pin — same reasoning as the platform-admin arm below. Pinning today's
600-
// 401 would turn the fix red; pinning the 403 is red today; and widening
601-
// the vocabulary for EVERY route would let the next route drift into the
602-
// same state in silence. Delete this arm when #10792 closes.
603-
const KNOWN_AUTHN_BEFORE_AUTHZ = 'POST /api/v1/auth/admin/remove-user'; // #10792
604-
const denialCodes =
605-
route === KNOWN_AUTHN_BEFORE_AUTHZ
606-
? /^(YOU_ARE_NOT_ALLOWED|UNAUTHENTICATED$)/
607-
: /^YOU_ARE_NOT_ALLOWED/;
599+
// ⛔ Do not re-widen the vocabulary — for this route or for all of them.
600+
// A route that answers `UNAUTHENTICATED` to a signed-in caller is
601+
// announcing that authentication ran where authorization should have, and
602+
// that is precisely the state this arm exists to catch.
603+
const denialCodes = /^YOU_ARE_NOT_ALLOWED/;
608604
if (member.code !== undefined) {
609605
expect(
610606
member.code,

0 commit comments

Comments
 (0)