Skip to content

Commit 1f1eda4

Browse files
fix(objectql): a file field reads the same whether or not the projection lists id (#26938)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQccQdv8fPv2TX5Av6ZoDHr)_ Fixes #26672 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:引擎的 `find` / `findOne` 在调用方投影里点名了文件字段却没点名 `id` 时,内部多读一列 `id`,读完后再把它从答案里裁掉(与公式字段扩宽列的裁剪同一处)。 - 为什么改:无权读 `sys_file` 的读者,文件元数据按"拥有该文件的记录"的判决来给,而这个判决按记录 `id` 匹配。投影漏了 `id`,判决就找不到记录,于是有权读的应用管理员在 `select=FILE_FIELD` 时拿到 `metadataRefused: true`,加上 `id` 却拿到完整值(hotclm 报告)。 - 风险与回滚:只影响点名文件字段且没点名 `id` 的投影,多读一列、答案列不变;无权读者照样被拒(两种投影都钉了)。回滚即还原 `packages/objectql/src/engine.ts` 的一处函数和两处调用。 - 先看:`packages/objectql/src/engine.ts`(`withFileOwnerKey` 及 `find` / `findOne` 两处调用)、`packages/rest/src/data-file-field-projection-without-id.test.ts`、`packages/objectql/src/engine-file-hydrate-parent-derived.test.ts`(改写了原先钉住旧行为的用例)。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/data-file-field-projection-without-id.test.ts`,预期 6 个用例全绿;在 main 上同一命令 3 红 3 绿(红的是三个"不带 id"的门)。 - 需要你做的:无,席位落地。 ## What changes - **`packages/objectql/src/engine.ts`**: a new module helper `withFileOwnerKey(schema, requestedFields, planned)` runs right after `planFormulaProjection` in both `find` and `findOne`. When the caller's explicit projection names a file-reference field (`FILE_REFERENCE_TYPES`) and the planned projection does not carry `id`, it appends `id` to what the driver is asked for and adds `id` to the widened set. The existing `withoutFormulaWidening` / `rowsWithoutFormulaWidening` cut at the `return` (after the middleware chain) then trims it, so the answer carries exactly the columns the caller named. Every consumer in between (formulas, `expand`, file-reference hydration, `afterFind` hooks, the secret mask, the middlewares' post-phase) reads the row with its `id`, which is what `resolveFileReferences` → `serveRefusedFileHydration` → `readParentDerivedFiles` need to match a file to the record that owns it. - The parent-derived verdict, the refused marker and the caller's own `sys_file` read are untouched: a reader the field-owned verdict refuses still gets `{ id, metadataRefused: true }`, a copied-in id is still refused on the record it was copied into. - Docblocks: `readParentDerivedFiles` no longer cites "a projection that left out `id`" as a reason for an empty map, and `withoutFormulaWidening` names the second thing it cuts. - **Changeset** `@objectstack/objectql` `patch`. ### Which doors share the fix The fix is in the engine's two read verbs, so every door that reads through them shares it; no door holds its own copy of the hydration. | Door | Path to the hydration | Shares the fix | |---|---|---| | REST `GET /api/v1/data/:object?select=` | `crud-routes.ts` → `protocol.findData` → `engine.find` | yes (pinned) | | REST `POST /api/v1/data/:object/query` `fields` | `crud-routes.ts` → `protocol.findData` → `engine.find` | yes (pinned) | | REST `GET /api/v1/data/:object/:id?select=` | `crud-routes.ts` → `protocol.getData` → `engine.findOne` | yes (pinned) | | Dispatcher data domain (list, query, by-id) | `runtime/src/domains/data.ts` → `action-execution.ts` `callData` → `protocol.findData` / `protocol.getData` → the same engine verbs | yes (same call, read not re-pinned) | | `expand` sub-reads | `expandRelatedRecords` → `this.find` with the join key `id` already added to a nested projection | already carried `id`; now also covered by the engine rule | | In-process `engine.find` / `engine.findOne` with `fields` | direct | yes (pinned in objectql and service-storage) | ## Tests - **New** `packages/rest/src/data-file-field-projection-without-id.test.ts` on the composed REST harness (`RestServer` → `ObjectStackProtocolImplementation` → `ObjectQL` → `SqlDriver` on better-sqlite3), a reader refused `sys_file` read whose field-owned verdict allows it: - `GET /data/:object?select=attachment`, `POST /data/:object/query { fields: ['attachment'] }`, `GET /data/:object/:id?select=attachment`: the full file value, and no `id` key on the row. - CONTROL `select=id,attachment` (all three doors): unchanged. - CONTROL a caller the verdict refuses: `{ id, metadataRefused: true }` with and without `id`, all three doors. - **Changed** `packages/objectql/src/engine-file-hydrate-parent-derived.test.ts`: the case "a read whose projection leaves out id cannot name its owner record, so every id keeps the marker" pinned exactly the defect this card fixes; it now pins that such a read gets the same verdict as `fields: ['id', 'attachment']` (owner hydrates, a copied-in id stays refused, rows carry only `attachment`), on `find` and `findOne`, plus a new refused-reader control. - **Added case** in `packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts`, against the REAL field-owned verdict `StorageServicePlugin` hands the engine: an id-less projection hydrates the owned file and the delegate-allowed file, and keeps the marker for the delegate's deny and for a copied-in id the reader cannot read. ## Acceptance notes - The `id` is read for any explicit projection naming a file field, whichever arm the hydration takes (the caller's own `sys_file` read does not need it). Narrowing it to the refused arm would need the admission answer before the driver read; one extra column on such reads was preferred over a second planning path. - An `afterFind` hook on such a read now sees `id` on the row, and has it cut afterwards, exactly as for a projection naming a formula field (#22300). ## Verification All readings below are on head `6ea02bc0` unless a line says otherwise. <details><summary>Red first on main, green after (REST door test)</summary> - On `main` (`1708115e`, objectql `dist/` built from main): `pnpm --filter @objectstack/rest exec vitest run src/data-file-field-projection-without-id.test.ts` answered **3 failed, 3 passed**. The three reds are the three id-less doors: `expected [ { attachment: { …(2) } } ] to deeply equal [ { attachment: { …(5) } } ]` (list and query) and `expected { id: 'f_26672_owned', …(1) } to deeply equal { id: 'f_26672_owned', …(4) }` (by-id). The three greens are the refusal it starts from, the `select=id,…` control and the refused-reader control. - After the fix (objectql rebuilt): **6 passed (6)**. </details> <details><summary>Ablation (objectql suite, resolves src)</summary> `engine.ts` replaced on disk with its `main` blob under a restoring `trap` (mutant `withFileOwnerKey` count 0): `engine-file-hydrate-parent-derived.test.ts` answered **1 failed, 16 passed**, the red being the id-less case; the refused-reader control stayed green, as it should. Restore proven by the HEAD blob hash and an empty `git diff HEAD` (`RESTORED-OK`). </details> <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/objectql test`: Test Files 475 passed (475), Tests 8850 passed (8850). - `pnpm --filter @objectstack/rest test`: Test Files 329 passed (329), Tests 6519 passed, 326 skipped (6845). - `pnpm --filter @objectstack/service-storage test`: Test Files 70 passed (70), Tests 1258 passed (1258). - `pnpm --filter @objectstack/objectql --filter @objectstack/rest --filter @objectstack/service-storage run typecheck`: exit 0 (each package's `check:test-typecheck` OK). - Those suites ran on `845325d3`; the only later change is one `as any` removed from the new REST test, re-run on `6ea02bc0` with `rest` typecheck and the file (6 passed). - `eslint --no-inline-config` over `packages/objectql/src`, the new REST test and `packages/services/service-storage/src`: exit 0. </details> <details><summary>Derived gates (dispatch-gates --commands, 67 families)</summary> Run on `6ea02bc0`, every exit code captured before any pipe: **67 of 67 exit 0**. `dispatch-gates --ran` reconciles: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. The first run on `845325d3` was red on `check:query-options-erasure` (the new test's direct `sys_file` read carried an `as any`, test surface 236 → 237); the cast was removed, so the test surface is back at 236. </details> `test:repo` (the vitest `repo` projects) was not run locally: the diff touches no file in any `vitest.repo-tests.json` and the derived list names none; CI's Test Core runs it. Dogfood battery: not run. The title carries no `!` and `Clause-②` has no `(narrowing)` arm. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nso8iiU7cfQSEAkX5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1539aad commit 1f1eda4

5 files changed

Lines changed: 320 additions & 11 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a file field reads the same whether or not the projection lists `id`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A reader who may not read `sys_file` (every member whose application grants no `sys_file` read) is served a file field by the verdict of the record that owns the file, matched on that record's `id`. A read whose projection left `id` out, such as `GET /api/v1/data/OBJECT?select=FILE_FIELD`, gave that verdict no record key, so a file the reader may read came back as `{ id, metadataRefused: true }`, while `select=id,FILE_FIELD` answered the full `{ id, name, size, mimeType, url }`.
11+
12+
`find` and `findOne` now read `id` for any projection that names a file field and cut it back off the answer, the same way a formula's widened columns are cut. So:
13+
14+
- A permitted reader gets the full file value with or without `id` in `select` / `fields`, on `GET /data/:object`, `POST /data/:object/query` and `GET /data/:object/:id`.
15+
- The answer still carries exactly the columns the caller named: no `id` is added to a row that did not ask for it.
16+
- A reader the owning record's verdict refuses is still answered `{ id, metadataRefused: true }`, with or without `id`.

‎packages/objectql/src/engine-file-hydrate-parent-derived.test.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,14 +384,35 @@ describe('a refused sys_file read still hydrates a file the record being read ow
384384
expect(rows.d5.attachment).toEqual(asFileReader.d5.attachment);
385385
});
386386

387-
it('a read whose projection leaves out id cannot name its owner record, so every id keeps the marker', async () => {
387+
it('[#26672] a read whose projection leaves out id still names its owner record — and answers no id it did not name', async () => {
388388
const { engine, calls } = await boot();
389389

390390
const rows = await engine.find('doc', { fields: ['attachment'], context: READER_CTX });
391+
const withId = byId(await engine.find('doc', { fields: ['id', 'attachment'], context: READER_CTX }));
392+
393+
// The same verdict as the projection that names `id`: d1 owns OWNED and
394+
// hydrates it, d3 holds a copy of it and keeps the marker.
395+
expect(rows).toContainEqual({ attachment: hydrated(OWNED, 'owned.pdf') });
396+
expect(rows).toContainEqual({ attachment: refused(OWNED) });
397+
expect(withId.d1.attachment).toEqual(hydrated(OWNED, 'owned.pdf'));
398+
expect(withId.d3.attachment).toEqual(refused(OWNED));
399+
for (const row of rows) expect(Object.keys(row)).toEqual(['attachment']);
400+
expect(calls[0]?.ownerIds).toContain('d1');
401+
402+
const one = await engine.findOne('doc', { where: { id: 'd1' }, fields: ['attachment'], context: READER_CTX });
403+
expect(one).toEqual({ attachment: hydrated(OWNED, 'owned.pdf') });
404+
});
391405

392-
expect(rows.find((r: any) => r.attachment?.id === OWNED && !r.attachment.metadataRefused)).toBeUndefined();
393-
expect(rows.filter((r: any) => r.attachment != null)).toContainEqual({ attachment: refused(OWNED) });
394-
expect(calls).toEqual([]);
406+
it('[#26672] …and a reader the verdict refuses is still refused when the projection leaves out id', async () => {
407+
const { engine } = await boot({ allow: [] });
408+
409+
const rows = await engine.find('doc', { fields: ['attachment'], context: READER_CTX });
410+
expect(rows.filter((r: any) => r.attachment != null)).toEqual(
411+
[refused(OWNED), refused(OWNED), refused(TOMBSTONE_OWNED), refused(PENDING_OWNED), refused(OTHER_OBJECT)]
412+
.map((attachment) => ({ attachment })),
413+
);
414+
const one = await engine.findOne('doc', { where: { id: 'd1' }, fields: ['attachment'], context: READER_CTX });
415+
expect(one).toEqual({ attachment: refused(OWNED) });
395416
});
396417

397418
it('the happy path costs no warn; the one debug line counts what the verdict allowed', async () => {

‎packages/objectql/src/engine.ts‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,8 @@ function planFormulaProjection(
17971797
* on driver-sql (a projection is exactly the named columns — no `id` unless it
17981798
* is named) and pinned against it in `engine-formula-projection-trim.test.ts`.
17991799
* `id` is cut like any other widened column when the caller did not name it.
1800+
* [#26672] The `id` {@link withFileOwnerKey} adds for a file field's owner
1801+
* verdict is cut the same way.
18001802
*
18011803
* ## Where it runs, and why there
18021804
*
@@ -1834,6 +1836,35 @@ function rowsWithoutFormulaWidening<T>(rows: T, widened: ReadonlySet<string> | u
18341836
return rows.map((row) => withoutFormulaWidening(row, widened)) as unknown as T;
18351837
}
18361838

1839+
/**
1840+
* [#26672] A projection that names a file field reads the record's `id` too,
1841+
* and the key is cut back off the answer like the formula widening is.
1842+
*
1843+
* File-reference hydration judges a reader refused `sys_file` read by the
1844+
* record that OWNS each file (#22637, `readParentDerivedFiles`), matched on
1845+
* that record's `id`. A projection that left `id` out handed it rows with no
1846+
* key, so a file the reader may read answered `{ id, metadataRefused: true }`
1847+
* to `select=<file field>` and the full value to `select=id,<file field>`. The
1848+
* verdict must not depend on whether the caller listed `id`, so the driver is
1849+
* asked for it here and {@link withoutFormulaWidening} trims it at the
1850+
* `return`: the caller still gets exactly the columns it named, and a reader
1851+
* the verdict refuses is still refused.
1852+
*
1853+
* Touches only an explicit projection that names a file field and not `id`;
1854+
* a projection the formula planner widened already carries `id`.
1855+
*/
1856+
function withFileOwnerKey(
1857+
schema: any,
1858+
requestedFields: string[] | undefined,
1859+
planned: { projected?: string[]; widened?: string[] },
1860+
): { projected?: string[]; widened?: string[] } {
1861+
if (!schema?.fields || !Array.isArray(requestedFields) || requestedFields.length === 0) return planned;
1862+
const projected = planned.projected ?? requestedFields;
1863+
if (projected.includes('id')) return planned;
1864+
if (!requestedFields.some((f) => FILE_REFERENCE_TYPES.has((schema.fields as any)[f]?.type))) return planned;
1865+
return { projected: [...projected, 'id'], widened: [...(planned.widened ?? []), 'id'] };
1866+
}
1867+
18371868
/**
18381869
* [#7095] ORDER BY a field whose value is computed on read — refused HERE, on
18391870
* the engine's own public boundary, and no longer only at the REST ingress.
@@ -12951,9 +12982,11 @@ export class ObjectQL implements IObjectQLEngine {
1295112982
* Returns, per allowed file id, the owning record's id and its servable row
1295212983
* — `null` when the row is owned and allowed but not servable, which the
1295312984
* caller reads as the bare id a `sys_file` reader would see. An id absent
12954-
* from the map keeps the refused marker. Unwired, no record ids (a
12955-
* projection that left out `id`), or a failure: an empty map — every id
12956-
* stays refused, the answer before this existed.
12985+
* from the map keeps the refused marker. Unwired, no record ids, or a
12986+
* failure: an empty map — every id stays refused, the answer before this
12987+
* existed. [#26672] `find` / `findOne` read `id` for any projection that
12988+
* names a file field ({@link withFileOwnerKey}), so a caller's projection
12989+
* that leaves `id` out does not land here.
1295712990
*/
1295812991
private async readParentDerivedFiles(
1295912992
objectName: string,
@@ -13502,10 +13535,12 @@ export class ObjectQL implements IObjectQLEngine {
1350213535
// driver, where the #3821 ladder answered it with EVERY field.
1350313536
assertProjectionHasNoDottedPaths(object, 'find', _findSchema, ast.fields);
1350413537
const _findFormula = planFormulaProjection(_findSchema, ast.fields);
13505-
if (_findFormula.projected) ast.fields = _findFormula.projected;
13538+
// [#26672] …and a file field's owner key — see `withFileOwnerKey`.
13539+
const _findProjection = withFileOwnerKey(_findSchema, ast.fields, _findFormula);
13540+
if (_findProjection.projected) ast.fields = _findProjection.projected;
1350613541
// [#22300] The columns that widening added, cut back off the answer at the
1350713542
// `return` — see `withoutFormulaWidening`.
13508-
const _findWidened = _findFormula.widened ? new Set(_findFormula.widened) : undefined;
13543+
const _findWidened = _findProjection.widened ? new Set(_findProjection.widened) : undefined;
1350913544

1351013545
// Drop any requested PLAIN field that doesn't exist on the schema.
1351113546
// Without this, drivers (notably SqlDriver) emit `SELECT unknown_col
@@ -13821,9 +13856,11 @@ export class ObjectQL implements IObjectQLEngine {
1382113856
// [#7642] Caller's own projection, before planning rewrites it — see `find`.
1382213857
const _findOneRequestedFields = Array.isArray(ast.fields) ? [...ast.fields] : undefined;
1382313858
const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields);
13824-
if (_findOneFormula.projected) ast.fields = _findOneFormula.projected;
13859+
// [#26672] Same as `find`: a file field's owner key is read too.
13860+
const _findOneProjection = withFileOwnerKey(_findOneSchema, ast.fields, _findOneFormula);
13861+
if (_findOneProjection.projected) ast.fields = _findOneProjection.projected;
1382513862
// [#22300] Same as `find`: what the widening added is cut at the `return`.
13826-
const _findOneWidened = _findOneFormula.widened ? new Set(_findOneFormula.widened) : undefined;
13863+
const _findOneWidened = _findOneProjection.widened ? new Set(_findOneProjection.widened) : undefined;
1382713864

1382813865
// Drop unknown PLAIN fields — see the equivalent block in `find()` for
1382913866
// the rationale, and for why this tolerance is plain-columns-only ([#7589]
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26672] A file field's read verdict does not depend on whether the caller's
5+
* projection lists `id`.
6+
*
7+
* ## The defect
8+
*
9+
* A reader refused `sys_file` read (every member whose application grants no
10+
* `sys_file` read) is served a file field by the parent-derived verdict of
11+
* #22637: a file whose `ref_object` / `ref_id` name the record being read
12+
* hydrates when the download door's field-owned verdict allows this reader.
13+
* That verdict is judged per RECORD, by the record's `id`. A projection that
14+
* left `id` out (`GET /data/:object?select=attachment`) handed the hydration
15+
* rows with no `id`, so no file could be matched to its owner and every one
16+
* answered `{ id, metadataRefused: true }` — while `select=id,attachment`
17+
* answered the full `{ id, name, size, mimeType, url }` to the same reader.
18+
*
19+
* ## What is pinned, on the composed REST harness
20+
*
21+
* `RestServer` → `ObjectStackProtocolImplementation` → `ObjectQL` → a real
22+
* `SqlDriver` on better-sqlite3, through the three read doors that reach the
23+
* hydration: `GET /data/:object` (`select`), `POST /data/:object/query`
24+
* (`fields`) and `GET /data/:object/:id` (`select`, the `findOne` door).
25+
*
26+
* - The permitted reader gets the full value with or without `id`, and no
27+
* `id` key the caller did not name.
28+
* - CONTROL the same projection with `id` answers as it did.
29+
* - CONTROL a reader the field-owned verdict refuses is still refused, with
30+
* or without `id`: nothing an unauthorised caller sees is widened.
31+
*
32+
* ## The stand-ins, and only these
33+
*
34+
* - The security layer's `sys_file` refusal: an engine middleware that
35+
* throws `PERMISSION_DENIED` for every non-system `sys_file` read, the
36+
* shape plugin-security's `file-field-hydration-refused.test.ts` measures
37+
* on the real `SecurityPlugin`.
38+
* - The field-owned verdict the storage plugin hands the engine: one that
39+
* allows the reader and nobody else. The real verdict's agreement with the
40+
* download door is pinned in service-storage's
41+
* `field-owned-file-hydration-door-agreement.test.ts`.
42+
*
43+
* `@objectstack/objectql` resolves through its `exports` to `dist/` here, so
44+
* an ablation of the engine is observed only after a rebuild of that package.
45+
*/
46+
47+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
48+
import { ObjectQL } from '@objectstack/objectql';
49+
import { SqlDriver } from '@objectstack/driver-sql';
50+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
51+
import { RestServer } from './rest-server';
52+
import { installDefaultOrganization } from './default-organization.testkit.js';
53+
54+
const OBJECT = 'rq_filed_doc';
55+
const SYS = { isSystem: true };
56+
const READER = 'u_reader';
57+
const STRANGER = 'u_stranger';
58+
const FILE_ID = 'f_26672_owned';
59+
60+
const hydrated = {
61+
id: FILE_ID, name: 'contract.pdf', size: 2048, mimeType: 'application/pdf', url: `/api/v1/storage/files/${FILE_ID}`,
62+
};
63+
const refused = { id: FILE_ID, metadataRefused: true };
64+
65+
function createMockServer() {
66+
const noop = () => {};
67+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
68+
}
69+
70+
function makeRes() {
71+
const res: any = {
72+
write: () => true, end: () => {},
73+
header: () => res,
74+
status: (code: number) => { res._status = code; return res; },
75+
json: (body: any) => { res._json = body; return res; },
76+
};
77+
return res;
78+
}
79+
80+
const refusal = () =>
81+
Object.assign(new Error('You do not have permission to perform this action.'), {
82+
name: 'PermissionDeniedError', code: 'PERMISSION_DENIED', status: 403, statusCode: 403,
83+
});
84+
85+
const rowsOf = (res: any): Array<Record<string, unknown>> => res._json?.records ?? res._json?.data ?? res._json;
86+
const recordOf = (res: any): Record<string, unknown> => res._json?.record ?? res._json;
87+
88+
describe('[#26672] a file field reads the same with or without `id` in the projection', () => {
89+
let engine: ObjectQL;
90+
let caller = READER;
91+
let list: (query: Record<string, unknown>) => Promise<any>;
92+
let query: (body: Record<string, unknown>) => Promise<any>;
93+
let getById: (id: string, query: Record<string, unknown>) => Promise<any>;
94+
95+
beforeAll(async () => {
96+
engine = new ObjectQL();
97+
engine.registerDriver(
98+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any,
99+
true,
100+
);
101+
await engine.init();
102+
engine.registry.registerObject({
103+
name: OBJECT,
104+
label: 'Filed doc',
105+
fields: {
106+
title: { name: 'title', type: 'text' },
107+
attachment: { name: 'attachment', type: 'file' },
108+
},
109+
} as any);
110+
// The storage service's `sys_file`, as far as the hydration reads it.
111+
engine.registry.registerObject({
112+
name: 'sys_file',
113+
label: 'System File',
114+
fields: {
115+
key: { name: 'key', type: 'text' },
116+
name: { name: 'name', type: 'text' },
117+
mime_type: { name: 'mime_type', type: 'text' },
118+
size: { name: 'size', type: 'number' },
119+
status: { name: 'status', type: 'text' },
120+
ref_object: { name: 'ref_object', type: 'text' },
121+
ref_id: { name: 'ref_id', type: 'text' },
122+
ref_field: { name: 'ref_field', type: 'text' },
123+
},
124+
} as any);
125+
await engine.syncSchemas();
126+
await installDefaultOrganization(engine);
127+
128+
await engine.insert('sys_file', {
129+
id: FILE_ID, key: `files/${FILE_ID}.pdf`, name: 'contract.pdf', mime_type: 'application/pdf', size: 2048,
130+
status: 'committed', ref_object: OBJECT, ref_id: 'd1', ref_field: 'attachment',
131+
} as any, { context: SYS } as any);
132+
await engine.insert(OBJECT, { id: 'd1', title: 'Signed', attachment: FILE_ID } as any, { context: SYS } as any);
133+
134+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
135+
if (!opCtx.context?.isSystem) throw refusal();
136+
return next();
137+
}, { object: 'sys_file' });
138+
engine.registerFieldOwnedFileReadAuthorizer(async (_object, ownerIds, context) =>
139+
new Set(context?.userId === READER ? ownerIds : []));
140+
141+
const protocol = new ObjectStackProtocolImplementation(engine as any);
142+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
143+
(rest as any).resolveExecCtx = async () => ({ userId: caller });
144+
rest.registerRoutes();
145+
const routes = rest.getRoutes();
146+
const route = (method: string, path: string) => {
147+
const found = routes.find((r: any) => r.method === method && r.path === path);
148+
expect(found).toBeDefined();
149+
return found!;
150+
};
151+
const listRoute = route('GET', '/api/v1/data/:object');
152+
const queryRoute = route('POST', '/api/v1/data/:object/query');
153+
const getRoute = route('GET', '/api/v1/data/:object/:id');
154+
list = async (q) => {
155+
const res = makeRes();
156+
await listRoute.handler({ params: { object: OBJECT }, query: q } as any, res);
157+
return res;
158+
};
159+
query = async (body) => {
160+
const res = makeRes();
161+
await queryRoute.handler({ params: { object: OBJECT }, body } as any, res);
162+
return res;
163+
};
164+
getById = async (id, q) => {
165+
const res = makeRes();
166+
await getRoute.handler({ params: { object: OBJECT, id }, query: q } as any, res);
167+
return res;
168+
};
169+
});
170+
171+
afterAll(async () => {
172+
try { await engine?.destroy(); } catch { /* noop */ }
173+
});
174+
175+
const as = (user: string) => { caller = user; };
176+
177+
it('the refusal it starts from: the reader may not read sys_file directly', async () => {
178+
const direct = await engine.find('sys_file', { context: { userId: READER } }).then(() => null, (e: any) => e);
179+
expect({ code: direct?.code, status: direct?.statusCode ?? direct?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
180+
});
181+
182+
it('GET /data/:object?select=<file field>: a permitted reader gets the full value, and no `id` it did not name', async () => {
183+
as(READER);
184+
const res = await list({ select: 'attachment' });
185+
expect(res._status ?? 200).toBe(200);
186+
expect(rowsOf(res)).toEqual([{ attachment: hydrated }]);
187+
});
188+
189+
it('POST /data/:object/query with fields: [<file field>]: the same', async () => {
190+
as(READER);
191+
const res = await query({ fields: ['attachment'] });
192+
expect(res._status ?? 200).toBe(200);
193+
expect(rowsOf(res)).toEqual([{ attachment: hydrated }]);
194+
});
195+
196+
it('GET /data/:object/:id?select=<file field> (the findOne door): the same', async () => {
197+
as(READER);
198+
const res = await getById('d1', { select: 'attachment' });
199+
expect(res._status ?? 200).toBe(200);
200+
const record = recordOf(res);
201+
expect(record.attachment).toEqual(hydrated);
202+
expect(record).not.toHaveProperty('id');
203+
});
204+
205+
it('CONTROL select=id,<file field> answers as it did', async () => {
206+
as(READER);
207+
expect(rowsOf(await list({ select: 'id,attachment' }))).toEqual([{ id: 'd1', attachment: hydrated }]);
208+
expect(rowsOf(await query({ fields: ['id', 'attachment'] }))).toEqual([{ id: 'd1', attachment: hydrated }]);
209+
expect(recordOf(await getById('d1', { select: 'id,attachment' }))).toMatchObject({ id: 'd1', attachment: hydrated });
210+
});
211+
212+
it('CONTROL a reader the field-owned verdict refuses is still refused, with or without `id`', async () => {
213+
as(STRANGER);
214+
expect(rowsOf(await list({ select: 'attachment' }))).toEqual([{ attachment: refused }]);
215+
expect(rowsOf(await list({ select: 'id,attachment' }))).toEqual([{ id: 'd1', attachment: refused }]);
216+
expect(rowsOf(await query({ fields: ['attachment'] }))).toEqual([{ attachment: refused }]);
217+
expect(rowsOf(await query({ fields: ['id', 'attachment'] }))).toEqual([{ id: 'd1', attachment: refused }]);
218+
const byId = recordOf(await getById('d1', { select: 'attachment' }));
219+
expect(byId.attachment).toEqual(refused);
220+
expect(byId).not.toHaveProperty('id');
221+
expect(recordOf(await getById('d1', { select: 'id,attachment' })).attachment).toEqual(refused);
222+
});
223+
});

0 commit comments

Comments
 (0)