Skip to content

Commit 54c2496

Browse files
fix(objectql): extend the credential-column guard from aggregates to filter and sort keys (#27477)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: #27660 Hardening, ADR-0100 family. No issue is linked: this change was dispatched directly, so there is no closing keyword. **BREAKING** (an accept-set narrowing), shipped as `patch`: this PR narrows a query shape that was accepted before, and no metadata an author writes changes. The changeset carries the FROM / TO and one ADR-0087 `not-required (no-migration-prescription)` marker. It needs one contract-review-tier review before it enters the queue. ## 维护者速读 - 改了什么:引擎原本只拒绝对凭据列(`password` / `secret`)做分组和 min/max 聚合,现在也拒绝把它们当过滤条件或排序键,统一返回 `400 INVALID_FIELD`,driver 不会被调用。 - 为什么改:凭据列读出时一律打码,但过滤和排序会读取存储值,结果可能随这个值变化。这次把已有的聚合守卫补齐到这两个位置。 - 风险与回滚:系统上下文(`isSystem`)不受影响,服务端自己的凭据查找照常工作,登录已在 dogfood 中验证。按凭据列过滤或排序的非系统调用会改为收到 400;以凭据列作 `matchFields` 的 upsert / update 导入,其匹配查找同样被拒,相关行报失败、不写入。回滚只需 revert 本 PR。 - 先看:`packages/objectql/src/credential-evaluation-guard.ts`(风险在 `isCredentialPresenceCondition` 与系统上下文豁免那一行),`packages/objectql/src/engine.ts`(六个入口的接入点),`packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/credential-evaluation-guard.test.ts`,预期 30/30 通过。 - 需要你做的:无,席位落地(待 contract-review-tier 增量复核通过)。 ## What changes - **One seam, beside the aggregate guard.** `rejectCredentialEvaluation` (new, `packages/objectql/src/credential-evaluation-guard.ts`) reads the same exemption-free collector as `rejectCredentialAggregation`, `collectCredentialFields`. It answers with the same ADR-0112 envelope: `INVALID_FIELD` / 400, with `field`, `fields`, `object` and `param`. It runs on `find`, `findOne`, `count`, `aggregate`, and predicate `update` / `delete`. It runs right after the `where` array-form lowering and before the search expansion, token resolution, the middleware chain or any driver call. - **Positions judged:** `where` (every logical branch), a `{ $field }` cross-field comparand, `orderBy`, each `aggregations[i].filter`, and each `expand` entry's own `where` / `orderBy`, at every depth, against the target object. A nested-relation condition is judged by the related object's own read, which passes through the same guard. - **Accepted on a credential column:** a has-a-value check only. That is a bare `null`, `$null`, `$exists`, `$empty`, `$eq: null`, `$ne: null`, or `$not` over one of those. It reveals nothing the masked row does not already show. If keeping this check is a product question, this is the safe default. Every other operator is refused. - **Who it binds:** every caller except a system context. The server's own credential consumers match on such columns as system. The security and sharing middlewares inject their filters after the guard has judged the caller's query. - **Import match:** an import in `upsert` or `update` mode whose `matchFields` names a credential column looks rows up through the same engine read with the import's caller context, so that lookup is now refused too: each row that reaches it is reported failed with the refusal and is not written. - **Search:** the engine's search scan set now withholds credential columns too, as it already withheld `internal: true` columns. This covers a credential column an object lists in `searchableFields`. - **`internal: true` columns:** the filter / sort refusal for these stays where it was decided, at the generic data door (`assertNoInternalFieldEvaluated`). Some of their server-side consumers do not run as system. The new dogfood case pins that door for one such column, beside the password case. - Docs: `content/docs/data-modeling/field-types.mdx` states the refusal. `content/docs/permissions/system-context.mdx` gains row 18c and a regenerated register for the guard's one `isSystem` read. ## Tests - `packages/objectql/src/credential-evaluation-guard.test.ts`: a real engine over a recording driver. It covers 13 refused `where` shapes, `orderBy` on `password` and on `secret`, `findOne` / `count` / `aggregate` / per-aggregation `filter` / `update` / `delete`, an `expand` entry and a nested-relation condition. Every refusal asserts `code`, `status`, `httpStatus`, `field` and `param`, and that the driver was never called. The test also covers eight has-a-value shapes, a system context, non-credential fields and the search scan set. - `packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts`: on the booted showcase REST door, the cases are `POST /data/showcase_field_zoo/query` with equality, range, `$startsWith`, `$contains`, a `secret`-column predicate and `orderBy`, plus `GET /data/showcase_field_zoo?filter=…`. Each answers 400 `INVALID_FIELD`, and the response never contains the stored value. `$null: false` and `null` are served, and so is a control on `name`. On a scratch object, `orderBy` and `where` on a `password` column and on an `internal: true` column are refused. The seeded admin's sign-in in each `beforeAll` covers the login path. - Fixture triage: in `engine-text-operator-declared-type-door.test.ts` and `engine-number-comparand-declared-type-door.test.ts`, the `password` / `secret` cells now run as a system caller. Those doors run first, inside `lowerWhereFilterArray`; the credential guard runs after them and refuses a non-system caller on the rows they pass. The doors' verdicts do not depend on the caller. `federated-injected-column-readers.test.ts` gains the guard's `referenceTargetOf()` row. ## Acceptance notes - Checklist item `records-forms.encrypted-field-behavior`: every acceptance clause and negative still holds, and none is contradicted. The new filter / sort refusal sits beside acceptance[4]. Adding a clause for it is checklist-authoring work and is not done here. - The Console already treats masked columns as not sortable and not filterable (`isMaskedColumnKey` in `ui/packages/components/src/renderers/complex/data-table.tsx`), so no UI path offers the refused action. - Every door that reaches the engine with a caller context (REST, the MCP bridges, flows, plugin routes) meets this guard. No door needed its own copy. ## Verification <details><summary>Declaration round, at head 5eb62f0</summary> - Changes in this round: the changeset declaration (`Clause-②: no (narrowing)`, the BREAKING banner with FROM / TO naming the import match consequence, one ADR-0087 `not-required (no-migration-prescription)` marker) and the comment in the two declared-type door tests. No behaviour change. - `pnpm check:adr-0087-registration`: exit 0. `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.` `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. - `node scripts/check-changeset-no-major.mjs --event` with this body: exit 0. `LEVEL AXIS: this PR declares clause-② no (narrowing) and metadata-protocol none ... a non-protocol change at patch`. - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2` over the two door tests and `credential-evaluation-guard.test.ts`: Test Files 3 passed (3), Tests 88 passed (88). - `pnpm --filter @objectstack/objectql typecheck`: exit 0, `check:test-typecheck: OK`, 39 files / 233 errors / 64 pinned signatures held. - Full dogfood battery (`pnpm --filter @objectstack/dogfood test`, closure rebuilt): Test Files 348 passed, 3 skipped (351); Tests 2808 passed, 20 skipped (2828). - Derived gate list (`dispatch-gates.mjs --commands`): 96 derived, 94 exit 0, 2 NOT MEASURED locally (`check:dual-build-cjs-loads` and `check:type-check-debt` need a whole-repo build). `--ran`: 96 accounted for. Both are in `Lint & Repo Gates`, which is green on this head. </details> All readings below were taken at head `feb0bdeb57`, after `origin/main` was merged in, the dogfood dependency closure was rebuilt and `packages/runtime/.objectstack` was cleared. <details><summary>Unit and REST door</summary> - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2`: Test Files 485 passed (485), Tests 9123 passed (9123). - `pnpm --filter @objectstack/objectql typecheck`: exit 0. `check:test-typecheck: OK`, with 39 files / 233 errors / 64 pinned signatures held, unchanged. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/encrypted-field-behavior.dogfood.test.ts`: Tests 6 passed (6). - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. </details> <details><summary>Ablation (one time each, with restore proven)</summary> - Source-resolved, through `node scripts/ablation-replace.mjs`: the guard's entry line was made to always return. The mutation landed (anchor 1 → 0, blob `3d38a49b3d6a` → `316fd9e3b630`). `credential-evaluation-guard.test.ts` then showed 18 failed / 12 passed of 30. All 18 refusal cases went red. The 12 has-a-value, system, control and search cases stayed green, as they should. Restore: blob equal to HEAD, `git diff HEAD` empty. - Dist-resolved, which the REST door suite consumes: same mutation with a marker, then an `@objectstack/objectql` rebuild. `ablation-dist-preflight` found the marker in 4 built files. The dogfood file showed 2 failed / 4 passed. Both new cases failed on the first refused request, which answered 200 with rows. Restore: rebuilt, `--absent` check clean, and the whole tree clean against HEAD. </details> <details><summary>Local gate subset</summary> - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 94 commands at `feb0bdeb57`. All 94 were run, each exit code captured before any pipe: 94 × exit 0. `--ran` reconciliation: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. - On the first pass, at the pre-merge head, two gates read the new test file. `check:objectql-double-limit` flagged that the recording double read rows before applying the bound. `check:query-options-erasure` reported the test surface grew 236 → 241. Both were fixed (bound applied after the filter, options typed), and both are exit 0 above. </details> The required CI families (`Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core` and the rest) run on this PR's check-runs. They are not re-run here. <sub>Body refreshed by the PM seat 2026-10-29T16:41Z: Skills-docs follow-up filed as #27660.</sub> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0561a98 commit 54c2496

12 files changed

Lines changed: 692 additions & 6 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
Clause-②: no (narrowing)
6+
Metadata-protocol: none
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime read door: the engine stops EVALUATING a `password` / `secret` column in a non-system caller's filter or sort position, a column every generic read already masks, and the remedy is a change to the caller's query, never to anyone's metadata. No exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only); the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered). -->
9+
10+
**BREAKING** (an accept-set narrowing), shipped as `patch`: nothing a metadata author writes changes (`Metadata-protocol: none`), so the level follows the kind, and the banner and the ADR-0087 disposition stay.
11+
12+
Hardening (ADR-0100): the engine's credential-column guard now covers filter and sort keys, not only aggregates. A `password` or `secret` field was already refused as a group-by key or a min / max operand; the same refusal now covers every position that evaluates the stored value.
13+
14+
**FROM.** For every caller except a system context, on `find`, `findOne`, `count`, `aggregate` and predicate `update` / `delete`, and so on every door that reaches them with a caller context (the REST data routes, the MCP bridges, flows, hooks' `ctx.api`, plugin routes):
15+
16+
- A `where` / `filter` predicate on a `password` or `secret` field was evaluated, including a `{ "$field": … }` comparand, a per-aggregation `filter`, an `expand` entry's own `where` / `orderBy`, and a nested-relation condition on a related object's credential field.
17+
- An `orderBy` key on such a field was admitted.
18+
- A search scanned such a field when the object listed it in `searchableFields`.
19+
- An import in `upsert` or `update` mode whose `matchFields` names such a field looked rows up by that field.
20+
21+
**TO.**
22+
23+
- Each of those filter and sort positions is refused `400 INVALID_FIELD`, the same ADR-0112 envelope as the aggregate refusal (with `field`, `object` and `param` naming the position), before any driver is asked. The group-by and min / max refusals are unchanged.
24+
- A has-a-value check stays accepted: `{ "$null": … }`, `{ "$exists": … }`, `{ "$empty": … }`, or equality with `null`.
25+
- A search never scans a credential column, even one an object lists in `searchableFields`.
26+
- An import whose `matchFields` names such a field now meets the same refusal in its match lookup: each row that reaches the lookup is reported failed with that refusal and is not written.
27+
- A system context is not bound, so the server's own credential lookups are unchanged.
28+
29+
**Fix.** A caller that sorted or filtered on a `password` / `secret` field now receives `400 INVALID_FIELD`: sort or filter by another field, or use a has-a-value check. An import that matched on such a field matches on another field.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ Reversible, encrypted-at-rest value (DB password, API key, token). Unlike `passw
121121
{ name: 'api_key', label: 'API Key', type: 'secret' }
122122
```
123123

124+
**Querying a `password` or `secret` field.** Neither type is a filter, sort or aggregation key.
125+
Only a has-a-value check is accepted on one: `{ "$null": true | false }`, `{ "$exists": … }`,
126+
`{ "$empty": … }`, or equality with `null`. Any other operator in `where` or `filter` (equality
127+
with a value, a range, `$startsWith` / `$contains`, `$in`, a `{ "$field": … }` comparand), an
128+
`orderBy` entry, a group-by or a min / max over one is refused with `400 INVALID_FIELD` before
129+
the query runs. That holds for every caller, administrators included. A search also never scans
130+
such a column, even one listed in `searchableFields`. Only server-side code running as the
131+
system context may match on the stored value (ADR-0100).
132+
124133
---
125134

126135
## Rich Content Types

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ bugs — it is the protection or the side effect that silently does not happen.
121121
| 17 | Approval snapshot payload redaction skipped, and so is the snapshot query guard and the reader-locale label overlay | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel — and a filter, sort or grouping by it on any read; and a request's `process_label` / `current_step_label` as stored, never in a locale the context carries, so nothing the platform reads back and writes again holds a reader's language. Lose: field-visibility redaction over approval payloads, the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach, and the flow and step labels in the reader's language | `bindSnapshotRedactionMiddleware`, `bindSnapshotPredicateGuard`, `bindReaderLocaleLabelsMiddleware` |
122122
| 18 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `enforceAuth` |
123123
| 18b | Settings namespace read scope not applied | service-settings | Get: a read of `sys_setting`, `sys_setting_audit` or `sys_platform_setting` across every namespace, whatever each namespace's `readPermission` names — the settings service's own reads of its stores take this path. Lose: the per-namespace read gate the generic data API applies to every other caller, the same rule the settings routes enforce | `settingsReadDoorMiddleware` |
124+
| 18c | **Credential-column filter / sort refusal not applied** — a `where`, `orderBy`, per-aggregation `filter` or `expand` entry naming a `password` / `secret` column is evaluated | objectql | Get: the server's own match on a stored credential value, a lookup that never returns it. Every other caller, administrators included, is refused `400 INVALID_FIELD` for anything but a has-a-value check (ADR-0100). Lose: nothing a wire caller reaches — `isSystem` is never set on an inbound request | `rejectCredentialEvaluation` |
124125

125126
### 2. Write pipeline and data integrity
126127

@@ -524,6 +525,7 @@ and CI refuses one that differs from what the tree produces.
524525
| `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | 1 | 51 |
525526
| `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict` | 1 | 55b |
526527
| `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | 1 | 8 |
528+
| `packages/objectql/src/credential-evaluation-guard.ts#rejectCredentialEvaluation` | 1 | 18c |
527529
| `packages/objectql/src/engine-delete-cascade.ts#recordReferenceCheckElevation` | 1 | 29 |
528530
| `packages/objectql/src/engine-driver-options.ts#buildDriverOptions` | 1 | 24 |
529531
| `packages/objectql/src/engine-execution-plumbing.ts#buildSession` | 1 | 63 |

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,15 @@ const CALLERS: Record<string, Classification> = {
209209
kind: 'not-a-served-read',
210210
reason: "resolves a seed's references at install, under the system identity; serves nothing to a caller",
211211
},
212+
// ADR-0100: the credential-column guard's filter / sort half.
213+
'packages/objectql/src/credential-evaluation-guard.ts': {
214+
kind: 'not-a-served-read',
215+
reason:
216+
"names the object an `expand` entry the caller wrote targets, only to read that object's DECLARED credential " +
217+
'fields from the registry and refuse (`INVALID_FIELD` / 400) a filter or sort key on one; it reads no record and ' +
218+
"serves nothing of the target — the expanded read itself is the engine's `expandRelatedRecords`. Pinned by " +
219+
'`packages/objectql/src/credential-evaluation-guard.test.ts` (the expand refusals assert the driver was never asked)',
220+
},
212221
// [#25522 cut 10] The write-path reference check, moved out of engine.ts.
213222
'packages/objectql/src/engine-referential-integrity.ts': {
214223
kind: 'not-a-served-read',
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The credential-column guard's filter / sort half (ADR-0100), against a REAL
5+
* {@link ObjectQL} engine over a minimal recording driver: every refusal is
6+
* the ADR-0112 `INVALID_FIELD` / 400 envelope and leaves the driver unasked;
7+
* a has-a-value check, a system context and every non-credential field are
8+
* served as before.
9+
*/
10+
11+
import { describe, it, expect, beforeEach } from 'vitest';
12+
import { ObjectQL } from './engine.js';
13+
import { isCredentialPresenceCondition } from './credential-evaluation-guard.js';
14+
import type { EngineAggregateOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data';
15+
16+
function makeRecordingDriver() {
17+
const rows = new Map<string, Record<string, unknown>[]>();
18+
const calls: string[] = [];
19+
const matches = (row: Record<string, unknown>, where: any): boolean => {
20+
if (!where || typeof where !== 'object') return true;
21+
for (const [k, v] of Object.entries(where)) {
22+
if (k.startsWith('$')) continue;
23+
if (v !== null && typeof v === 'object') continue;
24+
if ((row[k] ?? null) !== (v ?? null)) return false;
25+
}
26+
return true;
27+
};
28+
const driver: any = {
29+
name: 'memory', version: '0.0.0', supports: {},
30+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
31+
async execute() { return null; },
32+
async find(object: string, ast: any) {
33+
calls.push(`find:${object}`);
34+
const found = (rows.get(object) ?? []).filter((r) => matches(r, ast?.where));
35+
const page = typeof ast?.limit === 'number' ? found.slice(0, ast.limit) : found;
36+
return page.map((r) => ({ ...r }));
37+
},
38+
async findOne(object: string, ast: any) {
39+
calls.push(`findOne:${object}`);
40+
const r = (rows.get(object) ?? []).find((x) => matches(x, ast?.where));
41+
return r ? { ...r } : null;
42+
},
43+
async count(object: string, ast: any) {
44+
calls.push(`count:${object}`);
45+
return (rows.get(object) ?? []).filter((r) => matches(r, ast?.where)).length;
46+
},
47+
async aggregate(object: string) {
48+
calls.push(`aggregate:${object}`);
49+
return [{ n: (rows.get(object) ?? []).length }];
50+
},
51+
async create(object: string, data: Record<string, unknown>) {
52+
const list = rows.get(object) ?? [];
53+
const row = { id: `r${list.length + 1}`, ...data };
54+
list.push(row);
55+
rows.set(object, list);
56+
return { ...row };
57+
},
58+
async update(object: string, id: string, data: Record<string, unknown>) {
59+
calls.push(`update:${object}`);
60+
return { id, ...data };
61+
},
62+
async updateMany(object: string) { calls.push(`updateMany:${object}`); return 0; },
63+
async delete(object: string) { calls.push(`delete:${object}`); return true; },
64+
async deleteMany(object: string) { calls.push(`deleteMany:${object}`); return 0; },
65+
async upsert(_o: string, data: Record<string, unknown>) { return data; },
66+
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
67+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
68+
async commit() {}, async rollback() {},
69+
};
70+
return { driver, rows, calls };
71+
}
72+
73+
const person = {
74+
name: 'cg_person', label: 'Person',
75+
fields: {
76+
id: { name: 'id', label: 'ID', type: 'text' as const },
77+
name: { name: 'name', label: 'Name', type: 'text' as const },
78+
pin: { name: 'pin', label: 'PIN', type: 'password' as const },
79+
},
80+
};
81+
82+
const vault = {
83+
name: 'cg_vault', label: 'Vault',
84+
searchableFields: ['name', 'pin'],
85+
fields: {
86+
id: { name: 'id', label: 'ID', type: 'text' as const },
87+
name: { name: 'name', label: 'Name', type: 'text' as const },
88+
pin: { name: 'pin', label: 'PIN', type: 'password' as const },
89+
token: { name: 'token', label: 'Token', type: 'secret' as const },
90+
holder: { name: 'holder', label: 'Holder', type: 'lookup' as const, reference: 'cg_person' },
91+
},
92+
};
93+
94+
const USER: EngineQueryOptions['context'] = { userId: 'u1' };
95+
const SYSTEM: EngineQueryOptions['context'] = { isSystem: true };
96+
97+
async function buildEngine() {
98+
const engine = new ObjectQL();
99+
const recording = makeRecordingDriver();
100+
engine.registerDriver(recording.driver, true);
101+
await engine.init();
102+
engine.registry.registerObject(person as any);
103+
engine.registry.registerObject(vault as any);
104+
recording.rows.set('cg_person', [{ id: 'p1', name: 'Pat', pin: '1234' }]);
105+
recording.rows.set('cg_vault', [{ id: 'v1', name: 'alpha', pin: 'p@ssw0rd!', token: 'secret:h1', holder: 'p1' }]);
106+
return { engine, ...recording };
107+
}
108+
109+
/** The ADR-0112 envelope the refusal carries, asserted on the thrown error. */
110+
async function expectRefused(run: () => Promise<unknown>, field: string, param: string) {
111+
let thrown: any;
112+
try { await run(); } catch (e) { thrown = e; }
113+
expect(thrown, 'the call was refused').toBeDefined();
114+
expect(thrown.code).toBe('INVALID_FIELD');
115+
expect(thrown.status).toBe(400);
116+
expect(thrown.httpStatus).toBe(400);
117+
expect(thrown.field).toBe(field);
118+
expect(thrown.param).toBe(param);
119+
}
120+
121+
describe('ADR-0100 credential-column guard — filter and sort keys', () => {
122+
let ctx: Awaited<ReturnType<typeof buildEngine>>;
123+
beforeEach(async () => { ctx = await buildEngine(); });
124+
125+
const refusedWheres: Array<[string, EngineQueryOptions['where'], string]> = [
126+
['equality', { pin: 'p@ssw0rd!' }, 'pin'],
127+
['$eq with a value', { pin: { $eq: 'p@ssw0rd!' } }, 'pin'],
128+
['$ne with a value', { pin: { $ne: 'x' } }, 'pin'],
129+
['range', { pin: { $gt: 'o' } }, 'pin'],
130+
['$startsWith', { pin: { $startsWith: 'p@' } }, 'pin'],
131+
['$contains', { pin: { $contains: 'ssw' } }, 'pin'],
132+
['$in', { pin: { $in: ['a', 'b'] } }, 'pin'],
133+
['a secret column', { token: { $startsWith: 'secret:' } }, 'token'],
134+
['inside $or', { $or: [{ name: 'alpha' }, { pin: 'x' }] }, 'pin'],
135+
['inside $not', { $not: { pin: 'x' } }, 'pin'],
136+
['$not over a value operator', { pin: { $not: { $eq: 'x' } } }, 'pin'],
137+
['a cross-field comparand', { name: { $eq: { $field: 'pin' } } }, 'pin'],
138+
['the array form', [['pin', '=', 'p@ssw0rd!']], 'pin'],
139+
];
140+
141+
for (const [label, where, field] of refusedWheres) {
142+
it(`find refuses ${label} on a credential column, before the driver is asked`, async () => {
143+
await expectRefused(() => ctx.engine.find('cg_vault', { where, context: USER }), field, 'where');
144+
expect(ctx.calls).toEqual([]);
145+
});
146+
}
147+
148+
it('a context-less engine call is judged like any non-system caller', async () => {
149+
await expectRefused(() => ctx.engine.find('cg_vault', { where: { pin: 'x' } }), 'pin', 'where');
150+
});
151+
152+
it('refuses orderBy on a password and on a secret column', async () => {
153+
await expectRefused(
154+
() => ctx.engine.find('cg_vault', { orderBy: [{ field: 'pin', order: 'asc' }], context: USER }), 'pin', 'orderBy',
155+
);
156+
await expectRefused(
157+
() => ctx.engine.find('cg_vault', { orderBy: [{ field: 'token', order: 'desc' }], context: USER }), 'token', 'orderBy',
158+
);
159+
expect(ctx.calls).toEqual([]);
160+
});
161+
162+
it('refuses on findOne, count, aggregate (where and per-aggregation filter), update and delete', async () => {
163+
await expectRefused(() => ctx.engine.findOne('cg_vault', { where: { pin: 'x' }, context: USER }), 'pin', 'where');
164+
await expectRefused(() => ctx.engine.count('cg_vault', { where: { pin: 'x' }, context: USER }), 'pin', 'where');
165+
await expectRefused(
166+
() => ctx.engine.aggregate('cg_vault', { where: { pin: 'x' }, aggregations: [{ function: 'count', alias: 'n' }] }, { context: USER }),
167+
'pin', 'where',
168+
);
169+
await expectRefused(
170+
() => ctx.engine.aggregate('cg_vault', {
171+
aggregations: [{ function: 'count', alias: 'n', filter: { pin: { $gt: 'o' } } }],
172+
} satisfies EngineAggregateOptions, { context: USER }),
173+
'pin', 'aggregations[0].filter',
174+
);
175+
await expectRefused(
176+
() => ctx.engine.update('cg_vault', { name: 'z' }, { where: { pin: 'x' }, multi: true, context: USER } satisfies EngineUpdateOptions), 'pin', 'where',
177+
);
178+
await expectRefused(
179+
() => ctx.engine.delete('cg_vault', { where: { pin: 'x' }, multi: true, context: USER } satisfies EngineDeleteOptions), 'pin', 'where',
180+
);
181+
expect(ctx.calls).toEqual([]);
182+
});
183+
184+
it('refuses a credential column in an expand entry, judged against the target object', async () => {
185+
await expectRefused(
186+
() => ctx.engine.find('cg_vault', { expand: { holder: { object: 'cg_person', where: { pin: '1234' } } }, context: USER } satisfies EngineQueryOptions),
187+
'pin', 'expand.holder.where',
188+
);
189+
await expectRefused(
190+
() => ctx.engine.find('cg_vault', { expand: { holder: { object: 'cg_person', orderBy: [{ field: 'pin' }] } }, context: USER } satisfies EngineQueryOptions),
191+
'pin', 'expand.holder.orderBy',
192+
);
193+
expect(ctx.calls).toEqual([]);
194+
});
195+
196+
it('refuses a nested-relation condition on the related object’s credential column', async () => {
197+
await expectRefused(
198+
() => ctx.engine.find('cg_vault', { where: { holder: { pin: '1234' } }, context: USER }), 'pin', 'where',
199+
);
200+
});
201+
202+
const presence: Array<[string, EngineQueryOptions['where']]> = [
203+
['bare null', { pin: null }],
204+
['$null: true', { pin: { $null: true } }],
205+
['$null: false', { pin: { $null: false } }],
206+
['$exists', { pin: { $exists: true } }],
207+
['$empty', { pin: { $empty: false } }],
208+
['$eq: null', { pin: { $eq: null } }],
209+
['$ne: null', { token: { $ne: null } }],
210+
['$not over a presence check', { pin: { $not: { $null: true } } }],
211+
];
212+
for (const [label, where] of presence) {
213+
it(`serves a has-a-value check (${label})`, async () => {
214+
await expect(ctx.engine.find('cg_vault', { where, context: USER })).resolves.toBeInstanceOf(Array);
215+
expect(ctx.calls).toContain('find:cg_vault');
216+
});
217+
}
218+
219+
it('serves a system context unchanged — the server’s own match on a credential column', async () => {
220+
const rows = await ctx.engine.find('cg_vault', { where: { pin: 'p@ssw0rd!' }, context: SYSTEM });
221+
expect(rows.map((r: any) => r.id)).toEqual(['v1']);
222+
await expect(ctx.engine.find('cg_vault', { orderBy: [{ field: 'pin', order: 'asc' }], context: SYSTEM })).resolves.toHaveLength(1);
223+
await expect(ctx.engine.count('cg_vault', { where: { pin: 'p@ssw0rd!' }, context: SYSTEM })).resolves.toBe(1);
224+
});
225+
226+
it('leaves every non-credential field unaffected', async () => {
227+
const rows = await ctx.engine.find('cg_vault', {
228+
where: { name: 'alpha', holder: 'p1' }, orderBy: [{ field: 'name', order: 'asc' }], context: USER,
229+
});
230+
expect(rows.map((r: any) => r.id)).toEqual(['v1']);
231+
await expect(ctx.engine.count('cg_vault', { where: { name: 'alpha' }, context: USER })).resolves.toBe(1);
232+
});
233+
234+
it('a search never scans a credential column, even one the author listed as searchable', async () => {
235+
let seenWhere: unknown;
236+
const original = ctx.driver.find;
237+
ctx.driver.find = async (object: string, ast: any) => { seenWhere = ast?.where; return original.call(ctx.driver, object, ast); };
238+
await ctx.engine.find('cg_vault', { search: 'p@ss', context: USER });
239+
expect(JSON.stringify(seenWhere)).toContain('"name"');
240+
expect(JSON.stringify(seenWhere)).not.toContain('"pin"');
241+
});
242+
});
243+
244+
describe('isCredentialPresenceCondition', () => {
245+
it('accepts only presence shapes', () => {
246+
expect(isCredentialPresenceCondition(null)).toBe(true);
247+
expect(isCredentialPresenceCondition({ $null: true })).toBe(true);
248+
expect(isCredentialPresenceCondition({ $ne: null })).toBe(true);
249+
expect(isCredentialPresenceCondition({})).toBe(false);
250+
expect(isCredentialPresenceCondition('x')).toBe(false);
251+
expect(isCredentialPresenceCondition({ $null: 'yes' })).toBe(false);
252+
expect(isCredentialPresenceCondition({ $null: true, $eq: 'x' })).toBe(false);
253+
expect(isCredentialPresenceCondition({ $exists: true, $empty: false })).toBe(true);
254+
});
255+
});

0 commit comments

Comments
 (0)