Skip to content

Commit f4069c5

Browse files
fix(metadata): the object read's sortability follows the caller's field permission for every caller class (#22250) (#22308)
Fixes #22250 Clause-②: yes (widening) ## What this does For one caller, the object metadata read and the data route disagreed about a field that caller's field-level permission marks unreadable. The `sortability` projection served beside the schema on `GET /meta/object/NAME` marked that field `sortable: true` for a platform-admin or organization-admin caller, while the data route refuses a sort or filter on it with `403 PERMISSION_DENIED`. After this PR, the `sortability` projection is derived from the caller's own field permission for every caller class. The object definition itself (`item.fields`) is unchanged: whole for the exempt classes, because Studio and Setup authoring need it, and masked for everyone else. The data route and the permission read are unchanged. The spec's description of the projection is amended to match. ## Measured first: the exemption and its stated reason - **Where it is.** `isObjectSchemaMaskExempt` in `packages/metadata-core/src/object-schema-fls.ts` (ADR-0106 D4). It exempts `isSystem` and any caller holding `manage_metadata`, `studio.access` or `setup.access`. The platform-admin set holds all three. An organization admin holds `setup.access`. `resolveObjectSchemaMaskPosture` answers such a caller `passthrough` / `exempt` before it asks the security service. - **Its stated reason.** ADR-0106 D4: "Studio/Setup authoring requires the full schema." The code adds the write-gate derivation: a projected read sent back by a `PUT` would delete the fields it withheld. So this is the triage ruling's first branch: keep the definition read for authoring, and make the runtime projections agree with the caller's field permission. - **Dispatch assumption M2 was partly wrong.** The sortability projection (`translateMetaEnvelope` in `packages/rest/src/meta-item-read-gate.ts`, which both transports call) already read the post-mask document. For an exempt caller, though, that document is the unmasked one, so the projection inherited the exemption. - **Picker-facing field lists on the server.** `sortability` is the only runtime projection the object read serves beside the definition. The list read and the layered view serve definitions only, with no projection. `item.fields` is itself the definition the ruling keeps for authoring. objectui handles its pickers against the permission read on its side (objectui#11925, objectui#11943). ## Door table: `examples/app-showcase`, real boot Setup: `showcase_client_liaison` (which marks `showcase_project.budget`, `spent` and `budget_remaining` as `readable: false`) is bound to the `everyone` position. Callers: the seeded platform admin; `phone.demo` promoted to organization `admin`; `auditor.demo` as a member. The table was measured at base `c8bb3c8d9c` and at head `8b412a83e2`, then re-measured on the final head `a337d0cc53`, which carries `805f1ba09a`'s change to the exemption predicate's source and the spec text. Every head cell below reads the same on `8b412a83e2` and on `a337d0cc53`. | Door | Platform admin, base → head | Organization admin, base → head | Member (control), base → head | |---|---|---|---| | `GET /auth/me/permissions`, `budget` | `readable: false` → same | `readable: false` → same | `readable: false` → same | | `GET /data/showcase_project` | field absent from rows → same | field absent → same | field absent → same | | `GET /data/showcase_project?sort=budget` (and `?budget=100`) | `403 PERMISSION_DENIED` → same | `403` → same | `403` → same | | `GET /meta/object/showcase_project`, `item.fields.budget` | present (22 fields) → present (22) | present (22) → present (22) | absent (19) → absent (19) | | same read, `sortability.fields.budget` | `{ sortable: true }` (23 entries) → **absent** (20 entries) | `{ sortable: true }` (23) → **absent** (20) | absent (20) → absent (20) | | same read, `ETag` | `"39ce56dc"` → `"39ce56dc~3c4b8092"` | `"39ce56dc"` → `"39ce56dc~3c4b8092"` | `"39ce56dc~32c59ec2"` → same | | `GET /meta/object/showcase_project/layers` (authoring) | `effective` 22 fields with `budget` → same | same → same | 19 without `budget` → same | At head, all three classes are served byte-equal `sortability`. Conditional reads, the same on both heads: - The platform admin's own ETag answers `304`, and so does the organization admin's own ETag (on `a337d0cc53`). - The pre-change ETag `"39ce56dc"` answers `200`. - The member sending the admin's ETag gets `200`. After unbinding the set, the unrestricted admin's ETag is `"39ce56dc"` again (byte-identical, ADR-0106 D3), and `budget` is `{ sortable: true }` with 23 entries. ## How - **`@objectstack/metadata-core` (`object-schema-fls.ts`).** The `exempt` posture of a caller exempt by capability, in a deployment with a `security` service, carries a lazy `runtime` question. That question asks the same `getMetadataReadableFields` the mask asks a non-exempt caller, at most once. It never rejects: a throw or an `undefined` answer settles `undetermined`, with one `warn`, so D4's "a sick security service cannot fault an exempt caller" holds. `resolveObjectSchemaRuntimeView(document, posture)` returns the view the runtime projections are derived from: the served document for every other caller, and for an exempt caller the `fields` their permission reads, plus a fingerprint of what it withholds. Each withheld name in that fingerprint carries a qualifier (`@` occurs in no machine name), so an exempt caller and a projected caller denied the same field never share a validator. `isObjectSchemaMaskExempt` now delegates its capability half to a private helper, which the resolver asks alone. That adds no second elevation read (`check:system-context-census`). - **`@objectstack/rest`.** - `translateMetaEnvelope` takes an optional `runtimeDocument` (default: the served document) and derives `sortability` from it. - `createMetaItemAnswer` step 5 computes the runtime view and passes it through the `translateEnvelope` port, which gains an optional third parameter. - In `rest-server.ts`, the cached arm of `GET /meta/:type/:name` treats a posture carrying `runtime` like a projecting posture: the protocol is not handed `If-None-Match`, the runtime view's fingerprint is folded into the ETag, and the `304` is judged against that folded value. The private `translateMetaEnvelope` and `metaItemAnswerSources` forward the view. - **`@objectstack/runtime` (`domains/meta.ts`).** Only `answerMetaItem`'s `translateEnvelope` port, which forwards `runtimeDocument`. Every dispatcher item read already goes through `createMetaItemAnswer`. ## Spec text (cross-lane, `domain:spec`) Declared by the seat on `domain:spec`'s seat post. The change is to text only: no shape, key, enum or accept-set change. - **`packages/spec/src/api/sortability.zod.ts`.** - The `ObjectSortabilitySchema.fields` `.describe()` now says: "The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them." The list of absent names gains "a field the caller may not read". - The module docblock's category list gains case 5, "Withheld by the caller's field permission". Case 1's domain sentence carries the same exception, which removes nothing for every caller but a D4-exempt one. - `resolveObjectSortability`'s docblock says that, for a D4-exempt caller, it runs on the document narrowed to the fields the caller's own permission reads. - **Generated.** `content/docs/references/api/sortability.mdx` and `content/docs/references/api/protocol.mdx` were regenerated by `pnpm --filter @objectstack/spec check:generated --fix`. That forced a spec build, found only `check:docs` stale (14 of 15 green), and regenerated it. A second `check:generated` run then reported all 15 artifacts current. Nothing generated was hand-edited. - **Changeset.** No `@objectstack/spec` entry was added, because no changeset gate requires one for a description-only change: - `Check Changeset` (`pr-automation.yml`) counts the changesets a PR adds; - `check-changeset-no-major.mjs`, driven offline with this body as its event, reads `Clause-②: yes (widening)` and is satisfied by `metadata-core` and `rest` graded `minor`, and it lists no ungraded spec package; - `check-empty-changeset.mjs` is green. ## Tests - **`packages/metadata-core/src/object-schema-fls.test.ts`** (new describe block): - per class (platform admin, organization admin, member): the runtime view holds only the readable fields, the exempt definition is served whole, and the member's view is the served document by reference; - one permission gives one view whatever the class; no validator is shared with the definition mask; - an unrestricted exempt caller is byte-identical; the question is lazy and asked at most once, and never for a document without `fields`; - `undefined` and a throw both settle undetermined with a `warn`; `isSystem` and a deployment with no service carry no question. One existing assertion went from `toEqual` to `toMatchObject` plus a `typeof runtime === 'function'` check, because the exempt posture now carries the function. Its "asks nothing" pin (`asked === 0`) is kept. - **`packages/rest/src/meta-object-sortability-caller-fls.test.ts`** (new), per class, on the cached (default) and uncached branches: - the definition is unchanged, `sortability` never names the unreadable field, and it is equal across classes; - the member control still agrees with `resolveObjectSortability(item)`; - the cached arm's ETag is fingerprinted, the protocol is not handed the conditional, the admin's own ETag answers `304`, the member's ETag does not cross over, and an unrestricted admin keeps `"v1"` and its `304`; - a throwing service answers `200` with the definition and the unprojected `sortability`. - **`packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts`** (new): the same per-class pins on the dispatcher's protocol-backed and registry-backed item reads. **Reverse verification (from committed state, on-disk proof by `scripts/ablation-replace.mjs`):** - **A.** `translateMetaEnvelope` was mutated to derive `sortability` from the served document (the behaviour before this PR). The REST suite went 5 failed / 8 passed. After `pnpm --filter @objectstack/rest build`, `ablation-dist-preflight` found the marker in `dist`, and the runtime suite went 4 failed / 4 passed. Restore: blob equal to `HEAD`, `git diff HEAD` empty; rebuild; preflight `--absent` with a clean tree; 13/13 and 8/8 green. - **B.** The exempt posture was mutated to never carry `runtime`. The metadata-core file went 7 failed / 29 passed, all assertion failures. Restored, blob equal to `HEAD`. The first B attempt used an undefined identifier and went red on a `ReferenceError`, so that reading is void. The second was refused by the tool because the replacement contained the anchor, so nothing was mutated. ## Local verification (final head `a337d0cc53`) All suites ran through the shared verify lock. Exit codes were read from its `VERDICT` line. - **This round (spec text and regenerated docs).** - `@objectstack/spec` test: 625 files, 18711 passed and 1 todo. `typecheck` exit 0, which includes `check:scripts-typecheck` and `check:test-typecheck`. - `dispatch-gates --commands` derives 111 commands for the full touched path set (11 paths). All 111 ran and exited 0. - `--ran` reconciliation: 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every entry records its exit code). - The derivation warns that one file it reads, `scripts/platform-object-tenancy-census.json`, changed on `main` since this branch's merge base, so that family read this branch's copy. - Narrowed lint on `sortability.zod.ts`: - `eslint --no-inline-config --format json` reports 1 file, 0 errors and 0 warnings. - The effective config (`--print-config`) enables 5 syntax rules and `parserOptions.project` is `null`. - With no type-aware linting, the change cannot move a verdict on a file it does not touch. - **Earlier round (unchanged since).** - `@objectstack/metadata-core`: 18 files, 425 passed; `typecheck` exit 0. - `@objectstack/rest` at `3768ffad20`: 268 files, 5142 passed and 327 skipped; `typecheck` exit 0. - `@objectstack/runtime`: 340 files, 5508 passed and 19 skipped at `805f1ba09a`; `typecheck` exit 0 at `3768ffad20`. - Narrowed lint over those 7 files: 0 errors and 0 warnings. - Gates: 64 of 64 exited 0 at `3768ffad20`. - **Merge.** `main` was merged once, at `9865012548` (#22265 touched `rest-server.ts` and `runtime/src/domains/meta.ts` in a different region). Against the current `origin/main`, `git merge-tree --write-tree` exits 0 and no file overlaps, so no second merge was made. ## Answered by the seat - **Clause-②.** The measurement decides: `yes (widening)`, with `minor` for `metadata-core` and `rest` and `patch` for `runtime`. The claim's line was revised to match. Two transport-local copies of the posture logic were refused. - **The spec text.** It rides this PR, not a separate card, with `packages/spec` lifted for exactly this text. It is declared cross-lane on `domain:spec`'s seat post. ## Acceptance notes These are observations only, read from source and not measured. No card is filed for them. - **objectui's docblock.** objectui's `packages/core/src/utils/column-sortability.ts` repeats the sentence this PR amends ("the served field map plus the always-provisioned `id`"). That is another repo and is not edited here. Carrier: objectui `domain:ui` seat 1 (objectui#11925's filer). - **Partially masked fields.** For any caller, `sortability` keys on readability, while the security service's query guard also refuses a predicate or sort on a field served partially masked (`maskingRule`). Per `getQueryableFields`'s docblock, a masked field is readable but not queryable. No in-repo app declares a `maskingRule` today (showcase `permission-sets.ts`). Whoever touches `resolveObjectSortability` next should weigh it. - **External objects.** On an ADR-0015 `external` object, an author-declared platform-injected column that the caller cannot read is re-added by `unprovisionedInjectedColumns` as `sortable: true` plus a caveat once the mask removes the declaration. This is the same for every class. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e4111c commit f4069c5

11 files changed

Lines changed: 771 additions & 42 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/metadata-core": minor
3+
"@objectstack/rest": minor
4+
"@objectstack/runtime": patch
5+
---
6+
7+
fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class
8+
9+
Clause-②: yes (widening)
10+
11+
- **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses.
12+
- **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged.
13+
- **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator.
14+
- **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`.
15+
- **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service).
16+
- **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1360,7 +1360,7 @@ Enable package response
13601360

13611361
| Property | Type | Required | Description |
13621362
| :--- | :--- | :--- | :--- |
1363-
| **fields** | `Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`; a name absent from this map (an unknown field, a dotted path, an unprovisioned audit column) has no platform sort behind it and must get no sort affordance. |
1363+
| **fields** | `Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. |
13641364

13651365

13661366
---

‎content/docs/references/api/sortability.mdx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,15 @@ records as deliberately rejected).
3636
`assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and
3737
`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually
3838
do with an `orderBy` over the name. Three verdicts are REFUSALS, and one
39-
measured degradation is not refused; the projection covers all four:
39+
measured degradation is not refused; the projection covers all four, and
40+
one more absence that is a fact about the CALLER, not the column (5.):
4041

4142
1. **Unknown name** — not a field of the object. Refused (`400
4243
INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly
43-
the served field map plus the always-provisioned `id`, so a name with no
44-
entry has no platform sort behind it and gets no affordance.
44+
the served field map plus the always-provisioned `id`, less any field
45+
the caller's own field permission withholds (5. — which removes nothing
46+
for every caller but a D4-exempt one), so a name with no entry has no
47+
platform sort behind it and gets no affordance.
4548
2. **Dotted path** (`account.name`) — crosses into a related record no
4649
driver joins for. Refused. Encoded as absence too: entries are keyed by
4750
whole-column field names, and a dotted name can never appear as one.
@@ -62,6 +65,15 @@ measured degradation is not refused; the projection covers all four:
6265
so the entry stays `sortable: true` — the enforcement fact — and carries
6366
`caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative
6467
affordance without re-deriving federation from the document.
68+
5. **Withheld by the caller's field permission** (ADR-0106 D1/D4, #22250) —
69+
a field the caller's own field-level security marks unreadable. Refused
70+
for that caller by the data route's field guard (`403`: filtering or
71+
sorting on a hidden field would leak its values). Encoded as absence: no
72+
entry, whatever the caller's class — including a caller exempt from the
73+
object-schema mask (ADR-0106 D4: `manage_metadata`, `studio.access`,
74+
`setup.access`), who IS served the field in `item.fields` because
75+
authoring needs the whole definition. For every other caller the mask
76+
has already removed the field from `item`, so this is case 1's absence.
6577

6678
## Considered and deliberately NOT members
6779

@@ -132,7 +144,7 @@ const result = FieldSortabilitySchema.parse(data);
132144

133145
| Property | Type | Required | Description |
134146
| :--- | :--- | :--- | :--- |
135-
| **fields** | `Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`; a name absent from this map (an unknown field, a dotted path, an unprovisioned audit column) has no platform sort behind it and must get no sort affordance. |
147+
| **fields** | `Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. |
136148

137149
### Nested Shape: `ObjectSortability.fields[string]`
138150

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

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
normalizeIfNoneMatch,
1616
objectFieldVisibilityFingerprint,
1717
resolveObjectSchemaMaskPosture,
18+
resolveObjectSchemaRuntimeView,
1819
OBJECT_SCHEMA_MASK_DISABLE_ENV,
1920
OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES,
2021
OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC,
@@ -164,7 +165,10 @@ describe('[#7020] the D4 read exemption is derived from the write gate', () => {
164165
security: { getMetadataReadableFields: () => { asked++; throw new Error('must not be consulted'); } },
165166
enabled: true,
166167
});
167-
expect(posture).toEqual({ kind: 'passthrough', reason: 'exempt' });
168+
// [#22250] The posture carries the LAZY runtime question — resolving the
169+
// posture still asks nothing, and the definition still passes through.
170+
expect(posture).toMatchObject({ kind: 'passthrough', reason: 'exempt' });
171+
expect(posture.kind === 'passthrough' && typeof posture.runtime).toBe('function');
168172
expect(asked).toBe(0);
169173
});
170174

@@ -291,3 +295,136 @@ describe('[ADR-0106 D7] the metadata-plane query is preferred when the service o
291295
expect(posture).toMatchObject({ kind: 'project', readable: new Set(['id']) });
292296
});
293297
});
298+
299+
/**
300+
* [#22250] D4 exempts the DEFINITION (Studio/Setup authoring needs the whole
301+
* schema), not the runtime projections served beside it: those follow the
302+
* caller's own field permission, which the data route enforces for every
303+
* class. Pinned per caller class, with the member as the control.
304+
*/
305+
describe('[#22250] the D4 exemption covers the definition, not the runtime view', () => {
306+
const CLASSES = {
307+
'platform admin': { userId: 'u_platform_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] },
308+
'organization admin': { userId: 'u_org_admin', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'] },
309+
member: { userId: 'u_member', systemPermissions: [] as string[] },
310+
} as const;
311+
312+
const resolveFor = (context: unknown, readable: () => string[] | undefined, telemetry?: Parameters<typeof resolveObjectSchemaMaskPosture>[0]['telemetry']) => {
313+
let asked = 0;
314+
const security = { getMetadataReadableFields: () => { asked++; return readable(); } };
315+
return {
316+
posture: resolveObjectSchemaMaskPosture({ objectName: 'account', context, security, enabled: true, telemetry }),
317+
asked: () => asked,
318+
};
319+
};
320+
321+
for (const [label, context] of Object.entries(CLASSES)) {
322+
it(`${label}: the runtime view holds only the readable fields, whatever the definition serves`, async () => {
323+
const { posture: pending } = resolveFor(context, () => ['id']);
324+
const posture = await pending;
325+
const served = applyObjectSchemaMask(OBJECT, posture).document;
326+
const view = await resolveObjectSchemaRuntimeView(served, posture);
327+
328+
expect(Object.keys(view.document.fields)).toEqual(['id']);
329+
if (label === 'member') {
330+
// Control: the definition mask already projects a member, so the
331+
// runtime view IS the served document — same reference, nothing
332+
// withheld on top, no second fingerprint.
333+
expect(posture.kind).toBe('project');
334+
expect(Object.keys(served.fields)).toEqual(['id']);
335+
expect(view.document).toBe(served);
336+
expect(view.withheld).toEqual([]);
337+
expect(view.fingerprint).toBe('');
338+
} else {
339+
// The exempt definition stays whole, for authoring...
340+
expect(posture).toMatchObject({ kind: 'passthrough', reason: 'exempt' });
341+
expect(served).toBe(OBJECT);
342+
expect(Object.keys(served.fields)).toEqual(['id', 'salary_grade']);
343+
// ...and only the runtime view drops what the caller cannot read.
344+
expect(view.withheld).toEqual(['salary_grade']);
345+
expect(view.fingerprint).not.toBe('');
346+
}
347+
});
348+
}
349+
350+
it('one field permission, one runtime view, whatever the class', async () => {
351+
const views = await Promise.all(Object.values(CLASSES).map(async (context) => {
352+
const posture = await resolveFor(context, () => ['id']).posture;
353+
return (await resolveObjectSchemaRuntimeView(applyObjectSchemaMask(OBJECT, posture).document, posture)).document;
354+
}));
355+
for (const view of views) expect(view.fields).toEqual(views[2].fields);
356+
});
357+
358+
it('never shares a validator with the definition mask over the same denied set', async () => {
359+
const admin = await resolveFor(CLASSES['platform admin'], () => ['id']).posture;
360+
const member = await resolveFor(CLASSES.member, () => ['id']).posture;
361+
const adminView = await resolveObjectSchemaRuntimeView(OBJECT, admin);
362+
const memberMask = applyObjectSchemaMask(OBJECT, member);
363+
// Same denied field, different served bodies (the admin's carries the
364+
// field in `item`): the two fingerprints must differ or a 304 crosses over.
365+
expect(memberMask.fingerprint).toBe(objectFieldVisibilityFingerprint(['salary_grade']));
366+
expect(adminView.fingerprint).not.toBe(memberMask.fingerprint);
367+
});
368+
369+
it('an unrestricted exempt caller: same reference, empty fingerprint (D3 byte-identity)', async () => {
370+
const posture = await resolveFor(CLASSES['platform admin'], () => ['id', 'salary_grade']).posture;
371+
const view = await resolveObjectSchemaRuntimeView(OBJECT, posture);
372+
expect(view.document).toBe(OBJECT);
373+
expect(view.fingerprint).toBe('');
374+
});
375+
376+
it('asks the service lazily and at most once per posture, and never for a document without `fields`', async () => {
377+
const { posture: pending, asked } = resolveFor(CLASSES['platform admin'], () => ['id']);
378+
const posture = await pending;
379+
expect(asked()).toBe(0);
380+
await resolveObjectSchemaRuntimeView({ name: 'not_an_object_schema' }, posture);
381+
expect(asked()).toBe(0);
382+
await resolveObjectSchemaRuntimeView(OBJECT, posture);
383+
await resolveObjectSchemaRuntimeView(OBJECT, posture);
384+
expect(asked()).toBe(1);
385+
// The input is never mutated.
386+
expect(Object.keys(OBJECT.fields)).toEqual(['id', 'salary_grade']);
387+
});
388+
389+
it('`undefined` settles undetermined: the view is the served document, warned and counted once', async () => {
390+
const warns: unknown[] = [];
391+
const counters: unknown[] = [];
392+
const posture = await resolveFor(CLASSES['platform admin'], () => undefined, {
393+
warn: (m, meta) => warns.push([m, meta]),
394+
counter: (n, labels) => counters.push([n, labels]),
395+
}).posture;
396+
const view = await resolveObjectSchemaRuntimeView(OBJECT, posture);
397+
expect(view.document).toBe(OBJECT);
398+
expect(view.fingerprint).toBe('');
399+
expect(warns).toHaveLength(1);
400+
expect(counters).toEqual([[OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, { object: 'account' }]]);
401+
});
402+
403+
it('a throwing service never faults the exempt caller (D4) — the view settles undetermined, warned', async () => {
404+
const warns: unknown[] = [];
405+
const posture = await resolveObjectSchemaMaskPosture({
406+
objectName: 'account',
407+
context: CLASSES['platform admin'],
408+
security: { getMetadataReadableFields: () => { throw new Error('security down'); } },
409+
enabled: true,
410+
telemetry: { warn: (m, meta) => warns.push([m, meta]) },
411+
});
412+
const view = await resolveObjectSchemaRuntimeView(OBJECT, posture);
413+
expect(view.document).toBe(OBJECT);
414+
expect(view.fingerprint).toBe('');
415+
expect(warns).toHaveLength(1);
416+
});
417+
418+
it('`isSystem` and a deployment with no security service carry no runtime question', async () => {
419+
const system = await resolveFor({ isSystem: true }, () => ['id']);
420+
const systemPosture = await system.posture;
421+
expect(systemPosture).toEqual({ kind: 'passthrough', reason: 'exempt' });
422+
expect((await resolveObjectSchemaRuntimeView(OBJECT, systemPosture)).document).toBe(OBJECT);
423+
expect(system.asked()).toBe(0);
424+
425+
const noService = await resolveObjectSchemaMaskPosture({
426+
objectName: 'account', context: CLASSES['platform admin'], security: undefined, enabled: true,
427+
});
428+
expect(noService).toEqual({ kind: 'passthrough', reason: 'exempt' });
429+
});
430+
});

0 commit comments

Comments
 (0)