Skip to content

Commit 9c8b65a

Browse files
fix(plugin-security)!: the record an anonymous public-form submit echoes back passes the result masker (#21101)
Part of #21062 Clause-②: no (narrowing) ## What this changes This is position 1 of #21062: the public-form submit read-back. Position 2 (the public picker's sort and search key) is a separate dispatch, so #21062 remains open for it. A public form's submission is authorized by the ADR-0056 declaration-derived grant (`publicFormGrant`), which admits the create and the read-back on the form's declared object and nothing else. Its branch of the engine middleware in `packages/plugins/plugin-security/src/security-plugin.ts` runs before any permission set is resolved, and it used to `return next()` before step 4, the result masker. So the record echoed in the submit door's `201` body carried every field whose `maskingRule` applies as stored, to an anonymous submitter. That included a field filled from its `defaultValue` that the form never shows. PR #21051's zero-set fix does not reach this, because it is a different mechanism. `maskingRule`'s describe says the field is "Masked for every non-system caller unless …", and an anonymous submitter is a non-system caller. The echo is now masked for that caller. ## One masker (triage's direction, H4) - **Step 4's body moves, unchanged, into the private method `maskOperationResult(opCtx, permissionSets, secMeta, delegatorSets)`.** The middleware's step 4 calls it for the caller it resolved. The grant branch calls it for the caller the grant stands in for. There is no second masker and no masking code in the grant branch. The move is byte-identical: the base block and the method body, compared after a two-space dedent, differ by nothing. - **The caller the grant stands in for is resolved as the data plane resolves it.** The grant branch calls `resolvePermissionSetsForContext` on the grant's context, then `resolveCallerPosture` for those sets. The zero-set stand-in is called, not edited. The anonymous submitter acts for nobody, so the delegator is `null`. - **Those inputs are read before `next()` and fail closed, as the data plane's do.** A permission-resolution failure is refused with `403 PERMISSION_DENIED`, and so is a posture that cannot be read (the data plane's own `unresolvedPosture*` wording). Nothing is written behind either refusal. - **A grant context is never the principal-less hand-off.** A grant context that carries no position, no named set and no user id has its read-back masked too. - **Admission is unchanged.** The grant still admits the declared object only, the create plus the read-back operations (`insert`, `find`, `findOne`, `count`) and nothing else. The server-managed field strip is untouched. ## Where the read-back comes from (H2), and which skipped steps bear on it - **Measured on a real boot:** on the submit door, the only operation the grant's context reaches the engine with is the `insert`. The echo is the insert's result; this door does no `findOne` read-back. The grant's other admitted operations (`find`, `findOne`, `count`) are issued by no public door in the tree. They are masked by the same call now. - **The grant branch skips every gate between it and step 4.** For this card, step 4 is the one that matters. - **Not load-bearing here:** the predicate guard (step 2.9). It would matter only to a grant `find` / `findOne` / `count` carrying a predicate, which no public door issues (see Acceptance notes). - **Not load-bearing here:** the masked-echo write refusal (step 2.5a). It refuses a value on a write; it does not expose anything. - **Admission, not this card:** the CRUD, field-write, row-level, owner and tenant gates. ## The caller the grant stands in for (H3) | Deployment | Sets resolved for the submit route's grant context | Posture the masker reads | |:--|:--|:--| | No guest set registered (the showcase's shape) | none | the zero-set stand-in: the object's masking rules, no capability fold | | A guest set registered under the name the route's context requests | that guest set | the object's posture | The grant's context carries no user id, so no baseline set applies on either deployment. ## Per deployment, per field class (H1) The anonymous submit door was measured on real boots: `bootStack` with the real `SecurityPlugin`, ObjectQL, SQL driver, REST and auth layers. It was measured at the base `dff98c1f51` and at the fix `491331ca3e`. The readings are given by class here; the raw readings are in the seat's private scratch. | Field class | No guest set, before | No guest set, after | Guest set, before | Guest set, after | |:--|:--|:--|:--|:--| | Masking rule, collected by the form | stored | **masked** | stored | **masked** | | Masking rule, filled from its default, not on the form | stored | **masked** | stored | **masked** | | No rule, collected by the form (control) | stored | stored | stored | stored | | No rule, filled from its default, not on the form | stored | stored | stored | stored | | Capability-gated (`requiredPermissions`), no rule, filled from its default, not on the form | stored | stored ¹ | stored | **absent** | The submit answered `201` in every cell. A server-managed field the submitter supplied never landed, before or after. ¹ The zero-set stand-in carries no capability fold, so a caller with no set is served this class as stored. That is #21063's class, not addressed here; this PR does not edit the stand-in. ## Pins, red then green - **`packages/plugins/plugin-security/src/public-form-grant-masking.test.ts`** (new, 15 cases). - It covers both deployment shapes. Each case first asserts what resolution answers for the grant's caller there. - Per deployment, it pins five things: - every masked field is echoed masked on the submit, the defaulted one included; - the read-back operations the grant admits serve them masked; - the field with no rule is served as stored; - admission is unchanged: the create runs and the server-managed fields are stripped; - a count passes, and another operation or another object is refused `403 PERMISSION_DENIED` before it runs. - A boundary block pins three more cases: a grant context with no principal is masked too, and a permission-resolution failure or an unreadable posture refuses before the operation runs. - **`packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts`** (new, 2 cases). It runs a real boot of each deployment shape and drives the anonymous submit door over HTTP. Each case asserts: - the scene is real (a system read holds the stored values); - both masked field classes are echoed masked; - the field with no rule is echoed as stored; - the create succeeds, and a supplied server-managed field never lands. - **Red on the pins commit `503bf4bafa`** (plugin source at the base): unit 7 failed, 8 passed. The seven are the four masking pins (two per deployment), the principal-less pin and the two fail-closed pins. The premises, the control and the admission pins were green. Dogfood 2 of 2 failed, each on the stored value served where the mask was expected. - **Green on the fix commit `491331ca3e`:** unit 15 of 15, after `@objectstack/plugin-security` was rebuilt. Dogfood 2 of 2 (`ablation-dist-preflight` found the marker in 4 built files). `@objectstack/plugin-security` typecheck passed, the test layer included. ## Ablation The mutation makes the grant branch return before the masker again: `await next(); this.maskOperationResult(…); return;` became `return next();`. It was made through `scripts/ablation-replace.mjs` in wrap mode, with an outer `EXIT INT TERM` trap that restores the absolute path from `HEAD`. The anchor hit 1 → 0 and the blob moved `42d2348c…` → `67bf5842…`. - **Predicted:** exactly the five masker pins red (four masking pins and the principal-less pin), and the other ten green. The fail-closed pins stay green because the inputs are still read. Dogfood 2 of 2 red. - **Observed:** - `@objectstack/plugin-security` was rebuilt, and `ablation-dist-preflight --absent` found the marker absent from all 6 built files. - Unit: 5 failed, 10 passed. These are exactly the predicted five. - Dogfood: 2 of 2 failed, on the stored value. - **Restore:** `git checkout HEAD --` on the absolute path. - The restore was proven byte-identical: the blob is `42d2348c…`, equal to `HEAD`; `git diff HEAD` is empty; and the whole-tree status has 0 entries. - It was then rebuilt, and the preflight found the marker present in 2 built files. - Unit 15 of 15 and dogfood 2 of 2. ## Local verification, at the merged head `d076cc606a` All of the following ran in one locked sequential script, after a full workspace build (`turbo run build`, docs excluded). Every exit code was 0. - **Unit and type checks:** - `@objectstack/plugin-security` test: 153 files, 3301 passed, 23 skipped. This includes the new pin and the existing `publicFormGrant` suites. - `@objectstack/plugin-security` typecheck passed, the test layer included (`check:test-typecheck` OK). - `@objectstack/dogfood` typecheck passed. - **Dogfood (`isolated` project):** the new pin, `zero-set-masking` and `showcase-public-form`, 8 of 8. The `form-self-auth` dogfood (`shared-showcase` project), 3 of 3. These are the neighbours that drive the grant. - **Neighbour route suite:** `@objectstack/rest` `src/public-form-routes.test.ts`, 21 of 21. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands. All 67 ran. `--ran` reconciliation: 67 derived, 67 run, 0 not measured. - `check-adr-0087-registration`: the changeset reads `[BREAKING+bang+clause-②-narrowing]`, `not-required (no-migration-prescription)`. - `check-changeset-no-major`: no `major` bump. - `check:nul-bytes`, `check:doc-authoring`, `check:cross-package-test-inputs`, `check:test-source-alias` and `check:engine-double-contract`: green. - **The four roster families whose rosters sit under this diff's directories** (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`): green. - **Lint:** `eslint --no-inline-config --format json` on the 3 touched TypeScript files linted 3 files, with 0 errors and 0 warnings. - The population is `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus its never-linted build directories, and all 3 files are in it. - The config enables no type-aware linting (its own header states this), so this diff cannot move the verdict on any untouched file. - The repo-wide `pnpm lint` is left to CI. ## Surface - **The `publicFormGrant` branch** of the engine middleware: the comment and the masker call after `next()`. - **Step 4 of the same middleware:** its body is moved, unchanged, into `maskOperationResult`, which sits beside `computeReadPartialMaskRules`. The step itself is now one call. - This edit sits beside the grant region in the same file. It is declared here because the masker cannot be reached from the grant branch without either a copy or this move. - **Not edited:** - the zero-set stand-in (`resolveCallerPosture`, and the comment at its call site): it is called, not changed; - `packages/rest/src/**`; - `packages/spec/src/**`. ## Acceptance notes - **Not measured locally: CI's whole-root and workflow-valued families.** `dispatch-gates` names 11 whole-root families, 6 workflow-valued families, 5 path-scheduled CI jobs and 4 type-check programs that no local command places. They run on this PR. - **A grant `find` / `findOne` / `count` with a predicate on a masked field is admitted.** The predicate guard (step 2.9) does not run under the grant. This was measured at the engine only. No public door in the tree issues those operations under the grant; the submit door issues the `insert` alone. It is not filed, because it has no public-door reach. - **The masked-echo write refusal (step 2.5a) does not run under the grant.** A submitter can store a value shaped like a mask placeholder into a masked field. That is value integrity on an anonymous create, not exposure, and it is unchanged here. - **On a deployment with no guest set, a capability-gated field with no masking rule is still echoed stored.** That is the zero-set stand-in's missing capability fold, which is #21063's class; #21063 is not addressed here. - **The two fail-closed refusals are new answers on failure paths only.** On the submit door the posture is read from the live registry the insert itself requires, so an unreadable posture is not reached there in a healthy composition. The refusal is pinned at the unit layer. - **`main` was merged once, before the gate run.** It had moved 7 commits after the branch was cut, and none of them touches `plugin-security`. The pins were re-run on the merged head. `main` has since moved 8 more commits, none touching `plugin-security` or this diff's files. They are not merged here; the merge queue rebuilds on them. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8368f1c commit 9c8b65a

4 files changed

Lines changed: 537 additions & 25 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: the record an anonymous public-form submit echoes back passes the result masker, so a field whose masking rule applies is echoed masked (#21062)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author wrote is rewritten or changes meaning: every maskingRule already declared that it applies to every non-system caller who does not hold all of the field's requiredPermissions, and an anonymous form submitter is such a caller. The runtime now honours that declaration on the one door that skipped the masker. No stored metadata, schema key or published type moves. -->
10+
11+
**BREAKING for the anonymous public-form submit's response.**
12+
13+
**What changed.** A public form's submission is authorized by the ADR-0056
14+
declaration-derived grant, which admits the create and the read-back on the
15+
form's declared object and passes before any permission set is resolved. The
16+
grant handed the operation to the engine and returned before the result masker
17+
ran, so the record echoed in the `201` body carried every field whose
18+
`maskingRule` applies as stored: the field the form collects, and a field
19+
filled from its `defaultValue` that the form never shows.
20+
21+
The grant now hands what it returns to the same result masker the data plane
22+
uses, for the caller it stands in for: the permission sets resolved for the
23+
grant's context (the deployment's guest set when it registers one, otherwise
24+
none) and the object posture those sets read. A field whose masking rule
25+
applies is echoed masked. A field the caller's sets mark unreadable, or whose
26+
`requiredPermissions` they do not hold, is masked the way the data plane masks
27+
it for that caller. The read-backs the grant admits are masked the same way.
28+
29+
**What did not change.** The grant admits exactly what it admitted: the create
30+
and the read-back on the form's declared object, and nothing else. The
31+
server-managed fields are still stripped from the submitted row. The stored row
32+
is unchanged; only the echo is masked.
33+
34+
**One more refusal, by the same rule.** If the caller's permission sets or the
35+
object's security posture cannot be read, the submission is now refused with
36+
`403 PERMISSION_DENIED` before anything is written, as every other caller's
37+
request already is. The masking rules come from that posture, so the echo
38+
cannot be masked without it.
39+
40+
**What to do.** Nothing, unless a client reads a masked field's stored value
41+
back out of the submit response. The response now carries the masked value, as
42+
every other non-system read does. A field's `requiredPermissions` are the gate
43+
that lifts its mask, so a deployment whose guest set holds all of them is
44+
echoed the stored value; otherwise drop the `maskingRule`.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21062] The ADR-0056 public-form grant never bypasses the result masker.
5+
*
6+
* The grant the form-submit route builds admits exactly two things on the
7+
* form's declared object: the create, and the read-back of what was created.
8+
* It passes before any permission set resolves, so it used to hand the
9+
* operation to the engine and return before step 4 (the result masker) ever
10+
* ran — the record echoed to an anonymous submitter carried every field whose
11+
* `maskingRule` applies as stored, a field filled from its `defaultValue` that
12+
* the form never shows included.
13+
*
14+
* `maskingRule` declares itself for "every non-system caller unless the
15+
* field's `requiredPermissions` are ALL held". The submitter is a non-system
16+
* caller, so the read-back passes the SAME masker the data plane uses, for the
17+
* caller the grant stands in for: the permission sets the data plane resolves
18+
* for the grant's context (no user id, so no baseline — the deployment's guest
19+
* set if it registers one, otherwise none) and the posture its gates read for
20+
* those sets (the zero-set stand-in included).
21+
*
22+
* Pinned on both deployment shapes, each case first asserting what resolution
23+
* answers for the grant's caller there, so a later change to resolution moves
24+
* the case loudly instead of silently testing something else:
25+
*
26+
* - the masked fields are served masked on every read-back the grant admits,
27+
* the defaulted one included, and a field with no rule is served stored;
28+
* - the grant's admission is unchanged: the declared object's create and
29+
* read-back pass, the server-managed fields are still stripped from the
30+
* payload, and every other operation or object is still refused.
31+
*
32+
* The last block pins the grant's own boundary: a grant context carrying no
33+
* principal is masked too (the grant is never the principal-less hand-off),
34+
* and a masker whose inputs cannot be read refuses before the operation runs,
35+
* as the data plane does.
36+
*
37+
* Fixtures are synthetic. Harness mirrors `zero-set-masking.test.ts`.
38+
*/
39+
40+
import { describe, it, expect, vi } from 'vitest';
41+
import type { PermissionSet } from '@objectstack/spec/security';
42+
import type { FieldMaskingRule } from '@objectstack/spec/data';
43+
import { SecurityPlugin } from './security-plugin.js';
44+
import { maskFieldValue } from './field-masker.js';
45+
46+
const OBJECT = 'intake';
47+
const RULE: FieldMaskingRule = { keepHead: 1, keepTail: 1 };
48+
const SCHEMAS: Record<string, unknown> = {
49+
[OBJECT]: {
50+
name: OBJECT,
51+
fields: {
52+
// No rule: the control, served as stored.
53+
subject: { type: 'text', label: 'Subject' },
54+
// A rule, collected by the form.
55+
on_form_masked: { type: 'text', label: 'On form', maskingRule: RULE },
56+
// A rule, filled from its default and never shown by the form.
57+
defaulted_masked: { type: 'text', label: 'Defaulted', maskingRule: RULE, defaultValue: 'SYNTHDEFAULT01' },
58+
},
59+
},
60+
other_object: { name: 'other_object', fields: { name: { type: 'text', label: 'Name' } } },
61+
};
62+
const MASKED = ['on_form_masked', 'defaulted_masked'] as const;
63+
64+
/** What the engine stores and hands back for a submitted payload: the payload, an id, the default. */
65+
const STORED_ROW = {
66+
id: 'r1',
67+
subject: 'SYNTH-SUBJECT',
68+
on_form_masked: 'SYNTHVALUE01',
69+
defaulted_masked: 'SYNTHDEFAULT01',
70+
};
71+
72+
/** The guest set a deployment may register under the name the route's grant context requests. */
73+
const GUEST_SET: PermissionSet = {
74+
name: 'guest_portal',
75+
label: 'Guest',
76+
objects: { [OBJECT]: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false } },
77+
} as PermissionSet;
78+
79+
/** The context the form-submit route builds for an anonymous submitter. */
80+
const ROUTE_GRANT_CONTEXT = { publicFormGrant: { object: OBJECT }, permissions: ['guest_portal'], anonymous: true };
81+
82+
async function boot(opts: { guestSet: boolean; schemas?: Record<string, unknown> }) {
83+
const schemas = opts.schemas ?? SCHEMAS;
84+
const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = [];
85+
const services: Record<string, unknown> = {
86+
manifest: { register: vi.fn() },
87+
objectql: {
88+
registerMiddleware: (mw: any) => middlewares.push(mw),
89+
getSchema: (name: string) => schemas[name],
90+
findOne: vi.fn(async () => null),
91+
},
92+
metadata: {
93+
get: async (_type: string, name: string) => schemas[name],
94+
list: async (type: string) =>
95+
(opts.guestSet && (type === 'permission' || type === 'permissions') ? [GUEST_SET] : []) as PermissionSet[],
96+
},
97+
};
98+
const registerService = vi.fn();
99+
const ctx: Record<string, unknown> = {
100+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
101+
registerService,
102+
getService: (name: string) => {
103+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
104+
return services[name];
105+
},
106+
};
107+
const plugin = new SecurityPlugin({});
108+
await plugin.init(ctx as any);
109+
await plugin.start(ctx as any);
110+
if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware');
111+
const security = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as {
112+
resolvePermissionSetsForContext: (context: unknown) => Promise<PermissionSet[]>;
113+
};
114+
return { plugin, middleware: middlewares[0], security };
115+
}
116+
117+
type Verdict = { admitted: true; ran: boolean } | { admitted: false; ran: boolean; code?: unknown; status?: unknown };
118+
119+
/**
120+
* Drive the middleware with an engine stand-in for `next()`: it records that
121+
* the operation ran and produces what the engine would — the stored row for an
122+
* insert or a findOne, a one-row page for a find.
123+
*/
124+
async function run(
125+
middleware: (opCtx: any, next: () => Promise<void>) => Promise<void>,
126+
opCtx: Record<string, any>,
127+
): Promise<Verdict> {
128+
let ran = false;
129+
const engine = async () => {
130+
ran = true;
131+
if (opCtx.operation === 'insert' || opCtx.operation === 'findOne') opCtx.result = { ...STORED_ROW };
132+
else if (opCtx.operation === 'find') opCtx.result = [{ ...STORED_ROW }];
133+
else if (opCtx.operation === 'count') opCtx.result = 1;
134+
};
135+
try {
136+
await middleware(opCtx, engine);
137+
return { admitted: true, ran };
138+
} catch (e) {
139+
const err = e as { code?: unknown; status?: unknown; statusCode?: unknown };
140+
return { admitted: false, ran, code: err.code, status: err.status ?? err.statusCode };
141+
}
142+
}
143+
144+
const submit = (context: Record<string, unknown> = ROUTE_GRANT_CONTEXT): Record<string, any> => ({
145+
object: OBJECT,
146+
operation: 'insert',
147+
context: { ...context },
148+
options: {},
149+
data: { subject: STORED_ROW.subject, on_form_masked: STORED_ROW.on_form_masked },
150+
});
151+
152+
function expectMasked(served: Record<string, unknown>): void {
153+
for (const f of MASKED) {
154+
const stored = (STORED_ROW as Record<string, string>)[f];
155+
expect(served[f], f).toBe(maskFieldValue(stored, RULE));
156+
expect(served[f], f).not.toBe(stored);
157+
}
158+
}
159+
160+
const DEPLOYMENTS: Array<{ label: string; guestSet: boolean; resolves: string[] }> = [
161+
{ label: 'a deployment that registers no guest set', guestSet: false, resolves: [] },
162+
{ label: 'a deployment that registers a guest set', guestSet: true, resolves: ['guest_portal'] },
163+
];
164+
165+
describe('[#21062] the public-form grant serves its read-back through the result masker', () => {
166+
for (const d of DEPLOYMENTS) {
167+
describe(d.label, () => {
168+
it('premise: what resolution answers for the caller the grant stands in for', async () => {
169+
const { security } = await boot({ guestSet: d.guestSet });
170+
const sets = await security.resolvePermissionSetsForContext({ ...ROUTE_GRANT_CONTEXT });
171+
expect(sets.map((s) => s.name)).toEqual(d.resolves);
172+
});
173+
174+
it('an anonymous submit echoes every masked field masked, the defaulted one included', async () => {
175+
const { middleware } = await boot({ guestSet: d.guestSet });
176+
const opCtx = submit();
177+
expect(await run(middleware, opCtx)).toEqual({ admitted: true, ran: true });
178+
expectMasked(opCtx.result);
179+
});
180+
181+
it('the read-back operations the grant admits serve every masked field masked', async () => {
182+
const { middleware } = await boot({ guestSet: d.guestSet });
183+
const one: Record<string, any> = {
184+
object: OBJECT, operation: 'findOne', context: { ...ROUTE_GRANT_CONTEXT }, options: {}, ast: { where: { id: 'r1' } },
185+
};
186+
expect(await run(middleware, one)).toEqual({ admitted: true, ran: true });
187+
expectMasked(one.result);
188+
const page: Record<string, any> = {
189+
object: OBJECT, operation: 'find', context: { ...ROUTE_GRANT_CONTEXT }, options: {}, ast: { where: { id: 'r1' } },
190+
};
191+
expect(await run(middleware, page)).toEqual({ admitted: true, ran: true });
192+
expectMasked(page.result[0]);
193+
});
194+
195+
it('the field with no masking rule is served as stored', async () => {
196+
const { middleware } = await boot({ guestSet: d.guestSet });
197+
const opCtx = submit();
198+
expect(await run(middleware, opCtx)).toEqual({ admitted: true, ran: true });
199+
expect(opCtx.result.subject).toBe(STORED_ROW.subject);
200+
expect(opCtx.result.id).toBe(STORED_ROW.id);
201+
});
202+
203+
it('admission is unchanged: the create runs and the server-managed fields are stripped from it', async () => {
204+
const { middleware } = await boot({ guestSet: d.guestSet });
205+
const opCtx = submit();
206+
opCtx.data = {
207+
...opCtx.data,
208+
owner_id: 'usr_synth_forged', organization_id: 'org_synth_forged', created_by: 'usr_synth_forged',
209+
};
210+
expect(await run(middleware, opCtx)).toEqual({ admitted: true, ran: true });
211+
expect(opCtx.data).toEqual({ subject: STORED_ROW.subject, on_form_masked: STORED_ROW.on_form_masked });
212+
});
213+
214+
it('admission is unchanged: a count passes, and another operation or another object is refused before it runs', async () => {
215+
const { middleware } = await boot({ guestSet: d.guestSet });
216+
const update = await run(middleware, {
217+
object: OBJECT, operation: 'update', context: { ...ROUTE_GRANT_CONTEXT }, options: {}, data: { id: 'r1', subject: 'x' },
218+
});
219+
expect(update).toEqual({ admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 });
220+
const foreign = await run(middleware, {
221+
object: 'other_object', operation: 'insert', context: { ...ROUTE_GRANT_CONTEXT }, options: {}, data: { name: 'x' },
222+
});
223+
expect(foreign).toEqual({ admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 });
224+
const count = await run(middleware, {
225+
object: OBJECT, operation: 'count', context: { ...ROUTE_GRANT_CONTEXT }, options: {}, ast: { where: {} },
226+
});
227+
expect(count).toEqual({ admitted: true, ran: true });
228+
});
229+
});
230+
}
231+
});
232+
233+
describe('[#21062] the grant boundary: never a principal-less hand-off, and fail closed on the masker\'s inputs', () => {
234+
it('a grant context carrying no principal has its read-back masked too', async () => {
235+
const { middleware } = await boot({ guestSet: false });
236+
const opCtx = submit({ publicFormGrant: { object: OBJECT } });
237+
expect(await run(middleware, opCtx)).toEqual({ admitted: true, ran: true });
238+
expectMasked(opCtx.result);
239+
expect(opCtx.result.subject).toBe(STORED_ROW.subject);
240+
});
241+
242+
it('a permission-resolution failure refuses the operation before it runs', async () => {
243+
const { plugin, middleware } = await boot({ guestSet: true });
244+
(plugin as unknown as { resolvePermissionSetsForContext: () => Promise<never> }).resolvePermissionSetsForContext =
245+
async () => { throw new Error('synthetic resolution outage'); };
246+
expect(await run(middleware, submit())).toEqual({ admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 });
247+
});
248+
249+
it('an object whose posture cannot be read refuses the operation before it runs', async () => {
250+
const { middleware } = await boot({ guestSet: false, schemas: {} });
251+
expect(await run(middleware, submit())).toEqual({ admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 });
252+
});
253+
});

0 commit comments

Comments
 (0)