Skip to content

Commit e6dc7a2

Browse files
fix(metadata-core,rest,runtime): judge an objectOverride action param against the object it names (#21904)
Fixes #21884 Clause-②: yes (widening) ## What a user saw A `delegated_admin` may invite members: the invite door answers 200 for that principal. But `GET /meta/object/sys_user` served that principal no `invite_user` action, so the console withheld an action the server admits. The action's `role` param is `{ field: 'role', objectOverride: 'sys_member' }`, so it names `sys_member.role`. The ADR-0106 mask read every param's `field` as a field of the served object. A caller denied `sys_user.role` therefore lost the whole action. The mechanism, measured at `607463d736`: `presentationEntry` in `packages/metadata-core/src/object-schema-fls-references.ts` tested every non-list key of an action with `mentionsDenied({ [key]: inner }, denied, 'skip', 'classified')` (line 383), and `denied` is the served object's denied set. A unit repro against the base build (`role` denied on `sys_user`) served `['other']` and dropped `invite_user`. The base census below shows the same thing on a real showcase boot. ## What changed **The rule (`@objectstack/metadata-core`, `object-schema-fls-references.ts`).** An action's `params` are now read one param at a time (`actionParamReadsDenied`). A param whose `objectOverride` names another object reads that object's field. Its `field` is judged against the caller's readable set on that object, and it is not a reference to the served object's fields. The action is still dropped when the field is not readable there. It is also dropped when that object's readable set cannot be determined. The override's value is an object name, so it is no longer tested as a field token. Everything else on the param is still read against the served object. There is no special case for `invite_user`: the rule covers every authored param with `objectOverride`. **Where the other object's readable set comes from (H3).** The posture is still decided once per caller and object, before the fetch (ADR-0106 D3). Only the fetched document says which other objects its params name, so the related half runs after the fetch: - `resolveObjectSchemaMaskPosture` now puts `relate` on a `project` posture. `relate` asks the posture's own question (same caller, same security service, same D7 preference for `getMetadataReadableFields`) about another object. - `relateObjectSchemaMaskPosture(posture, ...documents)` fills `related` for the objects those documents' params name. It does nothing for any other posture or for a document with no such param. It asks each object once, and it never throws. - `applyObjectSchemaMask` passes `related` into the reference mask. Every related read it withholds goes into the fingerprint as `object.field`. Two callers denied the same fields on the served object but different fields on the other object therefore never share a validator (D3's 304 cohorts). An unrestricted caller's ETag is byte-identical to before. I chose this over a second posture argument at every exit for one reason: the posture already reaches every projection site, and the masker closure that resolved it does not. With `relate` on the posture, each exit adds one awaited call between its fetch and its projection. No exit had to add a port or a request field. **Every exit relates its posture (`@objectstack/rest`, `@objectstack/runtime`).** The issue placed the fix at `presentationEntry`, with the runtime dispatcher's `maskObjectSchema` as the possible exit. Measured, the projections that serve actions live in two packages. In `@objectstack/rest` they are the shared item chain, layered chain and list chain (`meta-item-read-gate.ts`) and `RestServer`'s cached read and published read (`rest-server.ts`). The runtime dispatcher reaches the shared chains through `projectMetaObjectSchema` plus its own `maskObjectSchema`. ADR-0106 D5 requires all of them to mask alike. So each one now relates its posture right after the fetch, which is the narrowest correct form: `packages/rest` is the real home of most exits. The `/meta` diff route masks only `{ fields }` and has no actions, so it needs no relate step. **The shared contract (`@objectstack/metadata-core/testing`).** `FLS_CONTRACT_OBJECT` gains two actions whose params read `contact` fields through `objectOverride`, and the retention facts require the readable one to be served. An exit that skips the relate step withholds it (fail closed) and fails the contract by exit name. This was measured: see reverse verification below. ## Decisions - **H4: the param's `name`.** Under `objectOverride`, a `name` that repeats `field` is read as that field, so it is judged on the other object. An explicit `name` that differs from `field` is a request-body key whose owner nothing here can verify. It keeps the existing reading, as a reference to the served object, which can over-mask but never leak. With `defaultFromRow`, the param also seeds `field` from the served object's row (the spec's "key = the resolved field name"). That is a second read of the served object, so `field` is judged there too. All three cases are pinned. - **H5: fail closed.** If the security service has no answer for the other object, throws for it, or the object does not exist, the action is dropped. A `project` posture that nobody related (hand-built, or an exit that skipped the step) relates nothing, so its `objectOverride` actions are dropped too. A related throw withholds only the actions that read that object, with a `warn` naming it. The served object's own D6 tiers are unchanged. Exempt callers (platform admin, `isSystem`) get passthrough and the service is never asked about the related object. That is pinned. ## Census (H2), measured on a real showcase boot I added a scratch probe under `packages/qa/dogfood/test/` (deleted afterwards, not committed). It read every object schema the showcase serves (78 objects) through the by-name read and the list read, as five principals in one organization: the seeded platform admin, an `owner` who is not a platform admin, an `admin`, a `delegated_admin` and a `member`. I ran it once on head, and once with all six touched source files restored to the base blobs and those packages rebuilt. The restore was proven by blob equality with HEAD and an empty `git diff HEAD`. The workspace has two authored params with `objectOverride`: `sys_user.invite_user`'s `role` (on `sys_member`) and `sys_member.invite_user`'s `email` (on `sys_invitation`). `sys-member.object.ts` has two hits, but the other one is a comment. The only other hit is the `packages/lint` test fixture, which this mask never reads. | principal | `sys_user.invite_user`, base to head | `sys_member.invite_user`, base to head | |:--|:--|:--| | platform admin | served, served | served, served | | owner | served, served | served, served | | admin | served, served | served, served | | delegated_admin | **dropped, served** | served, served | | member | **dropped, served** | served, served | The by-name read and the list read agree in every cell. Across all 78 objects × 5 principals × 2 reads, the only served/dropped verdicts that moved are the two in bold. No read answered anything but 200 at base or head. On head, the `delegated_admin` and the `member` are both not served `sys_user.role`, and both are served `sys_member.role`. ## Why the member is still not offered it The member is **not** denied `sys_member.role`, so it is now served `sys_user.invite_user` in the metadata. It is not offered the action because of the reach gate from #21883: `requiresMembershipReach: 'invite_member'` lowers to a `visible` predicate over `current_user.positions`, and that predicate excludes the member grade. The dogfood case says this in as many words. It asserts that both grades are denied `sys_user.role`, served `sys_member.role` and served the action, that the delegated_admin is offered it, and that the member's served predicate evaluates false. ## Exported surface (measured on the built declarations) I diffed `packages/metadata-core/dist/index.d.ts` (and `index.d.cts`) built at base `607463d736` against head: - added: `relateObjectSchemaMaskPosture(posture, ...documents)`; - added: two optional members on the `project` member of `ObjectSchemaMaskPosture`, `related` (a map from object name to its readable field set, or undefined) and `relate` (a function from object name to a promise of that set); - `dist/testing.d.ts`: the `FLS_CONTRACT_OBJECT` literal type gains the two actions; - nothing removed, renamed or narrowed. The rest of the diff is docblock text. So the claim's `Clause-②: no` becomes `yes (widening)`, and `@objectstack/metadata-core` takes a `minor` changeset. `@objectstack/rest` and `@objectstack/runtime` take `patch`: their exported signatures are unchanged (`projectMetaObjectSchema` keeps its signature). ## Tests Final head `e5e2792cf1`, which merges `origin/main` at `1e18a0735c`: - `pnpm --filter @objectstack/metadata-core test`: 18 files, 397 passed. - dogfood, `--project isolated`: `org-admin-affordance-reach.dogfood.test.ts` and `delegated-admin-invite.dogfood.test.ts`, 2 files, 17 passed. - The ADR-0106 contract suites at every exit: `packages/rest/src/meta-object-fls.test.ts` plus the two capability-gate suites that read the fixture, 3 files, 123 passed; `packages/runtime/src/domains/meta-object-fls.test.ts`, 85 passed. - `typecheck` for metadata-core and dogfood: exit 0. At `0f9ce970cd`, the previous merge of `origin/main`: - full `@objectstack/rest` (both projects): 265 files, 5075 passed, 327 skipped; - full `@objectstack/runtime` (both projects): 330 files, 5390 passed, 19 skipped; - `typecheck` for rest and runtime: exit 0. Between those two heads, this branch changed two test titles, and `origin/main` brought a `platform-objects` action retirement and spec test-title text. Neither touches rest or runtime, so the full suites were not rerun (AGENTS.md, Multi-agent discipline §10). CI runs them. New unit pins, in `object-schema-fls-references.test.ts` under "an action param under `objectOverride` is judged against the object it names": - triage's three: - a delegated_admin-shaped caller is served `invite_user`; - an action whose param names a denied field of the served object is still dropped; - an `objectOverride` param on a field denied on the other object still drops the action, even when nothing of the served object is denied; - fail closed: undetermined, throwing, unknown object, and an unrelated posture; - exempt callers are untouched; - the `name` and `defaultFromRow` readings; - fingerprint cohorts; - relate asks each object once. The dogfood: `org-admin-affordance-reach.dogfood.test.ts` pinned the defect itself as `MASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user']`. That pin is now "every site is served to every grade". The new case, "a delegated_admin is offered Invite User on sys_user; a plain member is not — by the reach gate, not the field mask", is the one dogfood test for this card. I edited the existing file rather than adding a second showcase boot. ## Reverse verification (one-off, on committed HEAD `af06da75ac`) - **The rule.** Through `scripts/ablation-replace.mjs`, I set `presentationEntry`'s key reading back to the base reading (`const read = readsAsThisObject(key);`, which reads `params` as the base did). Anchor 1 to 0, blob `d7455eadbd55` to `5b00498c2f07`. Result: 4 failed, 73 passed. Red: the delegated_admin pin; the other-object-denied pin (through its served-object-whole half, where the base serves the action); fail-closed; and the `name`/`defaultFromRow` pin. Green, as expected: the "denied field of THIS object" pin, the exempt pin, the fingerprint pin and the relate pin. The restore was proven by `blob == HEAD (d7455ea)` and an empty `git diff HEAD`. - **An exit that forgets to relate.** I removed the relate step from the shared item chain (`createMetaItemAnswer`) and ran `packages/rest/src/meta-object-fls.test.ts`. Result: 4 failed, 91 passed. The four were `restricted-caller/field-vanishes-whole`, `restricted-caller/required-permissions-cause`, `unrestricted-caller/byte-identical` and `guest-fallback/D7`, each failing under the exit "GET /meta/object/:name — uncached branch" with "the mask over-reached". No other exit failed. The restore was proven by `blob == HEAD (b9e94d4)` and an empty `git diff HEAD`. ## Gates (at `e5e2792cf1`) - **Derived families.** After the second merge, `node scripts/pm/dispatch-gates.mjs --commands` derives the same 68 families. All 68 exit 0, and `--ran` reconciles them: 68 run, 0 NOT MEASURED, 0 unrun, and every family carries a recorded exit code. - **`check:dual-build-cjs-loads`.** At `0f9ce970cd` it first answered `PREREQUISITE NOT MET`, because 8 packages outside the dogfood closure had no `dist/`. That was a run that measured nothing, not a red. After `turbo run build` over `./packages/*` and `./packages/*/*`, it reported 106 entry points across 66 packages load. - **The artifact-roster block.** It is printed outside the derived total, unchanged after the merge, and has 53 commands. 50 exit 0. Three need a PR's context and answered NOT WIRED or NOT MEASURED locally: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. `check-partof-closing-keyword` passes on this body when given it as `PR_BODY`. The other two run against this PR once it exists, and their results are in the dev report. - **The four symbol-anchor sweeps.** `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all exit 0. - **ESLint, narrowed.** `pnpm exec eslint --no-inline-config --format json` over the 9 touched `.ts` files reports 9 files, 0 errors, 0 warnings. All 9 are in the population `eslint.config.mjs` declares (`packages/**/*.{ts,tsx,mts,cts}`). The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Cost.** A masked read of a document with an `objectOverride` param costs one more security-service read per object those params name. Today that means two documents, `sys_user` and `sys_member`, one related object each. It applies to every `project` posture, including a caller who is denied nothing on the served object, because that caller can still be denied the field on the other object. - **What the contract cannot express.** The contract's security double answers the same set for every object, so it cannot express "denied here, readable there", which is this card's own case. The unit pins and the dogfood hold that case. The contract holds that every exit relates. - **Lane.** The edits to `packages/rest/src/meta-item-read-gate.ts` and `packages/rest/src/rest-server.ts` are outside the declared lane. They are where most of the exits are (see above). `sys-user.object.ts` is untouched: the declaration was right and the mask was wrong. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cab6396 commit e6dc7a2

10 files changed

Lines changed: 522 additions & 57 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
The object-schema field mask (ADR-0106 D1) judges an action param that names another object's field through `objectOverride` against that object, not the one being served.
8+
9+
Clause-②: yes (widening)
10+
11+
**What a user saw.** A `delegated_admin` may invite members, and the invite door admits them, but `GET /meta/object/sys_user` served that principal no `invite_user` action. The action's `role` param is `{ field: 'role', objectOverride: 'sys_member' }`: it names `sys_member.role`. The mask read every param's `field` as a field of the served object, so a caller denied `sys_user.role` lost the whole action. A plain `member` lost it the same way. The member is now served the action too, and still not offered it: the action's `requiresMembershipReach` predicate excludes the member grade.
12+
13+
**The rule.** A param whose `objectOverride` names another object reads that object's field. It is judged against the caller's readable fields on that object, and it is not a reference to the served object's fields. The action is still dropped when the caller cannot read the field there, and when that object's readable fields cannot be determined (no answer from the security service, a security service that throws, or an object that does not exist). Nothing about the other object is served on a guess. The rest of the param is still read against the served object: `visible`, an option's `visibleWhen`, `defaultValue`, and an explicit `name` that differs from `field`. A `name` that only repeats `field` is read as that field. With `defaultFromRow`, the param also reads `field` from the served object's row, so `field` is judged against the served object too. An exempt caller (platform admin, `isSystem`) is served the whole schema, as before.
14+
15+
**The API (`@objectstack/metadata-core`), additive.**
16+
17+
- `relateObjectSchemaMaskPosture(posture, ...documents)` completes a `project` posture for the documents it is about to mask. It reads the caller's readable fields on each other object their action params name through `objectOverride`. It runs after the fetch, because only the document names those objects. It returns every other posture, and any document with no such param, unchanged, and it never throws.
18+
- The `project` member of `ObjectSchemaMaskPosture` gains two optional fields. `relate` asks the posture's question (same caller, same security service) about another object. `resolveObjectSchemaMaskPosture` sets it. `related` holds the answers. A `project` posture built without `related` gets no answers, so `applyObjectSchemaMask` drops every action with such a param.
19+
- `applyObjectSchemaMask` folds each related read it withholds into the fingerprint, written as `object.field`. Two callers who are denied the same fields on the served object but differ on the other object get different validators. An unrestricted caller's ETag is unchanged.
20+
- The shared contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) gains two actions whose params read `contact` fields through `objectOverride`. The contract's projection cases now require the readable one to be served and the denied one to be dropped. An exit that never relates its posture fails the contract by name.
21+
22+
**Every exit relates its posture (`@objectstack/rest`, `@objectstack/runtime`).** These exits relate the posture after the fetch, before the projection: the shared item, layered and list chains, `RestServer`'s cached read and published read, and the runtime dispatcher's mask. The `/meta` diff route masks `fields` only and needs no relate step.
23+
24+
**Measured on a showcase boot.** We read every object schema (78 objects, by-name read and list read) as five principals: a platform admin, an org owner, an admin, a `delegated_admin` and a `member`. Before and after this change, the only served action that moved is `sys_user.invite_user`, which is now served to the `delegated_admin` and the `member`. This repository has two authored params with `objectOverride`: `sys_user.invite_user`'s `role` and `sys_member.invite_user`'s `email` (on `sys_invitation`). The second was served to all five principals before and after.

‎packages/metadata-core/src/object-schema-fls-contract.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
* pointers, name lists, an expression, a field-group predicate, an index,
3535
* list views (a column list, a filter, a view KEYED by a field's name, an
3636
* object-form column naming one through its nested `prefix` / `summary`),
37-
* actions (a visibility predicate) — and, inside the READABLE fields, a name
37+
* actions (a visibility predicate, and — #21884 — a param that reads a field of
38+
* ANOTHER object through `objectOverride`, which every exit must relate after
39+
* its fetch and judge against THAT object) — and, inside the READABLE fields, a name
3840
* list, a `dependsOn`, a predicate, a formula `expression` and an inline grid
3941
* (`inlineColumns` by name and by computed `expr`, `inlineAmountField`) that
4042
* read a sibling. The inline grid sits on `name` only so that a field every
@@ -100,6 +102,12 @@ export const FLS_CONTRACT_OBJECT = {
100102
actions: [
101103
{ name: 'regrade', label: 'Regrade', type: 'script', visible: 'record.salary_grade != null' },
102104
{ name: 'rename', label: 'Rename', type: 'script', visible: 'record.name != null' },
105+
// [#21884] Params reading `contact`'s fields. The security double answers
106+
// the same set for every object, so `contact.name` is readable wherever
107+
// `account.name` is: an exit that never relates its posture withholds
108+
// `invite` (fail closed) and fails the `retained` half by name.
109+
{ name: 'invite', label: 'Invite', type: 'script', params: [{ field: 'name', objectOverride: 'contact' }] },
110+
{ name: 'escalate', label: 'Escalate', type: 'script', params: [{ field: 'bonus_formula', objectOverride: 'contact' }] },
103111
],
104112
fields: {
105113
id: { type: 'text', label: 'Id' },
@@ -195,7 +203,10 @@ const RETAINED_FOR_ID_AND_NAME: readonly FlsContractRetention[] = [
195203
compact: { label: 'Compact', type: 'grid', columns: [{ field: 'name', width: 200 }] },
196204
}),
197205
},
198-
{ what: 'the action whose predicate reads a readable field', holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename']) },
206+
{
207+
what: 'the action whose predicate reads a readable field, and the one whose `objectOverride` param reads a field readable on THAT object',
208+
holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite']),
209+
},
199210
];
200211

201212
/**
@@ -229,6 +240,10 @@ const RETAINED_WITH_BONUS_READABLE: readonly FlsContractRetention[] = [
229240
&& d?.fields?.name?.inlineColumns?.[2]?.expr === 'bonus_formula * 2'
230241
&& d?.fields?.name?.inlineAmountField === 'bonus_formula',
231242
},
243+
{
244+
what: 'both actions whose `objectOverride` params read fields readable on THAT object',
245+
holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite', 'escalate']),
246+
},
232247
];
233248

234249
/** An unmasked answer is the whole fixture — every reference in every position. */
@@ -241,7 +256,7 @@ const RETAINED_UNMASKED: readonly FlsContractRetention[] = [
241256
what: 'every inline-grid column, every list view and every action',
242257
holds: (d) => d?.fields?.name?.inlineColumns?.length === 4
243258
&& Object.keys(d?.listViews ?? {}).length === 4 && d?.listViews?.compact?.columns?.length === 3
244-
&& d?.actions?.length === 2,
259+
&& d?.actions?.length === 4,
245260
},
246261
];
247262

‎packages/metadata-core/src/object-schema-fls-references.test.ts‎

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
import { describe, it, expect } from 'vitest';
1212
import { FieldSchema, InlineGridColumnSchema, ObjectSchema } from '@objectstack/spec/data';
1313
import { ColumnPrefixSchema, ColumnSummaryConfigSchema, ListColumnSchema } from '@objectstack/spec/ui';
14-
import { applyObjectSchemaMask, type ObjectSchemaMaskPosture } from './object-schema-fls.js';
14+
import {
15+
applyObjectSchemaMask,
16+
relateObjectSchemaMaskPosture,
17+
resolveObjectSchemaMaskPosture,
18+
type ObjectSchemaMaskPosture,
19+
} from './object-schema-fls.js';
1520
import {
1621
COLUMN_PREFIX_POSITIONS,
1722
COLUMN_SUMMARY_POSITIONS,
@@ -458,6 +463,146 @@ describe('mentionsDenied is an identifier-token test', () => {
458463
});
459464
});
460465

466+
describe('[ADR-0106 D1] an action param under `objectOverride` is judged against the object it names', () => {
467+
// The shape of `sys_user.invite_user`: `role` is a field of BOTH objects,
468+
// and the param names the member's — not the user's.
469+
const SYS_USER = {
470+
name: 'sys_user',
471+
fields: { id: { type: 'text' }, email: { type: 'email' }, role: { type: 'text' } },
472+
actions: [
473+
{
474+
name: 'invite_user',
475+
label: 'Invite User',
476+
type: 'api',
477+
params: [
478+
{ field: 'email', required: true },
479+
{ field: 'role', objectOverride: 'sys_member', required: true },
480+
],
481+
},
482+
{ name: 'set_role', label: 'Set Role', type: 'api', params: [{ field: 'role', required: true }] },
483+
{ name: 'mail', label: 'Mail', type: 'api', params: [{ field: 'email' }] },
484+
],
485+
};
486+
487+
/** A security service answering per object, as plugin-security does. */
488+
const securityFor = (answers: Record<string, string[] | undefined | 'throw'>) => ({
489+
getMetadataReadableFields: async (object: string) => {
490+
const answer = answers[object];
491+
if (answer === 'throw') throw new Error(`security unhealthy for ${object} (test)`);
492+
return answer === undefined ? undefined : [...answer];
493+
},
494+
});
495+
496+
/** The exit's sequence: resolve before the fetch, relate after it, mask. */
497+
const serve = async (
498+
answers: Record<string, string[] | undefined | 'throw'>,
499+
document: Record<string, unknown> = SYS_USER,
500+
context: Record<string, unknown> = { userId: 'u_delegate', systemPermissions: [] },
501+
) => {
502+
const posture = await resolveObjectSchemaMaskPosture({
503+
objectName: String(document.name), context, security: securityFor(answers), enabled: true,
504+
});
505+
return applyObjectSchemaMask(document, await relateObjectSchemaMaskPosture(posture, document));
506+
};
507+
const actionNames = (result: { document: unknown }) => ((result.document as any).actions ?? []).map((a: any) => a.name);
508+
509+
it('serves `invite_user` to a caller denied THIS object\'s `role` who may read the named object\'s `role` (the delegated_admin shape)', async () => {
510+
const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role', 'user_id'] });
511+
expect(result.denied).toEqual(['role']);
512+
expect(actionNames(result)).toEqual(['invite_user', 'mail']);
513+
// Served as authored — the param still names the member's `role`.
514+
expect((result.document as any).actions[0].params[1]).toEqual({ field: 'role', objectOverride: 'sys_member', required: true });
515+
});
516+
517+
it('still drops an action whose param names a denied field of THIS object', async () => {
518+
const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] });
519+
expect(actionNames(result)).not.toContain('set_role');
520+
// An override naming this object IS this object: the same reading.
521+
const self = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }, {
522+
...SYS_USER,
523+
actions: [{ name: 'self_role', type: 'api', params: [{ field: 'role', objectOverride: 'sys_user' }] }],
524+
});
525+
expect(actionNames(self)).toEqual([]);
526+
});
527+
528+
it('still drops the action when the caller is denied the field on the object the override names', async () => {
529+
const denied = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'user_id'] });
530+
expect(actionNames(denied)).toEqual(['mail']);
531+
// Even when nothing of THIS object is denied: the read is the other object's.
532+
const thisWhole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'user_id'] });
533+
expect(thisWhole.denied).toEqual([]);
534+
expect(actionNames(thisWhole)).toEqual(['set_role', 'mail']);
535+
});
536+
537+
it('fails closed when the named object\'s readable set cannot be determined — undetermined, throwing, or unknown', async () => {
538+
for (const sysMember of [undefined, 'throw'] as const) {
539+
expect(actionNames(await serve({ sys_user: ['id', 'email', 'role'], sys_member: sysMember }))).toEqual(['set_role', 'mail']);
540+
}
541+
const unknown = await serve({ sys_user: ['id', 'email', 'role'] }, {
542+
...SYS_USER,
543+
actions: [{ name: 'ghost', type: 'api', params: [{ field: 'role', objectOverride: 'no_such_object' }] }],
544+
});
545+
expect(actionNames(unknown)).toEqual([]);
546+
// A posture nobody related (an exit that skipped the step) relates nothing: closed too.
547+
const unrelated = applyObjectSchemaMask(SYS_USER, { kind: 'project', readable: new Set(['id', 'email', 'role']) });
548+
expect(actionNames(unrelated)).toEqual(['set_role', 'mail']);
549+
});
550+
551+
it('leaves an exempt caller\'s schema whole, and never asks about the related object', async () => {
552+
let asked = 0;
553+
const posture = await resolveObjectSchemaMaskPosture({
554+
objectName: 'sys_user',
555+
context: { userId: 'u_admin', systemPermissions: ['setup.access'] },
556+
security: { getMetadataReadableFields: async () => { asked++; return []; } },
557+
enabled: true,
558+
});
559+
const related = await relateObjectSchemaMaskPosture(posture, SYS_USER);
560+
expect(related).toBe(posture);
561+
expect(applyObjectSchemaMask(SYS_USER, related).document).toBe(SYS_USER);
562+
expect(asked).toBe(0);
563+
});
564+
565+
it('reads a `name` restating `field` as that field; an explicit other `name`, and a `defaultFromRow` seed, as THIS object\'s', async () => {
566+
const answers = { sys_user: ['id', 'email'], sys_member: ['id', 'role', 'title'] };
567+
const withParam = (param: Record<string, unknown>) => ({ ...SYS_USER, actions: [{ name: 'act', type: 'api', params: [param] }] });
568+
// The default body key, spelled out, is the same read.
569+
expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']);
570+
// A body key that differs from the field keeps the existing reading: it
571+
// spells a denied field of this object, so the action goes.
572+
expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'title', objectOverride: 'sys_member' })))).toEqual([]);
573+
expect(actionNames(await serve(answers, withParam({ name: 'member_role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']);
574+
// `defaultFromRow` seeds the value from THIS object's row — a read here too.
575+
expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', defaultFromRow: true })))).toEqual([]);
576+
// The rest of the param is still this object's: a predicate over a denied field drops it.
577+
expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', visible: 'record.role != null' })))).toEqual([]);
578+
});
579+
580+
it('folds a withheld related read into the fingerprint, so two cohorts never share a validator for different bodies', async () => {
581+
const reads = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] });
582+
const cannot = await serve({ sys_user: ['id', 'email'], sys_member: ['id'] });
583+
expect(reads.denied).toEqual(cannot.denied);
584+
expect(reads.fingerprint).not.toBe(cannot.fingerprint);
585+
// An unrestricted caller on both objects keeps the empty fingerprint and the same reference.
586+
const whole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'role'] });
587+
expect(whole.fingerprint).toBe('');
588+
expect(whole.document).toBe(SYS_USER);
589+
});
590+
591+
it('relates each named object once, across documents, and leaves a document with no such read alone', async () => {
592+
const asked: string[] = [];
593+
const posture = await resolveObjectSchemaMaskPosture({
594+
objectName: 'sys_user',
595+
context: { userId: 'u' },
596+
security: { getMetadataReadableFields: async (object: string) => { asked.push(object); return ['id', 'role']; } },
597+
enabled: true,
598+
});
599+
expect(await relateObjectSchemaMaskPosture(posture, { name: 'sys_user', actions: [] })).toBe(posture);
600+
const related = await relateObjectSchemaMaskPosture(posture, SYS_USER, SYS_USER);
601+
expect(await relateObjectSchemaMaskPosture(related, SYS_USER)).toBe(related);
602+
expect(asked).toEqual(['sys_user', 'sys_member']);
603+
});
604+
});
605+
461606
/** The keys a Zod object schema declares. */
462607
function declaredKeys(schema: unknown): string[] {
463608
const shape = (schema as { shape?: Record<string, unknown> }).shape;
@@ -509,7 +654,12 @@ describe('[ADR-0106] the shared contract table, driven through the bare projecti
509654
for (const testCase of OBJECT_SCHEMA_MASK_CASES.filter((c) => c.expect.kind === 'fields')) {
510655
it(testCase.id, () => {
511656
const readable = testCase.readable as readonly string[];
512-
const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, project([...readable]));
657+
// Related as an exit relates it: the contract's double answers the
658+
// same set for every object, `contact` included (#21884).
659+
const posture: ObjectSchemaMaskPosture = {
660+
kind: 'project', readable: new Set(readable), related: new Map([['contact', new Set(readable)]]),
661+
};
662+
const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, posture);
513663
assertObjectSchemaMaskCase('applyObjectSchemaMask', testCase, { kind: 'document', document });
514664
});
515665
}

0 commit comments

Comments
 (0)