Skip to content

Commit fce8e49

Browse files
authored
fix(objectql): fold internal: true into the aggregate guard (#7922) (#7984)
`rejectCredentialAggregation` decided what to refuse by asking `collectCredentialFields`, a collector keyed on the field TYPE (`secret` / `password`). That left it blind to ADR-0100's third credential channel — an auth-subsystem one-way hash living in an ordinary `text` column — which is exactly the channel #7728 minted the type-independent `internal: true` flag for. So the read path understood "protected by flag" while the guard still only understood "protected by type": a flagged column that `find` omitted could be named as a `groupBy` dimension or a MIN/MAX measure and come back as the group key itself. The guard now takes the deduped union of the two collectors. Composition happens at the call site — the collectors stay separate because their other consumers answer differently (the read path MASKS a credential type and OMITS a flagged field, and a flagged column must never acquire a mask). Nothing is disclosed by this today: there is no `/data/:object/aggregate` route and analytics requires a declared dataset, so no reachable caller could reach the gap. It is closed because the inconsistency is what bites the next adopter — `sys_api_key.key` is a SHA-256 hash, but `sys_session.token` (#7823) is a live bearer credential. Tests extend the existing #7728 floor (`internal-fields.test.ts`) rather than forking a second copy. The three CONTROL cases come first and are the load-bearing ones: an unflagged column on an object that HAS a flagged one still aggregates, an object with no flagged field is untouched, and COUNT(*) is not a false positive. An over-broad guard breaks analytics silently, so all three were verified falsifiable against two deliberate mutations of the guard.
1 parent e38db3d commit fce8e49

4 files changed

Lines changed: 238 additions & 17 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): the aggregate guard now refuses `internal: true` columns, not just the `secret`/`password` TYPES (#7922)
6+
7+
`aggregate()`'s fail-closed guard (`rejectCredentialAggregation`, ADR-0100 /
8+
#3171) decided what to refuse by asking `collectCredentialFields` — a collector
9+
keyed on the field **TYPE**. That left it blind to exactly the channel #7728
10+
had just given the read path a way to protect.
11+
12+
ADR-0100's third credential channel is an auth-subsystem one-way hash living in
13+
an ordinary `text` column (`sys_api_key.key`). No type-keyed collector can ever
14+
reach it, which is why #7728 minted the type-independent `internal: true` flag —
15+
*"the declared value is never returned on the generic data path"* — and taught
16+
`find` / `findOne` / the 201 create body / the by-id update body to omit it.
17+
18+
The aggregation guard was never taught the same thing. So the read path
19+
understood "protected by flag" while the guard still only understood "protected
20+
by type", and a flagged column that `find` omitted could be named as a `groupBy`
21+
dimension or a MIN/MAX measure and come back as the group key itself — the
22+
promise in the flag's own declaration stopping at the edge of `aggregate()`.
23+
24+
The guard now takes the **union** of the two collectors, deduped, so both the
25+
type-keyed and the flag-keyed sets are refused. Composition happens at the call
26+
site: the collectors stay separate because their other consumers answer
27+
differently — the read path MASKS a credential type and OMITS a flagged field,
28+
and a flagged column must never acquire a mask.
29+
30+
**Nothing is disclosed by this today, and this is not a security fix.** There is
31+
no `/data/:object/aggregate` route and analytics requires a declared dataset, so
32+
no reachable caller could reach the gap. It is closed because the inconsistency
33+
is what bites the next adopter: `sys_api_key.key` is a SHA-256 hash, but
34+
`sys_session.token` (#7823) is a live bearer credential, and the flag reads as
35+
though it already covered both.
36+
37+
**Unchanged.** An unflagged column still aggregates normally — including an
38+
ordinary column sitting on the same object as a flagged one, and `COUNT(*)` over
39+
an object that merely *has* one. Neither collector has a `managedBy` exemption,
40+
so the union does not acquire one, and the read-path behaviour from #7920 is
41+
untouched: `sys_api_key.key` still authenticates through `where: { key: <hash> }`
42+
and still mints show-once.

packages/objectql/src/engine.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9559,23 +9559,44 @@ export class ObjectQL implements IObjectQLEngine {
95599559
}
95609560

95619561
/**
9562-
* Fail-closed guard (ADR-0100 / #3171): refuse to aggregate over a credential
9563-
* field. `secret`/`password` values are masked on the generic read path so
9564-
* plaintext never leaves the engine, but `aggregate()` has no equivalent mask
9565-
* — a GROUP BY / MIN / MAX / array_agg over such a column would surface the
9566-
* stored `secret:<id>` ref or the password value, and post-hoc masking would
9567-
* corrupt group keys. So we reject instead. The check is unconditional
9568-
* (ignores `managedBy`): aggregating a credential is never legitimate, even on
9569-
* a better-auth object, where it would be an inference oracle over hashes.
9562+
* Fail-closed guard (ADR-0100 / #3171 / #7922): refuse to aggregate over a
9563+
* field whose value is withheld on the generic read path. Such fields reach
9564+
* this guard through **two independent collectors**, and it needs both:
9565+
*
9566+
* - {@link collectCredentialFields} — keyed by field TYPE (`secret` /
9567+
* `password`). A GROUP BY / MIN / MAX / array_agg over such a column would
9568+
* surface the stored `secret:<id>` ref or the password value.
9569+
* - {@link collectInternalReadFields} — keyed by the `internal: true` FLAG
9570+
* (#7728). ADR-0100's third credential channel is a one-way hash living in
9571+
* an ordinary `text` column, which no type-keyed collector can ever reach;
9572+
* the flag is that channel's opt-in declaration. Without this half the
9573+
* guard had the same type-vs-flag blind spot #7728 fixed on the read path:
9574+
* a flagged column was omitted from `find`/`findOne` yet freely groupable
9575+
* here, so the flag's promise ("never returned on the generic data path")
9576+
* stopped at the edge of `aggregate()`.
9577+
*
9578+
* Post-hoc masking is not available on this path — the value is already a
9579+
* group key by the time there is a row, and masking group keys corrupts the
9580+
* result. So we reject instead.
9581+
*
9582+
* Neither collector carries a `managedBy` exemption, so the union does not
9583+
* acquire one, deliberately. Read-masking exempts `password` on better-auth
9584+
* objects so login reads still see the stored value; *aggregating* a
9585+
* credential is never legitimate, least of all on an identity table, where it
9586+
* is an inference oracle over hashes.
95709587
*
95719588
* Only the two output-bearing positions on `EngineAggregateOptions` carry
95729589
* field names: `aggregations[].field` (skip COUNT(*) — undefined or '*') and
95739590
* `groupBy[]` (a string, or a `{ field }` bucket object).
95749591
*/
95759592
private rejectCredentialAggregation(object: string, query: EngineAggregateOptions): void {
95769593
const schema = this._registry.getObject(object);
9577-
const credentialFields = collectCredentialFields(schema);
9578-
if (credentialFields.length === 0) return;
9594+
// Deduped: one field can be reachable through both collectors (a `secret`
9595+
// column that is also flagged `internal`), and it must be named once.
9596+
const protectedFields = [
9597+
...new Set([...collectCredentialFields(schema), ...collectInternalReadFields(schema)]),
9598+
];
9599+
if (protectedFields.length === 0) return;
95799600

95809601
const referenced = new Set<string>();
95819602
for (const agg of query?.aggregations ?? []) {
@@ -9587,13 +9608,14 @@ export class ObjectQL implements IObjectQLEngine {
95879608
if (field) referenced.add(field);
95889609
}
95899610

9590-
const hit = credentialFields.filter((f) => referenced.has(f));
9611+
const hit = protectedFields.filter((f) => referenced.has(f));
95919612
if (hit.length > 0) {
95929613
throw new Error(
95939614
`Cannot aggregate credential field(s) ${hit.map((f) => `"${object}.${f}"`).join(', ')}: `
9594-
+ 'secret/password fields are masked on read so plaintext never leaves the engine, and '
9595-
+ 'aggregating them (group-by, min/max, array_agg, …) would surface the stored value. '
9596-
+ 'Refusing (fail-closed) — see ADR-0100 / #3171.',
9615+
+ 'secret/password fields are masked on read and `internal: true` fields are omitted '
9616+
+ 'outright, so the value never leaves the engine on the generic data path; aggregating '
9617+
+ 'them (group-by, min/max, array_agg, …) would surface it. '
9618+
+ 'Refusing (fail-closed) — see ADR-0100 / #3171 / #7922.',
95979619
);
95989620
}
95999621
}

packages/objectql/src/internal-fields.test.ts

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
*/
2424

2525
import { describe, it, expect, beforeEach } from 'vitest';
26-
import { ObjectQL } from './engine.js';
26+
import { ObjectQL, type EngineReadOptions } from './engine.js';
2727
import { collectInternalReadFields, SECRET_MASK } from './secret-fields.js';
28-
import type { ServiceObject } from '@objectstack/spec/data';
28+
import type { EngineAggregateOptions, ServiceObject } from '@objectstack/spec/data';
2929

3030
// ---- minimal stub driver (equality-only WHERE) ----------------------------
3131
// Rows leave the driver as COPIES, as a real driver's do — see the note in
@@ -145,6 +145,14 @@ async function buildEngine() {
145145

146146
const HASH = 'sha256:deadbeefcafe';
147147

148+
/**
149+
* Trailing read options for the aggregate cases below. Declared with its
150+
* contract type rather than inlined `as any`: erasing a read method's options
151+
* argument is what `query-options/no-any-erasure` bans and the #4918 ratchet
152+
* counts (`scripts/check-query-options-erasure-ratchet.mjs`).
153+
*/
154+
const SYSTEM: EngineReadOptions = { context: { isSystem: true } };
155+
148156
describe('#7728: the `internal` field flag omits a value from the generic data path', () => {
149157
let ctx: Awaited<ReturnType<typeof buildEngine>>;
150158
beforeEach(async () => { ctx = await buildEngine(); });
@@ -280,4 +288,138 @@ describe('#7728: the `internal` field flag omits a value from the generic data p
280288
expect(found).toHaveLength(1);
281289
});
282290
});
291+
292+
/**
293+
* [#7922] `aggregate()` has no strip: it groups and reduces the driver's raw
294+
* rows, so a flagged column reached through `groupBy` or an aggregation
295+
* measure would surface the very value the flag promises is "never returned
296+
* on the generic data path". The type-keyed half of this guard has been in
297+
* place since #3171 (see the `ADR-0100 / #3171` block in
298+
* `secret-fields.test.ts`, which stays the floor for `secret` / `password`);
299+
* what is pinned here is the flag-keyed half, which did not exist.
300+
*
301+
* The FIRST case is deliberately the negative one. A guard that refuses too
302+
* much breaks analytics silently — nothing throws at the surface a reviewer
303+
* looks at, the numbers just stop arriving — so the control that an
304+
* unflagged column still aggregates has to be able to fail on its own.
305+
*/
306+
describe('the aggregation guard', () => {
307+
/** Two rows sharing a prefix and one on its own — enough for real buckets. */
308+
const seedThree = async () => {
309+
await ctx.engine.insert('itest_api_key', { name: 'k1', prefix: 'osk_', revoked: false, key: HASH }, { context: { isSystem: true } } as any);
310+
await ctx.engine.insert('itest_api_key', { name: 'k2', prefix: 'osk_', revoked: false, key: `${HASH}-2` }, { context: { isSystem: true } } as any);
311+
await ctx.engine.insert('itest_api_key', { name: 'k3', prefix: 'svc_', revoked: true, key: `${HASH}-3` }, { context: { isSystem: true } } as any);
312+
};
313+
314+
it('CONTROL: an unflagged column on an object that HAS a flagged one still aggregates', async () => {
315+
await seedThree();
316+
317+
// `prefix` is an ordinary text column on the same object as the flagged
318+
// `key`. Grouping by it must keep working, and must return the real
319+
// buckets — asserting only "does not throw" would still pass if the
320+
// guard were replaced by a no-op that returned nothing.
321+
const rows = await ctx.engine.aggregate('itest_api_key', {
322+
aggregations: [{ function: 'count', alias: 'n' }],
323+
groupBy: ['prefix'],
324+
}, SYSTEM);
325+
326+
const byPrefix = Object.fromEntries(rows.map((r: any) => [r.prefix, Number(r.n)]));
327+
expect(byPrefix).toEqual({ osk_: 2, svc_: 1 });
328+
});
329+
330+
it('CONTROL: an object with NO flagged field aggregates untouched (the fast path)', async () => {
331+
await ctx.engine.insert('itest_plain', { key: 'visible' });
332+
await ctx.engine.insert('itest_plain', { key: 'visible' });
333+
await ctx.engine.insert('itest_plain', { key: 'other' });
334+
335+
// `itest_plain.key` shares its NAME with the flagged column on the other
336+
// object — a guard that collected field names globally rather than
337+
// per-schema would refuse here.
338+
const rows = await ctx.engine.aggregate('itest_plain', {
339+
aggregations: [{ function: 'count', alias: 'n' }],
340+
groupBy: ['key'],
341+
});
342+
343+
const byKey = Object.fromEntries(rows.map((r: any) => [r.key, Number(r.n)]));
344+
expect(byKey).toEqual({ visible: 2, other: 1 });
345+
});
346+
347+
it('CONTROL: COUNT(*) on the flagged object is not a false positive', async () => {
348+
await seedThree();
349+
// The object merely HAS a flagged column; nothing references it.
350+
const rows = await ctx.engine.aggregate('itest_api_key', {
351+
aggregations: [{ function: 'count', alias: 'n' }],
352+
}, SYSTEM);
353+
expect(Number((rows[0] as any).n)).toBe(3);
354+
});
355+
356+
it('rejects the flagged field as a string groupBy dimension', async () => {
357+
await seedThree();
358+
// The disclosure shape: one bucket per distinct hash, keyed BY the hash.
359+
await expect(
360+
ctx.engine.aggregate('itest_api_key', {
361+
aggregations: [{ function: 'count', alias: 'n' }],
362+
groupBy: ['key'],
363+
}, SYSTEM),
364+
).rejects.toThrow(/key/);
365+
});
366+
367+
it('rejects the flagged field as a structured {field} groupBy bucket', async () => {
368+
await seedThree();
369+
// `as unknown as` names the contract being bypassed rather than erasing
370+
// it: `EngineAggregateOptions.groupBy` is declared `string[]`, while the
371+
// engine reads structured `{ field, dateGranularity }` buckets too — so
372+
// this is deliberately off-contract input, and the guard must walk that
373+
// second spelling as well. (`as any` here would grow the #4918 ratchet.)
374+
await expect(
375+
ctx.engine.aggregate('itest_api_key', {
376+
aggregations: [{ function: 'count', alias: 'n' }],
377+
groupBy: [{ field: 'key' }],
378+
} as unknown as EngineAggregateOptions, SYSTEM),
379+
).rejects.toThrow(/key/);
380+
});
381+
382+
it('rejects the flagged field as an aggregation measure', async () => {
383+
await seedThree();
384+
// MIN/MAX over a credential is the inference oracle #3171 named.
385+
await expect(
386+
ctx.engine.aggregate('itest_api_key', {
387+
aggregations: [{ function: 'max', field: 'key', alias: 'x' }],
388+
}, SYSTEM),
389+
).rejects.toThrow(/key/);
390+
});
391+
392+
it('rejects even though the object is `managedBy: better-auth`', async () => {
393+
// The read path exempts better-auth from PASSWORD masking; neither
394+
// collector feeding this guard has an exemption, so the union does not
395+
// acquire one. `itest_api_key` is better-auth-managed and still refused.
396+
expect((tokenObject as any).managedBy).toBe('better-auth');
397+
await seedThree();
398+
await expect(
399+
ctx.engine.aggregate('itest_api_key', {
400+
aggregations: [{ function: 'count', alias: 'n' }],
401+
groupBy: ['key'],
402+
}, SYSTEM),
403+
).rejects.toThrow(/itest_api_key\.key/);
404+
});
405+
406+
it('names every refused field once, and only the refused ones', async () => {
407+
await seedThree();
408+
// Mixing a legitimate dimension with the flagged one refuses the whole
409+
// query (fail-closed) but must not slander `prefix`.
410+
const err = await ctx.engine.aggregate('itest_api_key', {
411+
aggregations: [{ function: 'count', alias: 'n' }],
412+
groupBy: ['prefix', 'key'],
413+
}, SYSTEM).then(
414+
() => null,
415+
(e: unknown) => e as Error,
416+
);
417+
expect(err).toBeInstanceOf(Error);
418+
expect(err!.message).toContain('itest_api_key.key');
419+
expect(err!.message).not.toContain('prefix');
420+
// Deduped: a field must not be listed twice if it is reachable through
421+
// both collectors.
422+
expect(err!.message.match(/itest_api_key\.key/g)).toHaveLength(1);
423+
});
424+
});
283425
});

packages/objectql/src/secret-fields.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,17 @@ export function collectMaskedReadFields(schema: ServiceObject | undefined | null
133133
* that needs a column readable simply does not flag it. An exemption here
134134
* would silently disable the flag on exactly the identity objects it was
135135
* minted for.
136-
* - **The caller OMITS the key rather than masking it** (see
136+
* - **The read-path caller OMITS the key rather than masking it** (see
137137
* {@link SECRET_MASK}). The mask signals "a value is set"; on a `required`
138138
* column that is zero bits of information, and shipping it would still put a
139139
* value under a field whose declaration promises none.
140140
*
141+
* [#7922] The read path is not the only consumer. `aggregate()` cannot omit —
142+
* a flagged column reached through `groupBy` is already the group KEY, and
143+
* masking keys corrupts the result — so the aggregate gate unions this collector
144+
* with {@link collectCredentialFields} and REFUSES the query instead. Same
145+
* question, two answers, because the two surfaces have different options.
146+
*
141147
* Returns an empty array when the schema has no fields or none are flagged, so
142148
* callers can fast-path on `length === 0`.
143149
*/
@@ -163,6 +169,15 @@ export function collectInternalReadFields(schema: ServiceObject | undefined | nu
163169
* aggregate-rejection gate keys off this stricter, exemption-free collector,
164170
* keeping the two concerns independent (they must not drift). See ADR-0100 / #3171.
165171
*
172+
* [#7922] This is the **type-keyed half** of what that gate refuses. Being
173+
* type-keyed it cannot see ADR-0100's third channel — a one-way hash in a `text`
174+
* column — so the gate unions it with {@link collectInternalReadFields}, the
175+
* flag-keyed half. ⛔ Do not collapse the two by widening either one — they
176+
* answer different questions ("is this a credential type?" vs "is this field
177+
* declared unreturnable?") and their other consumers respond differently: the
178+
* read path MASKS a credential type and OMITS a flagged field. Compose at the
179+
* call site, which is what the gate does.
180+
*
166181
* Returns an empty array when the schema has no fields or no credential fields,
167182
* so callers can fast-path on `length === 0`.
168183
*/

0 commit comments

Comments
 (0)