Skip to content

Commit 5210acf

Browse files
fix(service-storage): the attach verb vouches for the one file it created, so members attach files again after #31031 (#31061)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:服务端附件动词 `attachFileAs` 以调用者身份插入 `sys_attachment` 时,为它在同一次调用里刚创建的那一个 `sys_file` 担保(`vouchForReference`,`@objectstack/core`);写入时的引用校验对这一个对象/字段/id 不再问调用者的读权限,但仍探测它存在。其余一切仍按调用者判定。 - 为什么改:#31031 之后 `lookup` 引用只能指向调用者读得到的记录,而成员一般读不到 `sys_file`,于是成员经 `attachFileAs` 上传附件、以及打印存档到附件(`printRecordToFile`)一律失败(500,"File: no sys_file record has id …")。本 PR 恢复这两条路径,#31031 的通用规则不变。 - 风险与回滚:风险低。担保是 symbol 键的上下文属性,请求体、查询、存储行、流程定义都拼不出来;只对该次插入、该字段、该 id 生效。任何数据门(REST、MCP、流程、导入)送来的 `sys_file` id 照旧按调用者读权限判。回滚即撤本 PR,成员附件会重新失败。 - 先看:`packages/objectql/src/engine-referential-integrity.ts` 中 `callerReads` 一行;`packages/services/service-storage/src/file-attachment-service.ts` 步骤 ⑤ 的插入;`packages/core/src/security/vouched-reference.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-storage exec vitest run src/file-attachment-service.test.ts`,预期 32/32 绿,其中成员上传附件一组 7 条。 - 需要你做的:请决定下方 Acceptance notes 第一条(浏览器附件面板的两步上传同样被 #31031 拦下)走哪条路。 ## What changes Restores member attachments after #31031. That PR made a non-system write's `lookup` / `master_detail` / `user` reference resolve only through the caller's own read door, and that rule stands. The server-side attach verb writes its `sys_attachment` row as the caller, and that row's `file_id` names the `sys_file` the same call has just created for the caller. A member who may attach files need not read `sys_file`. So the reference check refused the verb's own row, and both `attachFileAs` and print-to-attachment failed for every such member. **The rule.** A service verb that has created a record in the same call may vouch, on its own write, for the one reference to that record. For exactly that object, field and id, the write-time reference check does not ask the caller's read door, and it still runs the elevated, tenant-kept existence probe. Nothing else on the write is admitted by it. - `packages/core/src/security/vouched-reference.ts` adds `vouchForReference` / `isVouchedReference` and the `VOUCHED_REFERENCES` key. It is the same shape as the flow-write mark (`FLOW_WRITE_REFERENCE_CHECK`): a `Symbol.for` key that sits in no schema, does not survive `ExecutionContextSchema.parse`, and cannot be spelled by any request body, query string, stored row or flow definition. - `packages/objectql/src/engine-referential-integrity.ts`: `assertReferencesResolve` asks the caller's read door for every `lookup` / `user` / `master_detail` value except a vouched one, which goes to the existence probe alone. - `packages/services/service-storage/src/file-attachment-service.ts`: the caller-scoped `sys_attachment` insert vouches for `sys_attachment.file_id` = the file id created in step ③. The system path (`attachFile`) carries no mark, because system writes are already exempt. **Why this seam.** I looked at three other shapes and did not use them: - An `ExecutionContext` key or an engine option. Either would add to the spec's Zod contract. - A system-elevated insert of the link. That drops the caller's RLS, FLS, the `uploaded_by` stamp, the parent-edit attach rule and the object permission on `sys_attachment`. - Admitting a `sys_file` the caller uploaded (`owner_id`) inside the generic rule. That special-cases one object in the generic engine, and it widens every data door. The mark keeps the insert as the caller's own write. Every other gate still judges the caller: object permission to create an attachment, parent edit (`installAttachmentAccessHooks`), `enable.files`, the file-reference gate (`installAttachmentFileReferenceGate`, the download door's verdict on the file), RLS, FLS, and the owner and audit stamps. The caller's other references, the parent included, are judged as before. No read is widened. ## Tests Final-head local runs are in the Verification section below. - `packages/services/service-storage/src/file-attachment-service.test.ts` gets a new top-level describe. It runs a real ObjectQL over sqlite with an engine middleware standing in for the object-permission layer: a non-system read of `sys_file` is refused `PERMISSION_DENIED` / 403. - Pins: a member attaches through `attachFileAs`. The attachment is `uploaded_by` the member and the file is owned by them. The print sequence (`assertMayAttach`, then `attachFileAs`) also attaches as the member. - Controls: `assertMayAttach` still refuses a member the attach rule refuses. A parent the member cannot read is refused `RECORD_NOT_FOUND` / 404. The member's own data-engine insert of `sys_attachment` naming a file they cannot read is refused `VALIDATION_FAILED` / `reference_not_found`, and so is the same insert naming the very file the verb attached. - `packages/objectql/src/engine-unreadable-reference-target.test.ts` gets a new top-level describe. - The vouched value lands and only the elevated probe runs. - A vouched id that names no row is still refused. - Another unreadable id in the same field, the same id in another field, and the same id on another object are each refused. - A multi-valued field admits only the vouched member. - A context without the mark is refused, and so is one carrying the symbol's description as a plain string key. - `packages/qa/dogfood/test/print-service-archive.dogfood.test.ts` runs on a real showcase boot. - A member holding the contributor set and a `sys_attachment` read/create grant, and nothing on `sys_file`, prints their own invoice to its attachments. The attachment names them, and `sys_file` stays unreadable to them afterwards. - Control: the same member's own `sys_attachment` insert naming a file they own and cannot read is refused `reference_not_found`. **Ablations.** Each was run on a committed fix, with the restore proved by blob hash against HEAD. - Storage side, with the vouch removed from the insert. The suite reads storage source directly, so no build was needed. The two member-attach pins and the same-file pin go red (3 failed, 29 passed). The four controls stay green. - Engine side, with `isVouchedReference` removed from `callerReads`. objectql was rebuilt and `ablation-dist-preflight` confirmed the marker absent from all 14 built files. - objectql pins: 4 failed, 117 passed. The 4 red are the admitting pins; the nowhere, other-id, other-object and unmarked controls stay green. - storage pins: 3 failed, 29 passed. - print dogfood: the member print is red; 10 passed, the control included. - The restore leg was rebuilt and the preflight found the marker present in 4 built files. - #31009's `showcase-mcp-record-files.dogfood.test.ts` lives only on that PR's branch. It was run in a scratch tree: this branch merged with `origin/claude/project-thread-s2vcdk` (head `c2a3229be1`) and not pushed. Result: 13 of 13 pass. With the engine-side ablation in that same tree, exactly the three attachment cases go red (3 failed, 10 passed), matching the 3-of-13 regression measured after `eb96480dd6`. ## Acceptance notes 1. **The browser attachments panel's two-step flow is refused the same way, and this PR does not change that.** - What fails: the panel uploads through the presigned door, then posts `POST /data/sys_attachment` with the returned `file_id` through the generic data door. That post answers `400 VALIDATION_FAILED`, `file_id` `reference_not_found`, for a member who holds the `sys_attachment` create grant and nothing on `sys_file`. - Measured on this branch: a showcase boot with a member holding `showcase_contributor` plus a `sys_attachment` read/create grant. Presign 200, PUT 200, complete 200, attach 400. The probe file was not committed. - Why main stays green: `attachments-permission-matrix.dogfood.test.ts` runs on a fixture baseline that grants `'*'` read, `sys_file` included. - Why this PR leaves it: per the order, the generic rule is not loosened for any data door here. The decision is the seat's. 2. **Audit of other service-internal writes on `main`.** I scanned every insert/update in `packages/services/*/src` and `packages/plugins/*/src` whose options carry a context that is not obviously the system's, and found no second instance of this regression shape to fix. - `admin-user-endpoints.ts` (plugin-auth). The admin create door grants a permission set as the administrator: `sys_user_permission_set.user_id` names the `sys_user` the same call created. The door's platform-admin gate admits only callers who read every user. Measured: `create-user-permission-set.dogfood.test.ts` passes 4 of 4 on this branch, G1 (user and grant land together) included. Not changed. - `approval-service.ts` `mirrorStatusField`. It updates the record's status mirror with an attributed context. That writes a status value, not a reference. Not affected. - `service-automation` `crud-nodes.ts` (a flow writing as the user). This is a generic door by design, so the caller's references are judged. Not changed. - Everything else matched in those paths — device registry, connector connections/credentials, sharing, share links, approvals' rows, datasource secrets, settings audit, the storage metadata store and the backfills — writes as the system. ## Verification Head `b018a55aff` (this branch with `main` merged in). - `pnpm --filter @objectstack/core typecheck`, `@objectstack/objectql`, `@objectstack/service-storage` and `@objectstack/dogfood`: each exit 0. - `pnpm --filter @objectstack/core test`, `@objectstack/service-storage test` and `@objectstack/objectql test`: each exit 0, at `78b1698943`. The commits after it change the two pin files' option typing and merge `main`; `main`'s five commits touch none of these packages. The two pin files were re-run after the typing change: objectql 121/121, storage 32/32. - Dogfood at `98c5826af7`: `print-service-archive` with `create-user-permission-set`, 15/15. - `pnpm --filter @objectstack/spec check:generated` after merging `main`: every gate up to date. - Derived gate union (`dispatch-gates.mjs --commands`, 71 commands): 70 of 71 exit 0 in one sweep. `check:type-check-debt` lost its turn on the shared verify lock (exit 3, PREREQUISITE NOT MET, nothing measured); re-run under the lock it exits 0. Reconciliation: `dispatch-gates --ran` reports 71 derived, 71 run, 0 NOT-MEASURED, all with recorded exit codes. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cad764d commit 5210acf

8 files changed

Lines changed: 425 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/service-storage': patch
5+
---
6+
7+
fix(service-storage): a member attaches a file through the attach verb again; the verb vouches for the one file it created
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
A signed-in member who may attach files can attach one again through the server-side attach verb (`attachFileAs` on the `file-attachments` service), and so can a print to a record's attachments (`printRecordToFile`). Since the write-time reference check began judging a `lookup` value through the caller's own read door, the verb's `sys_attachment` insert — written as the caller — was refused over its `file_id`: a member who may attach need not read `sys_file`, so the file the verb had just created answered `reference_not_found` and the call failed.
13+
14+
The rule now: a service verb that has created a record in the same call may vouch, on its own write, for the one reference to that record (`vouchForReference`, `@objectstack/core`). The write-time reference check does not ask the caller's read door for exactly that object, field and id, and still probes that the record exists. The attach verb vouches for its own `sys_attachment.file_id` and for nothing else; every other gate on the insert still judges the caller (the permission to create an attachment, the parent-edit attach rule, `enable.files`, the file-reference gate, row- and field-level security, and the `uploaded_by` stamp).
15+
16+
Unchanged: no caller gains a read of `sys_file` or of anything else. A `sys_file` id sent through any data door — REST, MCP, a flow, an import — is judged through the caller's read door exactly as before, the file the verb attached included. The mark is a symbol-keyed context property that no request body, query, stored row or flow definition can spell.

‎packages/core/src/security/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,16 @@ export {
537537
keepsFlowWriteReferenceCheck,
538538
} from './flow-write-reference-check.js';
539539

540+
// The mark a service verb's own write carries for one reference to a record
541+
// the same call authorized and created. Set by service-storage's
542+
// file-attachments writer, read by objectql's write-time reference check.
543+
export {
544+
VOUCHED_REFERENCES,
545+
vouchForReference,
546+
isVouchedReference,
547+
type VouchedReference,
548+
} from './vouched-reference.js';
549+
540550
// #30205 — a banned user's open work: the per-object counts, the optional bulk
541551
// hand-over (both read and written as the caller), and the ban-in-force
542552
// predicate objectql's write-time reference check refuses a new assignment by.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The mark a service's own write carries for ONE reference value it vouches
5+
* for: a record the same call has itself authorized and created.
6+
*
7+
* ## The rule it answers to
8+
*
9+
* A write that is not a system write may name, in a `lookup`, `user` or
10+
* `master_detail` field, only a record its caller's own read door returns; a
11+
* record the caller cannot read answers `reference_not_found`
12+
* (`assertReferencesResolve`, objectql). That rule judges a reference the
13+
* CALLER chose. Some service verbs write, as the caller, a row that links to a
14+
* record the verb created a moment earlier in the same call, after asking its
15+
* own authorization question first. The file-attachments verb is the case: it
16+
* asks the attach rule (`assertMayAttach`), stores a new `sys_file` for the
17+
* caller, then inserts the `sys_attachment` naming it AS the caller, so every
18+
* gate on that insert judges the caller. The caller did not choose that file
19+
* id, and a caller who may attach files need not read `sys_file` at all, so
20+
* the read-door question has nothing to judge there; asking it refuses the
21+
* verb outright.
22+
*
23+
* ## What it admits, and what it does not
24+
*
25+
* The mark names one `(object, field, id)`. The reference check reads it for
26+
* exactly that value on a write to that object: the caller's read door is not
27+
* asked for it, and the elevated, tenant-kept existence probe still is, so an
28+
* id that names no row of the caller's organization is refused as before.
29+
* Everything else about the write stays the caller's: every other field and
30+
* every other value in the same field is judged through the caller's read door,
31+
* and nothing about RLS, FLS, the owner and audit stamps, the object's hooks
32+
* or the caller's read of anything changes. It grants no read.
33+
*
34+
* ## Where it is set — and where it is never set
35+
*
36+
* Only by a service verb, on the one write it makes for the record it just
37+
* created, after that verb's own authorization: today the file-attachments
38+
* writer's `sys_attachment` insert (`FileAttachmentService.attachFileAs`,
39+
* service-storage). ⛔ Never on a context a request, an import, a flow, an
40+
* agent tool or any other generic data door hands the engine: a reference such
41+
* a door carries is the caller's choice, and the read-door rule judges it.
42+
*
43+
* ## Why a symbol, and why it is not an `ExecutionContext` field
44+
*
45+
* The same reasons as `FLOW_WRITE_REFERENCE_CHECK`: no request body, query
46+
* string, stored row or flow definition can spell a symbol, so no author can
47+
* set it; it is in no schema; `Symbol.for` keeps it one key across the ESM and
48+
* CJS builds; and, being undeclared, it does not survive
49+
* `ExecutionContextSchema.parse`. It rides a spread of the context as any own
50+
* property does, so a write a hook sets off under the vouched write carries it
51+
* too, and is admitted only for the same object, field and id.
52+
*/
53+
54+
/** The context key of the mark. See the module header. */
55+
export const VOUCHED_REFERENCES: unique symbol = Symbol.for('objectstack.core.vouchedReferences');
56+
57+
/** One reference value a service vouches for: `field` of a write to `object`, naming `id`. */
58+
export interface VouchedReference {
59+
readonly object: string;
60+
readonly field: string;
61+
readonly id: string;
62+
}
63+
64+
/**
65+
* `context` with `reference` added to the references it vouches for — a fresh
66+
* object; `context` is not changed. Called only by a service verb, for a
67+
* record the same call created (see the module header).
68+
*/
69+
export function vouchForReference<T extends object>(
70+
context: T,
71+
reference: VouchedReference,
72+
): T & { [VOUCHED_REFERENCES]: ReadonlyArray<VouchedReference> } {
73+
const held = (context as { [VOUCHED_REFERENCES]?: unknown })[VOUCHED_REFERENCES];
74+
const prior = Array.isArray(held) ? (held as ReadonlyArray<VouchedReference>) : [];
75+
const entry: VouchedReference = Object.freeze({
76+
object: String(reference.object),
77+
field: String(reference.field),
78+
id: String(reference.id),
79+
});
80+
return { ...context, [VOUCHED_REFERENCES]: Object.freeze([...prior, entry]) };
81+
}
82+
83+
/**
84+
* Does `context` vouch for `id` in `field` of a write to `object`? `false` for
85+
* every other value, field, object and context.
86+
*/
87+
export function isVouchedReference(context: unknown, object: unknown, field: unknown, id: unknown): boolean {
88+
if (context === null || typeof context !== 'object') return false;
89+
if (typeof object !== 'string' || typeof field !== 'string') return false;
90+
if (typeof id !== 'string' && typeof id !== 'number') return false;
91+
const held = (context as { [VOUCHED_REFERENCES]?: unknown })[VOUCHED_REFERENCES];
92+
if (!Array.isArray(held)) return false;
93+
const value = String(id);
94+
return held.some(
95+
(r) => r !== null && typeof r === 'object'
96+
&& (r as VouchedReference).object === object
97+
&& (r as VouchedReference).field === field
98+
&& (r as VouchedReference).id === value,
99+
);
100+
}

‎packages/objectql/src/engine-referential-integrity.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252

5353
import { referenceTargetOf, type EngineQueryOptions } from '@objectstack/spec/data';
5454
import type { ExecutionContext } from '@objectstack/spec/kernel';
55-
import { isUserBanInForce, keepsFlowWriteReferenceCheck, USER_BAN_FIELDS } from '@objectstack/core';
55+
import { isUserBanInForce, isVouchedReference, keepsFlowWriteReferenceCheck, USER_BAN_FIELDS } from '@objectstack/core';
5656
import { ValidationError, buildFieldError } from './validation/record-validator.js';
5757
import { servedAsStored } from './engine-top-level.js';
5858
import type { ObjectQL } from './engine.js';
@@ -219,6 +219,16 @@ export function referencesCheckedBeforeRules(context: unknown): boolean {
219219
* and a value the row already stores is not judged again (#29062). `tree`
220220
* keeps the elevated probe alone.
221221
*
222+
* One value is not the caller's choice: a reference a service verb vouches for
223+
* on its own write (`vouchForReference`, `@objectstack/core`), naming a record
224+
* the same call authorized and created — the file-attachments writer's
225+
* `sys_attachment.file_id`, inserted as the caller after its own attach check.
226+
* For exactly that object, field and id the caller's read door is not asked;
227+
* the elevated, tenant-kept existence probe still is. Every other value, field
228+
* and object on the write is judged as above, and the mark comes from no
229+
* request: it is a symbol-keyed context property no body, query or stored row
230+
* can spell.
231+
*
222232
* Fails OPEN when the target cannot be checked (unregistered object, no
223233
* driver, a probe that throws): an integrity check that cannot run must not
224234
* invent a rejection, and the alternative — refusing every write to an object
@@ -301,8 +311,12 @@ export async function assertReferencesResolve(
301311
if (v === null || v === undefined || v === '') continue;
302312
if (typeof v === 'object') continue;
303313
// [#22519, #29029] A master-detail header, a lookup target and a user
304-
// must also be one the caller can read.
305-
const callerReads = CALLER_READ_REFERENCE_TYPES.has(def?.type);
314+
// must also be one the caller can read — except the one value a service
315+
// verb vouches for on its own write: a record the same call authorized
316+
// and created (`vouchForReference`, @objectstack/core). Its existence is
317+
// still probed; nothing else on the write is admitted by it.
318+
const callerReads = CALLER_READ_REFERENCE_TYPES.has(def?.type)
319+
&& !isVouchedReference(context, schema?.name, name, v);
306320
if (def?.type !== 'master_detail' && target === BAN_CHECKED_USER_OBJECT) {
307321
// [#30205] One probe answers both: the row's existence and its ban.
308322
// [#29029] The caller's read door comes first: a user it does not

‎packages/objectql/src/engine-unreadable-reference-target.test.ts‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
import { describe, it, expect, beforeEach } from 'vitest';
3333
import { resolveThrownHttpError } from '@objectstack/types';
34-
import { markFlowWriteReferenceCheck } from '@objectstack/core';
34+
import { markFlowWriteReferenceCheck, vouchForReference, VOUCHED_REFERENCES } from '@objectstack/core';
3535
import { ObjectQL } from './engine.js';
3636
import { ValidationError } from './validation/record-validator.js';
3737

@@ -498,3 +498,74 @@ describe('[#29029] the update\'s reference forgiveness compares only stored valu
498498
});
499499
});
500500
});
501+
502+
describe('a reference a service verb vouches for on its own write: that one value skips the caller\'s read door, nothing else does', () => {
503+
let engine: ObjectQL;
504+
let targetReads: TargetRead[];
505+
let storeFor: (o: string) => Map<string, Record<string, any>>;
506+
507+
beforeEach(() => {
508+
({ engine, targetReads, storeFor } = makeEngine());
509+
});
510+
511+
/** Alice's context, vouching for `id` in `field` of a write to `object`. */
512+
const vouched = (id: string, field = 'duty', object = ITEM, base: Record<string, unknown> = ALICE) =>
513+
vouchForReference({ ...base }, { object, field, id });
514+
const insert = (object: string, payload: Record<string, unknown>, context: Record<string, unknown>) =>
515+
engine.insert(object, { title: 'new', ...payload }, { context });
516+
const items = (object = ITEM) => [...storeFor(object).values()];
517+
518+
it('the vouched value lands although the caller cannot read it; its existence is still probed, elevated', async () => {
519+
await insert(ITEM, { duty: UNREADABLE }, vouched(UNREADABLE));
520+
expect(items().some((r) => r.duty === UNREADABLE)).toBe(true);
521+
// No read door asked for it; the existence probe ran.
522+
expect(targetReads.map(({ operation, elevated }) => ({ operation, elevated }))).toEqual([
523+
{ operation: 'findOne', elevated: true },
524+
]);
525+
});
526+
527+
it('a vouched id that names no row is still refused reference_not_found', async () => {
528+
expect(envelopeOf(await refusalOf(() => insert(ITEM, { duty: NOWHERE }, vouched(NOWHERE))))).toEqual(notFound('duty'));
529+
expect(items()).toEqual([]);
530+
});
531+
532+
it('control: another unreadable id in the vouched field is refused as before', async () => {
533+
const err = await refusalOf(() => insert(ITEM, { duty: 't_flagged_other' }, vouched(UNREADABLE)));
534+
expect(envelopeOf(err)).toEqual(notFound('duty'));
535+
expect(items()).toEqual([]);
536+
});
537+
538+
it('control: the vouched id in another field of the same write is refused as before', async () => {
539+
const err = await refusalOf(() => insert(ITEM, { duty: UNREADABLE, assignee: UNREADABLE }, vouched(UNREADABLE)));
540+
expect(envelopeOf(err)).toEqual(notFound('assignee'));
541+
expect(items()).toEqual([]);
542+
});
543+
544+
it('control: the vouched id on a write to another object is refused as before', async () => {
545+
const err = await refusalOf(() => insert(RULED, { duty: UNREADABLE }, vouched(UNREADABLE)));
546+
expect(envelopeOf(err)).toEqual(notFound('duty'));
547+
expect(items(RULED)).toEqual([]);
548+
});
549+
550+
it('control: a multi-valued field admits the vouched member only, and refuses another unreadable one beside it', async () => {
551+
const err = await refusalOf(() => insert(ITEM, { duties: [UNREADABLE, 't_flagged_other'] }, vouched(UNREADABLE, 'duties')));
552+
expect(envelopeOf(err)).toEqual(notFound('duties'));
553+
await insert(ITEM, { duties: [UNREADABLE, 't_mine'] }, vouched(UNREADABLE, 'duties'));
554+
expect(items().some((r) => Array.isArray(r.duties) && r.duties.includes(UNREADABLE))).toBe(true);
555+
});
556+
557+
it('control: the same value without the mark is refused, and a string key of the same name is no mark', async () => {
558+
expect(envelopeOf(await refusalOf(() => insert(ITEM, { duty: UNREADABLE }, { ...ALICE })))).toEqual(notFound('duty'));
559+
// What a JSON body or a stored row could spell: the symbol's description as a plain key.
560+
const spelled = { ...ALICE, [String(VOUCHED_REFERENCES.description)]: [{ object: ITEM, field: 'duty', id: UNREADABLE }] };
561+
expect(envelopeOf(await refusalOf(() => insert(ITEM, { duty: UNREADABLE }, spelled)))).toEqual(notFound('duty'));
562+
expect(items()).toEqual([]);
563+
});
564+
565+
it('control: a caller whose read of the target object is refused outright is admitted for the vouched value only', async () => {
566+
await insert(ITEM, { duty: 't_mine' }, vouched('t_mine', 'duty', ITEM, CAROL));
567+
expect(items().some((r) => r.duty === 't_mine')).toBe(true);
568+
const err = await refusalOf(() => insert(ITEM, { duty: 't_mine_2' }, vouched('t_mine', 'duty', ITEM, CAROL)));
569+
expect(envelopeOf(err)).toEqual(notFound('duty'));
570+
});
571+
});

0 commit comments

Comments
 (0)