Skip to content

Commit 9112bcd

Browse files
fix(objectql): file-field hydration asks canReadObject before reading sys_file, so a record read logs no access denial (#24434)
Fixes #24335 Clause-②: no (no accept set or public surface moves: the served bodies and every refusal are unchanged; owes the seat's review of record at `TIER_DEFAULT` before the queue) ## What changed `resolveFileReferences` (`packages/objectql/src/engine.ts`) read `sys_file` as the caller and took the refused arm on the throw. A member with read on the record's object and without `sys_file` read therefore made a `sys_file` access attempt on every by-id and list read, and plugin-security logged `[Security] Access denied: operation 'find' on object 'sys_file' is not permitted for positions [org_member, everyone]` at WARN for each one. The engine now **asks before it reads**, as the parent-record read gate does ("A skipped object is not an access attempt", `parent-record-read-gate.ts`): - New private `callerMayReadSysFile` asks the registered security service's `canReadObject('sys_file', caller)`. It goes through the slot the engine already resolves (`resolveReadableIdsSecurity`, wired by `ObjectQLPlugin`) and uses the caller's envelope minus its operation-private keys (`withoutOperationPrivateKeys`, as `readParentDerivedFiles` already does). No new security API. - `false`: the refused arm is served without the read. - `true`: the caller's read goes ahead exactly as before, and the middleware still decides. - Unasked (no usable security service, no `canReadObject`, or an ask that throws): the read goes to the middleware as before. The ask can only narrow what is read. - The refused arm moves verbatim into `serveRefusedFileHydration`. It is shared by the ask and by the existing `PERMISSION_DENIED` catch, which stays for any refusal the admission did not foresee. - ⛔ `plugin-security` is not touched: its refusal, its log line and its level are unchanged. ## Why this shape (four axes) - **Real business need:** measured. Every member of a deployment whose app grants no `sys_file` read hits this on every record read (the WARN reproduced below on the booted composition). - **Long-term soundness:** engine-only. The security layer stays the single judge of every read it sees, and `canReadObject` is its own object-level gate, arm for arm. The alternative, a context flag marking the hydration read internal, would cross into `plugin-security` (`domain:services`) and add a bypass marker. - **Guarding against AI mistakes:** no bypass marker means there is nothing an author or agent can set to mute a denial. The refused value contract (`FileRefusedValueSchema`) is unchanged. - **Startup scope:** no new API, option, error code or gate. ## Measurements (M, booted composition) New dogfood pin `packages/qa/dogfood/test/file-field-hydration-no-denial.dogfood.test.ts`. It runs on a real org-bound `bootStack` with the real `SecurityPlugin`, the real `StorageServicePlugin` (local adapter, real bytes), and one record object holding a field-owned file. "Before" is measured on the same boot with `canReadObject` withdrawn from the service, which is the engine's pre-change path. | read | reader | before: security denials | after: security denials | body | |---|---|---|---|---| | `GET /data/fhn_doc/ID` | member, no `sys_file` read | 1 (`on object 'sys_file'`) | **0** | byte-identical (`res.text()`), file hydrated | | `GET /data/fhn_doc` | member, no `sys_file` read | 1 | **0** | byte-identical, file hydrated | | `GET /data/fhn_doc/ID` and list | reader with `sys_file` read | 0 | 0 | byte-identical, same hydrated value | | `GET /data/sys_file/ID` | member | - | 1 | `403 PERMISSION_DENIED`, unchanged | Run on `df035c834`: `pnpm --filter @objectstack/dogfood exec vitest run test/file-field-hydration-no-denial.dogfood.test.ts`, 5 passed. **Ablation (one, of the ask):** done with `scripts/ablation-replace.mjs` (anchor hit once, blob `0186fa3c` → `b21c19e7`). The ask was disabled, objectql was rebuilt, and `ablation-dist-preflight` found the marker in 4 `dist/` files. The two member WARN-count pins went red with exactly the issue's line: `expected [ "[Security] Access denied: operation 'find' on object 'sys_file' is not permitted for positions [org_member, everyone]" ] to deeply equal []` (2 failed, 3 passed). Restore: blob back to `0186fa3c`, `git diff HEAD` empty, rebuilt, `--absent` preflight clean, 5 passed. **Cost of the ask:** one `canReadObject` per hydration call, which is once per request for both by-id and list (measured: 1 ask per by-id read, 1 per list read), never per row. It is a cached permission check with no store read: 6.4 µs per ask (mean of 300 iterations on a shared box). The refused `sys_file` read it replaces cost about 1.2 ms. A reader with `sys_file` read pays the extra ask. End-to-end by-id latency on the shared box was within noise. ## Pins beside it `packages/objectql/src/engine-file-hydrate-refused.test.ts` reuses that file's existing driver double, so no new double is added. Its `boot` gains a `security` slot option and an attempts log on the refusing middleware. New cases: - A refused admission makes no `sys_file` read and serves the refused marker. The ask is about `sys_file`, carries the caller's envelope and no `__` keys. - The list and by-id bodies are byte-identical to the unasked engine's, and that comparison is armed: the unasked engine did attempt the read. - A direct `sys_file` read is still `PERMISSION_DENIED` / 403 and still reaches the security layer. - Controls: an admitted reader is hydrated; an admitted read that is still refused takes the same arm; with no `canReadObject`, or an ask that throws, the engine reads as before; a record with no file id asks nothing. Pin sweep: the existing pins on the refused arm and its `debug` line (`engine-file-hydrate-refused.test.ts`, `engine-file-hydrate-parent-derived.test.ts`, plugin-security `file-field-hydration-refused.test.ts`, service-storage `field-owned-file-hydration-door-agreement.test.ts`) wire no security service into the slot, so they still exercise the read-then-refuse arm, and they stay green unmodified. ## Verification record (head `df035c834`, after merging `origin/main` `e22defb13`) - `pnpm --filter @objectstack/objectql typecheck`: exit 0. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. Both were run before and after the merge. - `pnpm --filter @objectstack/objectql exec vitest run`: 445 files, 8366 tests passed. - Dogfood pin: 5 passed (after the merge, on rebuilt `objectql`, `plugin-auth` and `platform-objects`). - `node scripts/pm/dispatch-gates.mjs --commands` (no paths), 74 families. All 72 runnable families exit 0 on `df035c834`. `--ran` reconciliation: `74 derived, 72 run, 2 NOT-MEASURED, 0 UNRUN`. - NOT MEASURED: `check:query-options-erasure` (repo-wide eslint scan). The first local attempt was in flight when the container killed the worker (exit 137), and the retry hit the 280 s local window. CI's `Lint & Repo Gates` runs it. - NOT MEASURED: `check:type-check-debt` (repo-wide tsc census). It hit the same window and is left to CI. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 unbuilt packages). It was built for and re-run, and passes: 111 require entry points load. - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: OK, 0 lost. ## Acceptance notes - `canReadObject` mirrors the middleware's object-level arms but not its public-form grant (`publicFormGrant`), which admits `find` only on the form's own target object. A form targeting `sys_file` is not a realistic declaration, so no such caller reaches this path. Noted, not filed. - Locally, an interrupted gate run left `packages/spec/dist` without its `.d.ts`, and a spec rebuild restored it. This was local build state, not a finding. - The dogfood pin is declared to `domain:cli` at review, per the claim. --- _Generated by [Claude Code](https://claude.ai/code/session_01YXbt8cZ1Adcz5iV8AdwBXJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d12b23 commit 9112bcd

4 files changed

Lines changed: 541 additions & 39 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a record read that hydrates a file field no longer makes a `sys_file` access attempt for a reader without `sys_file` read
6+
7+
The engine expands a stored file id by reading `sys_file`, and it issued that read as the caller. A member who may read the record but not `sys_file` (the platform default) was refused it, and the security layer logged `[Security] Access denied: operation 'find' on object 'sys_file'` at WARN on every by-id and list read by that member, although the body served was right. Those lines buried the real denials in the operator's log.
8+
9+
The engine now asks the security service's `canReadObject('sys_file')` first, the same object-level read gate the security middleware applies, and serves a refused caller the field-owned answer without the read. Nothing else changes:
10+
11+
- The served body is byte-identical, by id and by list, for a reader with and without `sys_file` read. A file owned by the record still hydrates, and every other id still reads `{ id, metadataRefused: true }`.
12+
- A direct `GET /api/v1/data/sys_file/:id` by the same reader still answers `403 PERMISSION_DENIED` and still logs its denial.
13+
- With no security service, or one that offers no `canReadObject`, the engine reads as the caller, as before.
14+
15+
The ask is one cached permission check per read, not per row.
16+
17+
Clause-②: no (no accept set or public surface moves: the served bodies and every refusal are unchanged; owes the seat's review of record at `TIER_DEFAULT` before the queue)

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

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,18 @@
2727
* `file-field-hydration-refused.test.ts`; this file holds the seam's own
2828
* branches with a fake DRIVER (not a fake engine) and an engine middleware
2929
* standing in for the security layer's refusal.
30+
*
31+
* [#24335] ASKED BEFORE READ: with a security service in the engine's slot,
32+
* the engine asks its `canReadObject('sys_file')` first and a refused caller
33+
* is served the refused arm without the `sys_file` read — so the security
34+
* layer, which logs every refusal at WARN, sees no access attempt. The last
35+
* block pins that against the same fixture, and that the served body is the
36+
* one the refused read produced.
3037
*/
3138

3239
import { describe, it, expect } from 'vitest';
3340
import { FileRefusedValueSchema, valueSchemaFor } from '@objectstack/spec/data';
34-
import { ObjectQL } from './engine';
41+
import { ObjectQL, SECURITY_SERVICE_SOURCE_SLOT } from './engine';
3542

3643
const FILE_ID = 'f_7cQx2Lm90a';
3744
const SECOND_ID = 'f_zz11yy22xx';
@@ -142,9 +149,11 @@ function makeCapturingLogger() {
142149
* field), a registered `sys_file`, and three rows: one holding a file, one
143150
* holding two files plus a legacy URL, one holding none. When `refuseReader`
144151
* is set, a middleware refuses every non-system `sys_file` read the way the
145-
* security layer's CRUD gate does.
152+
* security layer's CRUD gate does, and records each read it refuses in
153+
* `attempts` (the reads plugin-security would log a denial for). `security`,
154+
* when given, is what the engine's security-service slot resolves.
146155
*/
147-
async function boot(opts: { sysFileRead?: () => Promise<any[]>; refuseReader?: boolean } = {}) {
156+
async function boot(opts: { sysFileRead?: () => Promise<any[]>; refuseReader?: boolean; security?: unknown } = {}) {
148157
const logger = makeCapturingLogger();
149158
const engine = new ObjectQL({ logger } as any);
150159
const { driver, storeFor } = makeDriver(opts.sysFileRead ?? (async () => COMMITTED_ROWS));
@@ -167,10 +176,17 @@ async function boot(opts: { sysFileRead?: () => Promise<any[]>; refuseReader?: b
167176
status: { type: 'text' },
168177
},
169178
} as any);
179+
const attempts: any[] = [];
180+
if (opts.security !== undefined) {
181+
(engine as unknown as Record<symbol, unknown>)[SECURITY_SERVICE_SOURCE_SLOT] = () => opts.security;
182+
}
170183
if (opts.refuseReader) {
171184
engine.registerMiddleware(
172185
async (ctx: any, next: () => Promise<void>) => {
173-
if (!ctx.context?.isSystem) throw refusal();
186+
if (!ctx.context?.isSystem) {
187+
attempts.push(ctx.context);
188+
throw refusal();
189+
}
174190
return next();
175191
},
176192
{ object: 'sys_file' },
@@ -184,7 +200,7 @@ async function boot(opts: { sysFileRead?: () => Promise<any[]>; refuseReader?: b
184200
gallery: [FILE_ID, SECOND_ID, 'https://cdn.example.com/legacy.png'],
185201
});
186202
storeFor('doc').set('d3', { id: 'd3', title: 'Empty', attachment: null, gallery: null });
187-
return { engine, logger };
203+
return { engine, logger, attempts };
188204
}
189205

190206
const byId = (rows: any[]) => Object.fromEntries(rows.map((r) => [r.id, r]));
@@ -296,3 +312,116 @@ describe('a refused sys_file read marks the file field refused — not absent',
296312
expect(logger.lines.debug.filter((l: any) => /sys_file read refused/.test(l.msg))).toEqual([]);
297313
});
298314
});
315+
316+
/**
317+
* A security service the engine's slot resolves, answering `canReadObject` as
318+
* `admits` says (the fixture's middleware refuses every non-system reader, so
319+
* `false` is the true answer for READER_CTX), and recording every ask.
320+
*/
321+
function makeSecurity(admits: (object: string, context: any) => boolean | Promise<boolean>) {
322+
const asks: Array<{ object: string; context: any }> = [];
323+
const svc: Record<string, unknown> = {
324+
getReadFilter: async () => undefined,
325+
canReadObject: async (object: string, context: any) => {
326+
asks.push({ object, context });
327+
return admits(object, context);
328+
},
329+
};
330+
return { svc, asks };
331+
}
332+
333+
describe('[#24335] the engine asks before it reads sys_file — a refused reader makes no access attempt', () => {
334+
it('a reader the admission refuses is served the refused arm and never reaches the sys_file read', async () => {
335+
const { svc, asks } = makeSecurity((_o, ctx) => ctx?.isSystem === true);
336+
const { engine, attempts } = await boot({ refuseReader: true, security: svc });
337+
338+
const rows = byId(await engine.find('doc', { context: READER_CTX }));
339+
340+
// No read of sys_file reached the security layer: nothing for it to log.
341+
expect(attempts).toEqual([]);
342+
expect(rows.d1.attachment).toEqual({ id: FILE_ID, metadataRefused: true });
343+
// One ask per read, about sys_file, with the caller's own envelope.
344+
expect(asks).toHaveLength(1);
345+
expect(asks[0].object).toBe('sys_file');
346+
expect(asks[0].context).toMatchObject(READER_CTX);
347+
expect(Object.keys(asks[0].context).filter((k) => k.startsWith('__'))).toEqual([]);
348+
});
349+
350+
it('the body is byte-identical to the one the refused read served, by list and by id', async () => {
351+
const asked = await boot({ refuseReader: true, security: makeSecurity(() => false).svc });
352+
const unasked = await boot({ refuseReader: true });
353+
354+
const listAsked = await asked.engine.find('doc', { context: READER_CTX });
355+
const listUnasked = await unasked.engine.find('doc', { context: READER_CTX });
356+
const oneAsked = await asked.engine.findOne('doc', { where: { id: 'd2' }, context: READER_CTX });
357+
const oneUnasked = await unasked.engine.findOne('doc', { where: { id: 'd2' }, context: READER_CTX });
358+
359+
// The comparison is armed: the unasked engine did attempt, and was refused.
360+
expect(unasked.attempts.length).toBeGreaterThan(0);
361+
expect(asked.attempts).toEqual([]);
362+
expect(oneAsked?.gallery?.[0]).toEqual({ id: FILE_ID, metadataRefused: true });
363+
expect(JSON.stringify(listAsked)).toBe(JSON.stringify(listUnasked));
364+
expect(JSON.stringify(oneAsked)).toBe(JSON.stringify(oneUnasked));
365+
});
366+
367+
it('the reader\'s own sys_file read is still refused, and still reaches the security layer', async () => {
368+
const { engine, attempts } = await boot({ refuseReader: true, security: makeSecurity(() => false).svc });
369+
370+
const err = await engine.find('sys_file', { context: READER_CTX }).catch((e: unknown) => e);
371+
372+
expect((err as any)?.code).toBe('PERMISSION_DENIED');
373+
expect((err as any)?.statusCode).toBe(403);
374+
expect(attempts).toHaveLength(1);
375+
});
376+
377+
it('CONTROL a reader the admission admits still reads sys_file as itself, and is hydrated', async () => {
378+
const { svc, asks } = makeSecurity(() => true);
379+
const { engine } = await boot({ security: svc });
380+
381+
const rows = byId(await engine.find('doc', { context: READER_CTX }));
382+
383+
expect(asks).toHaveLength(1);
384+
expect(rows.d1.attachment).toEqual({
385+
id: FILE_ID,
386+
name: 'contract.pdf',
387+
size: 1024,
388+
mimeType: 'application/pdf',
389+
url: `/api/v1/storage/files/${FILE_ID}`,
390+
});
391+
});
392+
393+
it('CONTROL an admitted read the security layer still refuses takes the same refused arm', async () => {
394+
const { engine, attempts } = await boot({ refuseReader: true, security: makeSecurity(() => true).svc });
395+
396+
const rows = byId(await engine.find('doc', { context: READER_CTX }));
397+
398+
expect(attempts).toHaveLength(1);
399+
expect(rows.d1.attachment).toEqual({ id: FILE_ID, metadataRefused: true });
400+
});
401+
402+
it('CONTROL unasked — no canReadObject, or an ask that throws — the read goes to the security layer as before', async () => {
403+
const noMethod = await boot({ refuseReader: true, security: { getReadFilter: async () => undefined } });
404+
const throwing = await boot({
405+
refuseReader: true,
406+
security: makeSecurity(() => {
407+
throw new Error('admission unavailable');
408+
}).svc,
409+
});
410+
411+
for (const { engine, attempts } of [noMethod, throwing]) {
412+
const rows = byId(await engine.find('doc', { context: READER_CTX }));
413+
expect(attempts).toHaveLength(1);
414+
expect(rows.d1.attachment).toEqual({ id: FILE_ID, metadataRefused: true });
415+
}
416+
});
417+
418+
it('CONTROL a record holding no file id asks nothing', async () => {
419+
const { svc, asks } = makeSecurity(() => false);
420+
const { engine } = await boot({ refuseReader: true, security: svc });
421+
422+
const row = await engine.findOne('doc', { where: { id: 'd3' }, context: READER_CTX });
423+
424+
expect(row?.id).toBe('d3');
425+
expect(asks).toEqual([]);
426+
});
427+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 106 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13305,10 +13305,19 @@ export class ObjectQL implements IObjectQLEngine {
1330513305
* that record — and hydrates when it allows. Every other id keeps the
1330613306
* refused marker. See {@link readParentDerivedFiles}.
1330713307
*
13308-
* Batched: at most one `sys_file` `id $in […]` read per call (no N+1) — and,
13309-
* on the refused arm only, one more system read plus one verdict call; zero
13310-
* reads when no field holds a string value (the blob-only case), so the step
13311-
* is free for objects that have not adopted references.
13308+
* ASKED BEFORE READ (#24335): the caller's `sys_file` read is issued only
13309+
* when the security service admits it — {@link callerMayReadSysFile}, the
13310+
* middleware's own object-level read gate. A caller it refuses goes
13311+
* straight to the refused arm, the answer that read's refusal produced, so
13312+
* the served value is the same and no access attempt is made: the security
13313+
* layer logs no denial for a read the reader never asked for. A direct
13314+
* `sys_file` read by that reader is refused, and logged, exactly as before.
13315+
*
13316+
* Batched: one admission ask and at most one `sys_file` `id $in […]` read
13317+
* per call (no N+1) — and, on the refused arm only, one more system read
13318+
* plus one verdict call; zero of each when no field holds a string value
13319+
* (the blob-only case), so the step is free for objects that have not
13320+
* adopted references.
1331213321
*/
1331313322
private async resolveFileReferences(
1331413323
objectName: string,
@@ -13355,6 +13364,13 @@ export class ObjectQL implements IObjectQLEngine {
1335513364
const uniqueIds = [...new Set(candidateIds)];
1335613365
if (uniqueIds.length === 0) return records; // blob-only / empty → zero cost
1335713366

13367+
// [#24335] Ask before reading: a caller the security service refuses
13368+
// `sys_file` read is served the refused arm without the read whose
13369+
// refusal would have produced it.
13370+
if (!(await this.callerMayReadSysFile(execCtx))) {
13371+
return this.serveRefusedFileHydration(objectName, records, fileFields, uniqueIds, execCtx);
13372+
}
13373+
1335813374
// One batched sys_file read. `__expandRead` mirrors the lookup-expansion
1335913375
// sub-read (a system-built marker, never from client input). Metadata only —
1336013376
// byte-download authorization is enforced at the /files/:fileId resolver.
@@ -13408,37 +13424,12 @@ export class ObjectQL implements IObjectQLEngine {
1340813424
// them. The marker stays on everything else: an id another record owns
1340913425
// (copied in), an attachment-only or unclaimed file, an id with no row,
1341013426
// and every id when the verdict is unwired or cannot be asked.
13427+
//
13428+
// [#24335] A caller the security service's admission already refused
13429+
// never reaches this read (see {@link callerMayReadSysFile}); this arm
13430+
// still serves a refusal the admission did not foresee, the same way.
1341113431
if (isReadRefusal(error)) {
13412-
const owned = await this.readParentDerivedFiles(objectName, records, uniqueIds, execCtx);
13413-
this.logger.debug('sys_file read refused for this caller; file fields carry the refused marker', {
13414-
object: objectName,
13415-
fields: fileFields,
13416-
refusedIds: uniqueIds.length,
13417-
parentDerivedIds: owned.size,
13418-
});
13419-
const refused = (id: string): FileRefusedValue => ({ id, metadataRefused: true });
13420-
// Judged per ELEMENT and per RECORD: the verdict covers a file only on
13421-
// the record whose field owns it, so the same id copied into a second
13422-
// record is still refused there. An owned, allowed id with no servable
13423-
// row reads as the bare id — what a `sys_file` reader gets for it.
13424-
const valueFor = (record: any, v: unknown): unknown => {
13425-
if (!isFileIdToken(v)) return v;
13426-
const hit = owned.get(v);
13427-
if (!hit || record?.id == null || hit.ownerId !== String(record.id)) return refused(v);
13428-
return hit.row ? toFileValue(hit.row) : v;
13429-
};
13430-
for (const record of records) {
13431-
for (const fieldName of fileFields) {
13432-
const val = record[fieldName];
13433-
if (val == null) continue;
13434-
if (Array.isArray(val)) {
13435-
record[fieldName] = val.map((v: unknown) => valueFor(record, v));
13436-
} else {
13437-
record[fieldName] = valueFor(record, val);
13438-
}
13439-
}
13440-
}
13441-
return records;
13432+
return this.serveRefusedFileHydration(objectName, records, fileFields, uniqueIds, execCtx);
1344213433
}
1344313434
// Everything else (connection drop, timeout, a database-level permission
1344413435
// fault, query error) means the rows may well exist and simply were not
@@ -13485,6 +13476,87 @@ export class ObjectQL implements IObjectQLEngine {
1348513476
return records;
1348613477
}
1348713478

13479+
/**
13480+
* [#24335] May this caller read `sys_file` at all? Asked of the registered
13481+
* security service's `canReadObject` BEFORE {@link resolveFileReferences}
13482+
* issues the caller's own `sys_file` read, as the parent-record read gate
13483+
* asks before it reads a parent object ("A skipped object is not an access
13484+
* attempt", plugin-audit). `canReadObject` is the security middleware's own
13485+
* object-level read gate, arm for arm, and answers without logging or
13486+
* throwing; the read it spares is one that gate would refuse, and
13487+
* plugin-security logs every refusal at WARN — a line on every ordinary
13488+
* record read by a reader without `sys_file` read, burying the real denials.
13489+
*
13490+
* Asked with the caller's envelope minus its operation-private `__` keys
13491+
* (`withoutOperationPrivateKeys`, as {@link readParentDerivedFiles} asks its
13492+
* verdict): no second derivation of authority, and no muting of the
13493+
* middleware's log — the reader's own `sys_file` read still logs its denial.
13494+
*
13495+
* It only ever NARROWS what is read: `true` still reads as the caller, and
13496+
* that read still decides. Unasked ⇒ `true`, the read goes to the middleware
13497+
* as it did before: no usable security service, one whose contract omits the
13498+
* optional `canReadObject`, or an ask that throws. Any other answer than
13499+
* `true` ⇒ the refused arm, which is what that read's refusal served.
13500+
*/
13501+
private async callerMayReadSysFile(execCtx?: ExecutionContext): Promise<boolean> {
13502+
const security = this.resolveReadableIdsSecurity();
13503+
if (security.kind !== 'usable' || typeof security.svc.canReadObject !== 'function') return true;
13504+
const caller = withoutOperationPrivateKeys((execCtx ?? {}) as Record<string, unknown>);
13505+
try {
13506+
return (await security.svc.canReadObject('sys_file', caller as ExecutionContext)) === true;
13507+
} catch {
13508+
return true;
13509+
}
13510+
}
13511+
13512+
/**
13513+
* The refused arm of {@link resolveFileReferences}: this caller may not read
13514+
* `sys_file`, whether its admission said so up front (#24335) or its read was
13515+
* refused. Each id a record of this result owns is put to the field-owned
13516+
* verdict ({@link readParentDerivedFiles}, #22637) and hydrates when it
13517+
* allows; every other id becomes `{ id, metadataRefused: true }`
13518+
* (`FileRefusedValueSchema`). Logged at `debug`: the answer is handed to
13519+
* the caller in the value.
13520+
*/
13521+
private async serveRefusedFileHydration(
13522+
objectName: string,
13523+
records: any[],
13524+
fileFields: string[],
13525+
uniqueIds: string[],
13526+
execCtx?: ExecutionContext,
13527+
): Promise<any[]> {
13528+
const owned = await this.readParentDerivedFiles(objectName, records, uniqueIds, execCtx);
13529+
this.logger.debug('sys_file read refused for this caller; file fields carry the refused marker', {
13530+
object: objectName,
13531+
fields: fileFields,
13532+
refusedIds: uniqueIds.length,
13533+
parentDerivedIds: owned.size,
13534+
});
13535+
const refused = (id: string): FileRefusedValue => ({ id, metadataRefused: true });
13536+
// Judged per ELEMENT and per RECORD: the verdict covers a file only on
13537+
// the record whose field owns it, so the same id copied into a second
13538+
// record is still refused there. An owned, allowed id with no servable
13539+
// row reads as the bare id — what a `sys_file` reader gets for it.
13540+
const valueFor = (record: any, v: unknown): unknown => {
13541+
if (!isFileIdToken(v)) return v;
13542+
const hit = owned.get(v);
13543+
if (!hit || record?.id == null || hit.ownerId !== String(record.id)) return refused(v);
13544+
return hit.row ? toFileValue(hit.row) : v;
13545+
};
13546+
for (const record of records) {
13547+
for (const fieldName of fileFields) {
13548+
const val = record[fieldName];
13549+
if (val == null) continue;
13550+
if (Array.isArray(val)) {
13551+
record[fieldName] = val.map((v: unknown) => valueFor(record, v));
13552+
} else {
13553+
record[fieldName] = valueFor(record, val);
13554+
}
13555+
}
13556+
}
13557+
return records;
13558+
}
13559+
1348813560
/**
1348913561
* The servable subset of some `sys_file` rows, keyed by id — the rule both
1349013562
* arms of {@link resolveFileReferences} serve by, so a row hydrates under

0 commit comments

Comments
 (0)