Skip to content

Commit deb538f

Browse files
os-zhuangclaude
andauthored
fix(storage): let an object delegate file-read authorization to its service (#3580)
Fixes a regression from the governed-download change (ADR-0104 D3 wave 2, #3534), found by driving app-showcase in a browser as a real non-admin approver: a LEGITIMATE APPROVER COULD SEE A DECISION ATTACHMENT'S FILENAME BUT GOT 403 OPENING IT. The gate authorized a field-owned file by testing whether the caller can READ the owning row. For an ordinary business object that is right — row readability IS the access rule. For sys_approval_action it asks the wrong authority: the audit table is deliberately closed to ordinary approver positions ("operation 'find' on object 'sys_approval_action' is not permitted for positions [auditor, everyone]"), so the test denied exactly the person the attachment was filed for. The approvals service has always held the real rule — which is why the timeline listing the attachment returned 200 while the bytes returned 403. An object may now name a service to answer instead: - ObjectSchema.fileAccessDelegate — the kernel service that authorizes downloads of files owned by this object's media fields - IFileAccessDelegate.authorizeFileRead(recordId, context) — the contract - sys_approval_action declares 'approvals'; ApprovalService.authorizeFileRead reuses the SAME gate listActions applies (visibility of the parent request) rather than inventing a second, looser rule for the bytes Fails closed: a declared delegate that is missing or does not implement the method denies, rather than silently reverting to the raw read it was declared to replace. Objects that declare nothing are unchanged. Verified in the browser on both sides of the gate, not just the happy one. The approver now downloads the real PDF (200, %PDF-1.4 bytes); an anonymous request is still refused (401), so the anonymous capability URL #3534 set out to close stays closed. Checking the permissive side mattered: the delegate is exactly as open as listActions and no more, because approval history is tenant-scoped by design. The net invariant is that a decision attachment is exactly as readable as the decision it hangs off — never more, and no longer less. Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd Co-authored-by: Claude <noreply@anthropic.com>
1 parent d44dbfa commit deb538f

10 files changed

Lines changed: 233 additions & 3 deletions

File tree

.changeset/file-access-delegate.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-storage": patch
4+
"@objectstack/plugin-approvals": patch
5+
---
6+
7+
fix(storage): let an object delegate file-read authorization to its service
8+
9+
Fixes a regression from the governed-download change (ADR-0104 D3 wave 2): a
10+
**legitimate approver could see a decision attachment's filename but got 403
11+
opening it**, found by driving app-showcase in a browser as a real non-admin
12+
approver.
13+
14+
Cause: a field-owned file's download was authorized by testing whether the
15+
caller can READ the owning row. For an ordinary business object that is right —
16+
row readability *is* the access rule. For `sys_approval_action` it is the wrong
17+
authority: the audit table is deliberately closed to ordinary approver
18+
positions (`operation 'find' … is not permitted for positions [auditor,
19+
everyone]`), so the test denied the very approver the attachment was filed for.
20+
The approvals *service* has always had the real rule, which is why the timeline
21+
listing the attachment returned 200 while the bytes returned 403.
22+
23+
An object may now name a service to answer the question instead:
24+
25+
- `ObjectSchema.fileAccessDelegate` — a kernel service that authorizes
26+
downloads of files owned by that object's media fields.
27+
- `IFileAccessDelegate.authorizeFileRead(recordId, context)` — the contract.
28+
- `sys_approval_action` declares `'approvals'`; `ApprovalService.authorizeFileRead`
29+
reuses the *same* gate `listActions` applies (visibility of the parent
30+
request) rather than inventing a second, looser rule for the bytes.
31+
32+
**Fails closed**: a declared delegate that is missing or does not implement the
33+
method denies, rather than silently reverting to the raw read it was declared to
34+
replace. Objects without the declaration are unchanged.
35+
36+
Verified in the browser against app-showcase, both sides of the gate: the
37+
approver now downloads the real PDF (200), and an anonymous request is still
38+
refused (401) — the anonymous capability URL the original change closed stays
39+
closed. A decision attachment ends up exactly as readable as the decision it
40+
hangs off: never more, and no longer less.

content/docs/references/data/object.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ const result = ApiMethod.parse(data);
128128
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
129129
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
130130
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
131+
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
131132
| **validations** | `any[]` | optional | Object-level validation rules |
132133
| **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). |
133134
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* the read API, and the global record-lock hook.
1010
*/
1111

12-
import { describe, it, expect, beforeEach } from 'vitest';
12+
import { describe, it, expect, beforeEach, vi } from 'vitest';
1313
import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
1414
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
1515

@@ -2005,3 +2005,69 @@ describe('ApprovalService — queue approver is unresolved (#3508)', () => {
20052005
expect(warnings.some(([msg]) => String(msg).includes("'queue'") && String(msg).includes('#3508'))).toBe(true);
20062006
});
20072007
});
2008+
2009+
// ── File-access delegate (ADR-0104 D3 wave 2) ────────────────────────
2010+
//
2011+
// A decision attachment is OWNED by its `sys_approval_action` row, so the
2012+
// storage service would otherwise authorize the download by testing whether
2013+
// the caller can READ that row. It cannot — the table is closed to ordinary
2014+
// approver positions — which denied the very approver the attachment was filed
2015+
// for (reproduced in the browser against app-showcase). `sys_approval_action`
2016+
// therefore declares `fileAccessDelegate: 'approvals'` and the service answers,
2017+
// reusing the rule that already governs seeing a decision: visibility of the
2018+
// PARENT REQUEST, exactly as listActions applies it.
2019+
describe('ApprovalService — authorizeFileRead delegate (ADR-0104 D3 wave 2)', () => {
2020+
const svcFor = (engine: any) => {
2021+
let n = 0;
2022+
return new ApprovalService({
2023+
engine,
2024+
clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
2025+
});
2026+
};
2027+
2028+
const seedDecision = async (engine: any) => {
2029+
const svc = svcFor(engine);
2030+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2031+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
2032+
const action = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
2033+
return { svc, req, actionId: String(action.id) };
2034+
};
2035+
2036+
it('allows a caller who can see the parent request', async () => {
2037+
const engine = makeFakeEngine();
2038+
const { svc, actionId } = await seedDecision(engine);
2039+
2040+
expect(await svc.authorizeFileRead(actionId, SYS)).toBe(true);
2041+
});
2042+
2043+
it('denies a caller who cannot see the parent request', async () => {
2044+
const engine = makeFakeEngine();
2045+
const { svc, actionId } = await seedDecision(engine);
2046+
// getRequest is the single gate this delegates to — when it yields nothing
2047+
// for this caller, the bytes must be refused too.
2048+
vi.spyOn(svc as any, 'getRequest').mockResolvedValue(null);
2049+
2050+
expect(await svc.authorizeFileRead(actionId, CTX)).toBe(false);
2051+
});
2052+
2053+
it('denies an unknown action id', async () => {
2054+
const engine = makeFakeEngine();
2055+
const { svc } = await seedDecision(engine);
2056+
2057+
expect(await svc.authorizeFileRead('aact_does_not_exist', SYS)).toBe(false);
2058+
expect(await svc.authorizeFileRead('', SYS)).toBe(false);
2059+
});
2060+
2061+
it('fails CLOSED when the lookup throws', async () => {
2062+
const engine = makeFakeEngine();
2063+
const { svc, actionId } = await seedDecision(engine);
2064+
vi.spyOn(engine as any, 'find').mockRejectedValue(new Error('driver down'));
2065+
2066+
expect(await svc.authorizeFileRead(actionId, SYS)).toBe(false);
2067+
});
2068+
2069+
it('sys_approval_action declares the delegate, so the storage gate asks the service', async () => {
2070+
const { SysApprovalAction } = await import('./sys-approval-action.object.js');
2071+
expect((SysApprovalAction as any).fileAccessDelegate).toBe('approvals');
2072+
});
2073+
});

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2887,4 +2887,36 @@ export class ApprovalService implements IApprovalService {
28872887
}
28882888
return actions;
28892889
}
2890+
2891+
/**
2892+
* `IFileAccessDelegate` — may this caller download a decision attachment?
2893+
* (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
2894+
*
2895+
* A file referenced by `sys_approval_action.attachments` is owned by that
2896+
* audit row, so the storage service would otherwise authorize the download by
2897+
* testing whether the caller can READ the row. It cannot: `sys_approval_action`
2898+
* is deliberately closed to ordinary approver positions, so that test denies
2899+
* the very approver the attachment was filed for.
2900+
*
2901+
* The rule that actually governs seeing a decision is the one `listActions`
2902+
* applies — can the caller see the PARENT REQUEST? — so this reuses it
2903+
* exactly, rather than inventing a second, looser rule for the bytes. Fails
2904+
* closed on any error.
2905+
*/
2906+
async authorizeFileRead(actionId: string, context: SharingExecutionContext): Promise<boolean> {
2907+
if (!actionId) return false;
2908+
try {
2909+
const rows = await this.engine.find('sys_approval_action', {
2910+
where: { id: actionId },
2911+
limit: 1,
2912+
context: SYSTEM_CTX,
2913+
});
2914+
const requestId = (Array.isArray(rows) ? rows[0] : undefined)?.request_id;
2915+
if (!requestId) return false;
2916+
// Same gate as listActions: visibility of the decision's parent request.
2917+
return !!(await this.getRequest(String(requestId), context));
2918+
} catch {
2919+
return false;
2920+
}
2921+
}
28902922
}

packages/plugins/plugin-approvals/src/sys-approval-action.object.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export const SysApprovalAction = ObjectSchema.create({
2626
titleFormat: '{action} · {step_name}',
2727
highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'created_at'],
2828

29+
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
30+
// are OWNED by this row — and the storage service would otherwise authorize
31+
// their download by testing whether the caller can READ this row. It cannot:
32+
// this table is deliberately closed to ordinary approver positions, so that
33+
// test denies the very approver the attachment was filed for. The approvals
34+
// service already owns the rule for seeing a decision (visibility of the
35+
// parent request, exactly as `listActions` applies it), so it answers.
36+
fileAccessDelegate: 'approvals',
37+
2938
listViews: {
3039
recent: {
3140
type: 'grid',

packages/services/service-storage/src/storage-service-plugin.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import { resolveAuthzContext } from '@objectstack/core';
5-
import type { IHttpServer, IDataEngine, IStorageService } from '@objectstack/spec/contracts';
5+
import type {
6+
IHttpServer,
7+
IDataEngine,
8+
IStorageService,
9+
IFileAccessDelegate,
10+
} from '@objectstack/spec/contracts';
611
import {
712
OBSERVABILITY_METRICS_SERVICE,
813
NoopMetricsRegistry,
@@ -498,8 +503,29 @@ function buildFileReadAuthorizer(
498503
// Field-owned (ADR-0104 D3 wave 2): exactly ONE record's field holds
499504
// this file, so access is that record's READ access — never a union.
500505
if (file.ref_object && file.ref_id != null && file.ref_id !== '') {
506+
const ownerObject = String(file.ref_object);
507+
const ownerId = String(file.ref_id);
508+
509+
// An object whose access is MEDIATED BY A SERVICE rather than by row
510+
// permissions names that service in `fileAccessDelegate`. Asking the
511+
// row directly would be asking the wrong authority: `sys_approval_action`
512+
// is deliberately unreadable to ordinary approver positions, so a raw
513+
// read denies the very approver the attachment is for. Fails closed —
514+
// a declared delegate that is missing or incomplete denies rather than
515+
// silently reverting to the raw read it was declared to replace.
516+
const delegateName = (engine as any).getObject?.(ownerObject)?.fileAccessDelegate;
517+
if (typeof delegateName === 'string' && delegateName) {
518+
try {
519+
const delegate = ctx.getService<IFileAccessDelegate>(delegateName);
520+
if (!delegate || typeof delegate.authorizeFileRead !== 'function') return 'deny';
521+
return (await delegate.authorizeFileRead(ownerId, authz)) ? 'allow' : 'deny';
522+
} catch {
523+
return 'deny';
524+
}
525+
}
526+
501527
try {
502-
const visible = (await (engine as any).find(String(file.ref_object), {
528+
const visible = (await (engine as any).find(ownerObject, {
503529
where: { id: file.ref_id },
504530
fields: ['id'],
505531
limit: 1,

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3515,6 +3515,7 @@
35153515
"IEmbedder (interface)",
35163516
"IExportService (interface)",
35173517
"IExternalDatasourceService (interface)",
3518+
"IFileAccessDelegate (interface)",
35183519
"IHierarchyScopeResolver (interface)",
35193520
"IHttpRequest (interface)",
35203521
"IHttpResponse (interface)",

packages/spec/liveness/object.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@
197197
"status": "planned",
198198
"authorWarn": true,
199199
"note": "[ADR-0090 D11] P1 lands the SPEC SHAPE only (validated external<=internal at authoring; Studio surfaces the Ext badge). Runtime consumption (audience-aware evaluator branch substituting the external dial for external principals) is scheduled with the principal-taxonomy semantics phase; tracked on #2696. `planned` + authorWarn per enforce-or-mark: authors get told the dial does not evaluate yet. Was a bespoke 'authorable' status outside the documented vocabulary."
200+
},
201+
"fileAccessDelegate": {
202+
"status": "live",
203+
"evidence": "packages/services/service-storage/src/storage-service-plugin.ts (buildFileReadAuthorizer)",
204+
"note": "ADR-0104 D3 wave 2. Named service answers 'may this caller download a file owned by this object's media fields?' instead of the storage service testing raw readability of the owning row — which asks the wrong authority for an object whose access is mediated by a service. sys_approval_action declares 'approvals'; the service reuses the same parent-request visibility gate listActions applies. Fails closed: a declared-but-missing delegate denies."
200205
}
201206
}
202207
}

packages/spec/src/contracts/storage-service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,28 @@ export interface IStorageService {
192192
*/
193193
abortChunkedUpload?(uploadId: string): Promise<void>;
194194
}
195+
196+
/**
197+
* A kernel service that answers file-read authorization on behalf of an object
198+
* (ADR-0104 D3 wave 2). Named by that object's `fileAccessDelegate`.
199+
*
200+
* Exists because "can the caller READ the owning row?" — the storage service's
201+
* default question — is the wrong question for an object whose access is
202+
* mediated by a service rather than by row permissions. `sys_approval_action`
203+
* is the motivating case: it is deliberately unreadable to ordinary approver
204+
* positions, yet an approver must still be able to open a decision attachment.
205+
* The approvals service already knows who may see a request's history, so it
206+
* answers instead.
207+
*
208+
* Implementations should apply the SAME rule that governs reading the record
209+
* through their own API — not a looser one. The delegate widens who can reach
210+
* the bytes, so a permissive implementation is a data leak.
211+
*/
212+
export interface IFileAccessDelegate {
213+
/**
214+
* May this caller download a file owned by `recordId` on the delegating
215+
* object? Return `false` (never throw) to deny; a throw is treated as a
216+
* denial too, since authorization must fail closed.
217+
*/
218+
authorizeFileRead(recordId: string, context: unknown): Promise<boolean>;
219+
}

packages/spec/src/data/object.zod.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,31 @@ const ObjectSchemaBase = z.object({
953953
// enforced by the LifecycleService. Absent = `record` (today's behavior).
954954
lifecycle: LifecycleSchema.optional().describe('Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService.'),
955955

956+
/**
957+
* Who answers "may this caller download a file owned by this object's media
958+
* fields?" (ADR-0104 D3 wave 2).
959+
*
960+
* By default the storage service asks the question directly — can the caller
961+
* READ the owning row? That is right for ordinary business objects, where
962+
* row readability *is* the access rule. It is wrong for an object whose
963+
* access is **mediated by a service** rather than by row permissions: a
964+
* system audit table like `sys_approval_action` is deliberately unreadable
965+
* to ordinary positions, yet a legitimate approver must still be able to
966+
* open a decision attachment. Testing raw readability there asks the wrong
967+
* authority and denies the very people the record is for.
968+
*
969+
* Naming a kernel service here delegates the question to it. The service
970+
* must implement `authorizeFileRead(recordId, context) => boolean` (see
971+
* `IFileAccessDelegate`). Fails CLOSED: a declared service that is missing
972+
* or does not implement the method denies the download rather than falling
973+
* back to the raw read.
974+
*/
975+
fileAccessDelegate: z.string().optional().describe(
976+
'Kernel service that authorizes downloads of files owned by this object\'s media fields, '
977+
+ 'instead of testing whether the caller can read the owning row. For objects whose access '
978+
+ 'is mediated by a service (e.g. sys_approval_action → approvals). Fails closed.',
979+
),
980+
956981
/**
957982
* Logic & Validation (Co-located)
958983
* Best Practice: Define rules close to data.

0 commit comments

Comments
 (0)