diff --git a/.changeset/metadata-plane-fls-object-schema-masking.md b/.changeset/metadata-plane-fls-object-schema-masking.md new file mode 100644 index 0000000000..5af9156fbb --- /dev/null +++ b/.changeset/metadata-plane-fls-object-schema-masking.md @@ -0,0 +1,96 @@ +--- +"@objectstack/metadata-core": minor +"@objectstack/plugin-security": minor +"@objectstack/runtime": minor +"@objectstack/rest": minor +--- + +feat(meta): object schemas served by `/meta` and `/metadata` are masked per caller (ADR-0106, #3682) + +The data plane has enforced field-level security everywhere it matters for +several releases — list reads mask values, exports project columns, and the +write path 403s forbidden fields. The **metadata** plane did not: any +authenticated caller who asked `GET /meta/object/:name` received the full object +schema, including fields they have no read access to at all. + +That is more than a list of names. A field carries its label, type, **picklist +option values** (often a sensitive operational taxonomy), its **formula** +expression (pricing and scoring IP), its `visibleWhen` predicate, its +`defaultValue`, and — via ADR-0066 D3 — the `requiredPermissions` capability +names guarding it. For a customer running a dealer, supplier or patient portal +on ObjectStack, the only remediation available in their own tier was modelling +discipline: keep sensitive fields off portal-visible objects, or split one +business entity into an internal object and a portal object and synchronize +them. This is a platform-side fix, so every deployment inherits it. + +**What changes.** Serving an object schema now projects `fields` onto the set +the caller may read, and a field outside that set is removed **whole** — no +name, no label, no options, no formula, no `requiredPermissions`. Partial +redaction was rejected: keeping the name still leaks existence and invites +clients to render ghost columns. Masking keys on the `readable` bit only; a +readable-but-not-editable field stays in the schema, because the UI must render +it and the `editable` affordance is already served per caller by +`/auth/me/permissions`. + +Every outlet that serves an object schema goes through one shared projection, +so coverage is not a per-route promise: + +- `GET /meta/object/:name` — the cached branch (the default) **and** the + uncached branch, which is what `?state=draft`, `?preview=draft` and + `?package=` take; +- `GET /meta/object/:name?layers=true` — the layered diagnostic view, all three + of `code` / `overlay` / `effective`; +- `GET /meta/:type/:section/:name` — the compound-name read; +- `GET /meta/object` — the list read, each item projected independently; +- the runtime `/metadata` catch-all — the protocol-backed, registry-backed and + last-ditch single reads, the `/metadata/objects` list (protocol and registry), + and the legacy one-segment `/metadata/:objectName` spelling. + +**Caching is unchanged in cost and correct per cohort.** The shared metadata +cache still stores one full schema per (type, name, locale, environment) — no +caller dimension in the key — and the mask runs after retrieval. What varies +per caller is the validator: a stable hash of the caller's *denied* field set is +folded into the ETag. A caller who can read everything denies nothing, so their +fingerprint is empty and both their ETag and their response body are +**byte-identical** to previous releases. Callers in one permission cohort share +`304`s; a permission change moves the fingerprint and self-invalidates the stale +`304`, so nothing needs purging after a permission-set edit. + +**Exemptions** are a property of the caller, not of the route: `isSystem` and +platform-admin callers (holders of `studio.access` / `setup.access`, the same +judgement the app filter uses) receive the full schema on any route, because +Studio and Setup authoring cannot work against a projected schema. + +**Failure posture is explicit and three-tiered.** With no `security` service +registered the schema is served unmasked — that deployment has no FLS posture at +all and tightening only the metadata plane would be theater. When field +visibility cannot be *determined* (a registry-hydration window), the schema is +served unmasked but loudly: a structured warning, a new +`objectstack_meta_field_visibility_undetermined_total` counter, and a response +downgraded to `Cache-Control: private, no-store` with no shared ETag. Failing +closed there would brick every render of the object for every user and can +deadlock console bootstrap, since permission sets are themselves metadata. When +permission evaluation **throws**, the request fails with `503 +FIELD_VISIBILITY_UNRESOLVED` — an unhealthy security service must not auto-open +a disclosure hole, and an empty-fields `200` would be both a silently wrong UI +and cacheable poison. + +**Guest and public deployments** get a deliberate posture rather than an +accidental one: `@objectstack/plugin-security` gains +`getMetadataReadableFields`, which resolves the configured fallback permission +set (`security.fallbackPermissionSet`, default `member_default`) for a caller +who resolves to zero sets, exactly as `/auth/me/permissions` does. +`getReadableFields` is unchanged — on the data plane, mirroring the engine +middleware's fall-open is what keeps it drift-free. + +**Escape hatch.** Masking is the platform default. A deployment that explicitly +wants an unmasked metadata plane sets `OS_ALLOW_UNMASKED_OBJECT_METADATA=1`, or +`metadata.maskObjectFields: false` on the REST server. Toggling it changes +disclosure only: the console reads every field affordance from +`/auth/me/permissions`, so UI correctness is unaffected either way. + +Operators fronting the runtime with a CDN or reverse proxy should read the new +"CDN / reverse-proxy caching of `/meta` object schemas" section in the +production-readiness guide before tuning anything — in particular, do not +configure a proxy to ignore `Cache-Control: private`, and do not strip or +rewrite `ETag` on these routes. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 1fb329d4f4..4656824f84 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -301,6 +301,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from |:---|:---|:---|:---| | `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. | | `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). | +| `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. | | `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,validation`) granted a runtime escape hatch that treats them as `allowOrgOverride: true`, letting artifact-backed items of those protected types be overridden per-org outside their static registry declaration. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | --- diff --git a/content/docs/deployment/production-readiness.mdx b/content/docs/deployment/production-readiness.mdx index 3aeb36d9bd..d97980a5a8 100644 --- a/content/docs/deployment/production-readiness.mdx +++ b/content/docs/deployment/production-readiness.mdx @@ -104,6 +104,53 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/ma compliance regime needs longer, and register an `archive` datasource if audit data must move to cold storage instead of being retained hot. +## CDN / reverse-proxy caching of `/meta` object schemas + +Object schemas are the hottest metadata read — every list, form and detail +render fetches one — so they are the first thing an operator reaches for when +putting a CDN or reverse proxy in front of the runtime. Since +[ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) +those responses are **per-caller**, and the rules below are what keep that safe. + +- **The response is already marked private.** `GET /meta/object/:name` answers + `Cache-Control: private, no-cache` — store it in the *client*, revalidate on + every use, never in a shared cache. A proxy configured to ignore `private` + (`proxy_ignore_headers Cache-Control`, an "override TTL" rule, an edge worker + that caches by URL alone) would serve one user's projection to another. Do + not add such a rule for `/meta` or `/metadata`. +- **The ETag carries the caller's field visibility.** The validator is the + shared document hash plus, for a restricted caller, a `~` suffix + hashing the set of fields that caller cannot read. Consequences worth knowing + before tuning anything: + - A caller who can read every field produces **no** suffix, so their ETag and + body are byte-identical to pre-ADR-0106 releases — existing cache hit rates + on unrestricted deployments are unchanged. + - Callers in the same permission cohort share one validator, and therefore + share `304`s. Cache storage stays O(objects), not O(users × objects). + - A permission change moves the fingerprint, so a stale `304` self-invalidates + on the next revalidation. There is nothing to purge after editing a + permission set. + - **Never strip or rewrite `ETag`** on these routes (some proxies drop it when + they compress). Without it every read falls back to a full body, and a proxy + that *replaces* it with its own hash of the served body erases the cohort + dimension. +- **A `private, no-store` response means field visibility could not be + resolved.** In that degraded window the runtime serves the unmasked schema + deliberately — failing closed would brick every render of the object and can + deadlock console bootstrap, since permission sets are themselves metadata — + and marks it un-storable so it cannot be replayed to anyone else. It also + emits a structured warning and increments + `objectstack_meta_field_visibility_undetermined_total`. **Alert on that + counter**: a deployment sitting in this state is disclosing more than it + intends, and it is meant to be a hydration blip, not a resting state. +- **A `503 FIELD_VISIBILITY_UNRESOLVED` is the security service being + unhealthy**, not the metadata store. The runtime refuses the read rather than + disclose an unmasked schema. It is safe to retry and must not be cached. +- To deliberately restore the old, unmasked behaviour on a trusted deployment, + the supported switch is `OS_ALLOW_UNMASKED_OBJECT_METADATA=1` (or per-server + `metadata.maskObjectFields: false`) — not a caching rule that hides the + per-caller dimension. + ## What's NOT in the runtime (yet) - **OTel context propagation.** We export `parseTraceparent` / diff --git a/docs/adr/0106-metadata-plane-fls-object-schema-masking.md b/docs/adr/0106-metadata-plane-fls-object-schema-masking.md index 660b9f2c91..b8375ca722 100644 --- a/docs/adr/0106-metadata-plane-fls-object-schema-masking.md +++ b/docs/adr/0106-metadata-plane-fls-object-schema-masking.md @@ -1,6 +1,6 @@ # ADR-0106: Metadata-Plane Field-Level Security — Per-Caller Masking of Object Schemas -**Status**: Proposed (2026-07-27) +**Status**: Accepted (2026-07-27; implemented 2026-08-08 — #3682) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove, fail-posture discipline), [ADR-0066](./0066-unified-authorization-model.md) (unified authz; D3 field `requiredPermissions` — mask on read, deny on write), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (permission set as the only capability container; `readable`/`editable` FLS bits), [ADR-0046](./0046-package-docs-as-metadata.md) (§6.7 audience gate — the existing per-caller metadata read gate this ADR generalizes from) **Tracking**: #3661 (steps ① ② shipped client-side as objectui#2866; this ADR is step ③) diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index aba553c99a..85882bdfdd 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -35,3 +35,12 @@ export * from './engine-update-dispatch.js'; // surface and the write path now derive one answer from one table instead of // reporting two. export * from './audit-field-governance.js'; + +// [ADR-0106 / #3682] The metadata-plane FLS projection — one masking function +// and one fingerprint, shared by every object-schema exit in +// `@objectstack/rest` and `@objectstack/runtime`. Sunk here by the same +// criterion as the governance table above: the exits live in two dispatch +// packages that share no other common home, and D5 ("every schema-serving +// outlet, or the mask is decoration") is only true if they all run the same +// projection rather than a copy each. +export * from './object-schema-fls.js'; diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts new file mode 100644 index 0000000000..bd4b7b4e26 --- /dev/null +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106 / #3682] The metadata-plane FLS contract, as ONE case table every + * schema-serving exit is driven through. + * + * ## Why this is shared rather than per-suite + * + * ADR-0106 D5 states the invariant negatively — "every schema-serving outlet, + * or the mask is decoration" — and the exits it names live in two packages and + * six code paths: `@objectstack/rest`'s single cached read, single uncached + * read, layered read, compound-name read and list read, plus + * `@objectstack/runtime`'s `/metadata` catch-all (protocol-backed, + * registry-backed, last-ditch, list, and the legacy one-segment spelling). + * A per-suite table would let a new exit ship with no coverage and nothing + * would go red; driving them all from this one means a forgotten exit fails + * **by name**. + * + * The invariant itself is one sentence: for a restricted caller, an unreadable + * field is COMPLETELY ABSENT from every exit — no third, quieter answer (not a + * name with the details stripped, not a `null`, not a 200 with empty `fields`). + * + * Same shape, and the same reason, as `contract-suite.ts` next door: one + * contract, several implementations, one table. + */ + +/** The object schema every exit serves while the contract runs. */ +export const FLS_CONTRACT_OBJECT = { + name: 'account', + label: 'Account', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + // Everything ADR-0106's Context section names as leaking with the field: + // a sensitive enumeration, the capability guarding it, and a formula + // that is itself business IP. + salary_grade: { + type: 'select', + label: 'Salary Grade', + options: [{ value: 'band_a', label: 'Band A' }, { value: 'band_b', label: 'Band B' }], + requiredPermissions: ['view_compensation'], + }, + bonus_formula: { + type: 'formula', + label: 'Bonus', + formula: 'salary_grade == "band_a" ? 0.2 : 0.1', + visibleWhen: 'record.status == "active"', + }, + }, +} as const; + +/** Every field name {@link FLS_CONTRACT_OBJECT} declares. */ +export const FLS_CONTRACT_ALL_FIELDS = ['id', 'name', 'salary_grade', 'bonus_formula'] as const; + +/** What the exit's `security` double answers, or that it throws. */ +export type FlsContractReadable = readonly string[] | undefined | 'throw'; + +/** The one verdict every exit must reach for a case. */ +export type FlsContractVerdict = + /** These field names are present; those are COMPLETELY absent. */ + | { kind: 'fields'; present: readonly string[]; absent: readonly string[] } + /** Every declared field survives — the passthrough tiers (D4 exemptions, D6 tier 1/2, D8). */ + | { kind: 'unmasked' } + /** D6 tier 3 — the exit refuses: 5xx, no body carrying `fields`. */ + | { kind: 'fault' }; + +export interface ObjectSchemaMaskCase { + /** Stable id — this is what a forgotten exit fails by. */ + readonly id: string; + /** Why this row exists, in the ADR's terms. */ + readonly why: string; + /** The caller's execution context, as the exit resolves it. */ + readonly context: Record; + /** What `security.getMetadataReadableFields` answers for `account`. */ + readonly readable: FlsContractReadable; + /** ADR-0106 D8 — masking off for this deployment. */ + readonly maskingDisabled?: boolean; + readonly expect: FlsContractVerdict; +} + +/** + * The ADR-0106 case table. + * + * Ordered by tier, not by convenience: the projection first, then the three D6 + * failure postures, then the D4 exemptions, then the D7 and D8 knobs. + */ +export const OBJECT_SCHEMA_MASK_CASES: readonly ObjectSchemaMaskCase[] = [ + { + id: 'restricted-caller/field-vanishes-whole', + why: 'D1 — an unreadable field is removed whole: name, label, type, options, formula, visibleWhen and requiredPermissions all go with it.', + context: { userId: 'u_portal', systemPermissions: [] }, + readable: ['id', 'name'], + expect: { kind: 'fields', present: ['id', 'name'], absent: ['salary_grade', 'bonus_formula'] }, + }, + { + id: 'restricted-caller/required-permissions-cause', + why: 'D1 — the two causes of unreadability (an explicit `readable:false` and a missing `requiredPermissions` capability) are already folded together by `getReadableFields`, so an exit sees one answer and must not distinguish them.', + context: { userId: 'u_portal', systemPermissions: [] }, + readable: ['id', 'name', 'bonus_formula'], + expect: { kind: 'fields', present: ['id', 'name', 'bonus_formula'], absent: ['salary_grade'] }, + }, + { + id: 'unrestricted-caller/byte-identical', + why: 'D3 — a caller who denies nothing gets the pre-ADR response: every field, no fingerprint, no ETag change.', + context: { userId: 'u_staff', systemPermissions: [] }, + readable: [...FLS_CONTRACT_ALL_FIELDS], + expect: { kind: 'unmasked' }, + }, + { + id: 'no-security-service/tier-1', + why: 'D6 tier 1 — the deployment has no FLS posture at all; the data plane does not mask either, so tightening the metadata plane alone would be theater.', + context: { userId: 'u_staff', systemPermissions: [] }, + readable: undefined, + expect: { kind: 'unmasked' }, + }, + { + id: 'undetermined/tier-2', + why: 'D6 tier 2 — the field universe is unresolvable (registry hydration). Serve unmasked rather than brick every render of the object, but loudly and without a shared validator.', + context: { userId: 'u_staff', systemPermissions: [] }, + readable: undefined, + expect: { kind: 'unmasked' }, + }, + { + id: 'evaluation-throws/tier-3', + why: 'D6 tier 3 — an unhealthy security service must not auto-open a disclosure hole. The exit refuses; it never falls back to the cached full body.', + context: { userId: 'u_portal', systemPermissions: [] }, + readable: 'throw', + expect: { kind: 'fault' }, + }, + { + id: 'empty-readable-set/no-empty-fields-200', + why: 'D6 — `getReadableFields` answers `[]` only where its own posture read failed closed (#3545). An empty-fields 200 is "silently wrong UI AND cacheable poison", so the exit refuses instead.', + context: { userId: 'u_portal', systemPermissions: [] }, + readable: [], + expect: { kind: 'fault' }, + }, + { + id: 'is-system/exempt', + why: 'D4 — `isSystem` bypasses, and the exemption is a CALLER property: it short-circuits before the security service is consulted at all.', + context: { isSystem: true }, + readable: ['id'], + expect: { kind: 'unmasked' }, + }, + { + id: 'platform-admin/exempt', + why: 'D4 — Studio/Setup authoring needs the full schema; judged by the same `systemPermissions` reading the `app` filter uses.', + context: { userId: 'u_admin', systemPermissions: ['studio.access'] }, + readable: ['id'], + expect: { kind: 'unmasked' }, + }, + { + id: 'guest-fallback/D7', + why: 'D7 — a caller resolving to zero permission sets goes through the fallback set rather than the everything-default; the exit sees whatever that resolution answers and projects it like any other. (The resolution itself is pinned in plugin-security; a truly ANONYMOUS caller never reaches an exit on a requireAuth deployment, which D7 says in as many words.)', + context: { userId: 'u_guest', positions: [], permissions: [], systemPermissions: [] }, + readable: ['id', 'name'], + expect: { kind: 'fields', present: ['id', 'name'], absent: ['salary_grade', 'bonus_formula'] }, + }, + { + id: 'masking-disabled/D8', + why: 'D8 — the escape hatch opts a deployment out of the metadata-plane mask entirely; the security service is not consulted.', + context: { userId: 'u_portal', systemPermissions: [] }, + readable: ['id'], + maskingDisabled: true, + expect: { kind: 'unmasked' }, + }, +]; + +/** What an exit answered when the contract drove it. */ +export type ObjectSchemaMaskOutcome = + | { kind: 'document'; document: unknown } + | { kind: 'fault'; status: number }; + +/** One schema-serving outlet under test. */ +export interface ObjectSchemaMaskExit { + /** Human name — this is what a broken exit is reported as. */ + readonly name: string; + /** Serve {@link FLS_CONTRACT_OBJECT} through this outlet under `testCase`. */ + run(testCase: ObjectSchemaMaskCase): Promise; +} + +/** Pull the served `fields` record out of whatever envelope an outlet answers. */ +function servedFields(document: unknown): Record | undefined { + if (!document || typeof document !== 'object') return undefined; + const rec = document as Record; + const fields = rec.fields; + if (fields && typeof fields === 'object' && !Array.isArray(fields)) return fields as Record; + return undefined; +} + +/** + * Assert one exit's answer against one case. + * + * Framework-free on purpose (throws plain `Error`s) so the table can be driven + * from a vitest suite in either package without this module importing vitest — + * `contract-suite.ts` pays that import cost because it *is* a suite; this is a + * matcher. + */ +export function assertObjectSchemaMaskCase( + exitName: string, + testCase: ObjectSchemaMaskCase, + outcome: ObjectSchemaMaskOutcome, +): void { + const where = `${exitName} :: ${testCase.id}`; + if (testCase.expect.kind === 'fault') { + if (outcome.kind !== 'fault') { + throw new Error( + `${where}: expected the exit to REFUSE (5xx) but it served a body. ${testCase.why}`, + ); + } + if (outcome.status < 500) { + throw new Error(`${where}: expected a 5xx refusal, got ${outcome.status}. ${testCase.why}`); + } + return; + } + + if (outcome.kind === 'fault') { + throw new Error(`${where}: expected a served body, got a ${outcome.status} refusal. ${testCase.why}`); + } + const fields = servedFields(outcome.document); + if (!fields) { + throw new Error(`${where}: the served body carries no \`fields\` record — ${JSON.stringify(outcome.document)}`); + } + + const expected = testCase.expect.kind === 'unmasked' + ? { present: [...FLS_CONTRACT_ALL_FIELDS], absent: [] as readonly string[] } + : testCase.expect; + + for (const name of expected.present) { + if (!(name in fields)) { + throw new Error(`${where}: expected field '${name}' to be served, it was not. ${testCase.why}`); + } + } + for (const name of expected.absent) { + if (name in fields) { + throw new Error( + `${where}: field '${name}' must be COMPLETELY ABSENT for this caller, but the exit served it. ${testCase.why}`, + ); + } + // The whole-field rule: no residue anywhere in the serialized document. + // A partial redaction (a name kept, the details stripped) still leaks + // existence, which D1 rules out in as many words. + if (JSON.stringify(outcome.document).includes(`"${name}"`)) { + throw new Error( + `${where}: '${name}' is gone from \`fields\` but still appears elsewhere in the served document — D1 removes the field WHOLE.`, + ); + } + } +} + +/** + * The `security` service double a case implies. + * + * Registers `getMetadataReadableFields` (ADR-0106 D7's entry point) AND + * `getReadableFields` at the same answer, so an exit that feature-detects + * either one is driven identically — the fallback path is exercised by the + * dedicated plugin-security suite, not by making outlets disagree here. + * Returns `undefined` for the no-service tier so the caller can register + * nothing at all. + */ +export function securityDoubleFor(testCase: ObjectSchemaMaskCase): Record | undefined { + if (testCase.id === 'no-security-service/tier-1') return undefined; + const answer = () => { + if (testCase.readable === 'throw') throw new Error('security service unhealthy (test)'); + return testCase.readable === undefined ? undefined : [...testCase.readable]; + }; + return { + getReadableFields: async () => answer(), + getMetadataReadableFields: async () => answer(), + }; +} diff --git a/packages/metadata-core/src/object-schema-fls.test.ts b/packages/metadata-core/src/object-schema-fls.test.ts new file mode 100644 index 0000000000..5f0a1a7c6c --- /dev/null +++ b/packages/metadata-core/src/object-schema-fls.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106 / #3682] The shared projection's own properties — the ones the + * per-exit contract suites consume rather than re-derive. + */ + +import { describe, it, expect } from 'vitest'; +import { + ObjectSchemaMaskEvaluationError, + applyObjectSchemaMask, + foldVisibilityFingerprintIntoEtag, + isObjectSchemaMaskExempt, + isObjectSchemaMaskingEnabled, + normalizeIfNoneMatch, + objectFieldVisibilityFingerprint, + resolveObjectSchemaMaskPosture, + OBJECT_SCHEMA_MASK_DISABLE_ENV, + OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, + type ObjectSchemaMaskPosture, +} from './object-schema-fls.js'; + +const OBJECT = { + name: 'account', + label: 'Account', + fields: { + id: { type: 'text' }, + salary_grade: { type: 'select', options: [{ value: 'a' }], requiredPermissions: ['view_comp'] }, + }, +}; + +const project = (readable: string[]): ObjectSchemaMaskPosture => ({ kind: 'project', readable: new Set(readable) }); + +describe('[ADR-0106 D1] applyObjectSchemaMask', () => { + it('removes an unreadable field WHOLE — options and requiredPermissions go with it', () => { + const { document, denied } = applyObjectSchemaMask(OBJECT, project(['id'])); + + expect(Object.keys((document as any).fields)).toEqual(['id']); + expect(denied).toEqual(['salary_grade']); + expect(JSON.stringify(document)).not.toContain('view_comp'); + expect(JSON.stringify(document)).not.toContain('salary_grade'); + }); + + it('never mutates its input — the shared cache entry stays full', () => { + const source = JSON.parse(JSON.stringify(OBJECT)); + applyObjectSchemaMask(source, project(['id'])); + expect(Object.keys(source.fields)).toEqual(['id', 'salary_grade']); + }); + + it('returns the SAME REFERENCE when nothing is denied — an unrestricted caller pays no copy', () => { + const result = applyObjectSchemaMask(OBJECT, project(['id', 'salary_grade'])); + expect(result.document).toBe(OBJECT); + expect(result.fingerprint).toBe(''); + expect(result.denied).toEqual([]); + }); + + it('is a no-op for every passthrough posture', () => { + for (const reason of ['disabled', 'no-service', 'exempt', 'not-applicable'] as const) { + const result = applyObjectSchemaMask(OBJECT, { kind: 'passthrough', reason }); + expect(result.document).toBe(OBJECT); + } + expect(applyObjectSchemaMask(OBJECT, { kind: 'undetermined' }).document).toBe(OBJECT); + }); + + it('tolerates any input — a document with no `fields` record is returned untouched', () => { + for (const input of [undefined, null, 42, 'x', [], { name: 'x' }, { fields: [] }]) { + expect(applyObjectSchemaMask(input as any, project([])).document).toBe(input); + } + }); + + it('flags `emptied` when the projection would leave no fields at all', () => { + expect(applyObjectSchemaMask(OBJECT, project([])).emptied).toBe(true); + expect(applyObjectSchemaMask(OBJECT, project(['id'])).emptied).toBe(false); + // A source that declares none was never a lie to begin with. + expect(applyObjectSchemaMask({ name: 'x', fields: {} }, project([])).emptied).toBe(false); + }); +}); + +describe('[ADR-0106 D3] visibility fingerprint', () => { + it('is empty for a caller who denies nothing', () => { + expect(objectFieldVisibilityFingerprint([])).toBe(''); + }); + + it('is order-independent — one cohort, one hash', () => { + expect(objectFieldVisibilityFingerprint(['b', 'a'])).toBe(objectFieldVisibilityFingerprint(['a', 'b'])); + }); + + it('separates cohorts', () => { + expect(objectFieldVisibilityFingerprint(['a'])).not.toBe(objectFieldVisibilityFingerprint(['a', 'b'])); + // Not merely a concatenation — `['ab']` and `['a','b']` must differ. + expect(objectFieldVisibilityFingerprint(['ab'])).not.toBe(objectFieldVisibilityFingerprint(['a', 'b'])); + }); + + it('folds into an ETag only when non-empty — that is the byte-identical guarantee', () => { + expect(foldVisibilityFingerprintIntoEtag('v1', '')).toBe('v1'); + expect(foldVisibilityFingerprintIntoEtag('v1', 'deadbeef')).toBe('v1~deadbeef'); + }); + + it('normalizeIfNoneMatch strips weak markers and quotes the way the protocol does', () => { + expect(normalizeIfNoneMatch('"v1~deadbeef"')).toBe('v1~deadbeef'); + expect(normalizeIfNoneMatch('W/"v1"')).toBe('v1'); + expect(normalizeIfNoneMatch(' ')).toBeUndefined(); + expect(normalizeIfNoneMatch(undefined)).toBeUndefined(); + }); +}); + +describe('[ADR-0106 D4] exemptions are caller properties', () => { + it('exempts `isSystem` and the two builder capabilities, nothing else', () => { + expect(isObjectSchemaMaskExempt({ isSystem: true })).toBe(true); + expect(isObjectSchemaMaskExempt({ systemPermissions: ['studio.access'] })).toBe(true); + expect(isObjectSchemaMaskExempt({ systemPermissions: ['setup.access'] })).toBe(true); + expect(isObjectSchemaMaskExempt({ systemPermissions: ['manage_users'] })).toBe(false); + expect(isObjectSchemaMaskExempt({ userId: 'u' })).toBe(false); + expect(isObjectSchemaMaskExempt(undefined)).toBe(false); + }); +}); + +describe('[ADR-0106 D8] escape hatch', () => { + it('defaults ON', () => { + expect(isObjectSchemaMaskingEnabled(undefined, {})).toBe(true); + expect(isObjectSchemaMaskingEnabled(true, {})).toBe(true); + }); + + it('honours the config key and the environment override', () => { + expect(isObjectSchemaMaskingEnabled(false, {})).toBe(false); + expect(isObjectSchemaMaskingEnabled(undefined, { [OBJECT_SCHEMA_MASK_DISABLE_ENV]: '1' })).toBe(false); + // A falsy-looking value is not an opt-out — a deliberate override has + // to look deliberate. + expect(isObjectSchemaMaskingEnabled(undefined, { [OBJECT_SCHEMA_MASK_DISABLE_ENV]: '0' })).toBe(true); + expect(isObjectSchemaMaskingEnabled(undefined, { [OBJECT_SCHEMA_MASK_DISABLE_ENV]: 'false' })).toBe(true); + expect(isObjectSchemaMaskingEnabled(undefined, { [OBJECT_SCHEMA_MASK_DISABLE_ENV]: '' })).toBe(true); + }); +}); + +describe('[ADR-0106 D6] three-tier failure posture', () => { + const base = { objectName: 'account', context: { userId: 'u' }, enabled: true }; + + it('tier 1 — no security service → passthrough', async () => { + expect(await resolveObjectSchemaMaskPosture({ ...base, security: undefined })) + .toEqual({ kind: 'passthrough', reason: 'no-service' }); + // A service that implements neither method is the same tier. + expect(await resolveObjectSchemaMaskPosture({ ...base, security: {} })) + .toEqual({ kind: 'passthrough', reason: 'no-service' }); + }); + + it('tier 2 — `undefined` → undetermined, with a structured warn AND a metric', async () => { + const warns: Array<[string, unknown]> = []; + const counters: Array<[string, unknown]> = []; + const posture = await resolveObjectSchemaMaskPosture({ + ...base, + security: { getReadableFields: () => undefined }, + telemetry: { + warn: (m, meta) => warns.push([m, meta]), + counter: (n, labels) => counters.push([n, labels]), + }, + }); + + expect(posture).toEqual({ kind: 'undetermined' }); + expect(warns).toHaveLength(1); + expect(warns[0][0]).toContain('ADR-0106'); + expect(warns[0][1]).toMatchObject({ object: 'account' }); + expect(counters).toEqual([[OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, { object: 'account' }]]); + }); + + it('tier 3 — a throwing service becomes ObjectSchemaMaskEvaluationError, never a posture', async () => { + const boom = new Error('security down'); + await expect(resolveObjectSchemaMaskPosture({ + ...base, + security: { getReadableFields: () => { throw boom; } }, + })).rejects.toBeInstanceOf(ObjectSchemaMaskEvaluationError); + + const error = await resolveObjectSchemaMaskPosture({ + ...base, + security: { getReadableFields: () => { throw boom; } }, + }).catch((e) => e); + expect(error.objectName).toBe('account'); + expect(error.evaluationError).toBe(boom); + }); + + it('an EXEMPT caller short-circuits before the service is consulted — a sick service cannot fault them', async () => { + let asked = false; + const posture = await resolveObjectSchemaMaskPosture({ + ...base, + context: { isSystem: true }, + security: { getReadableFields: () => { asked = true; throw new Error('boom'); } }, + }); + expect(posture).toEqual({ kind: 'passthrough', reason: 'exempt' }); + expect(asked).toBe(false); + }); + + it('D8 disabled short-circuits before the exemption check and the service alike', async () => { + let asked = false; + const posture = await resolveObjectSchemaMaskPosture({ + ...base, + enabled: false, + security: { getReadableFields: () => { asked = true; return ['id']; } }, + }); + expect(posture).toEqual({ kind: 'passthrough', reason: 'disabled' }); + expect(asked).toBe(false); + }); +}); + +describe('[ADR-0106 D7] the metadata-plane query is preferred when the service offers it', () => { + it('prefers `getMetadataReadableFields` over `getReadableFields`', async () => { + const posture = await resolveObjectSchemaMaskPosture({ + objectName: 'account', + context: {}, + enabled: true, + security: { + getReadableFields: () => ['id', 'salary_grade'], + getMetadataReadableFields: () => ['id'], + }, + }); + expect(posture).toEqual({ kind: 'project', readable: new Set(['id']) }); + }); + + it('falls back to `getReadableFields` on a service that predates D7', async () => { + const posture = await resolveObjectSchemaMaskPosture({ + objectName: 'account', + context: {}, + enabled: true, + security: { getReadableFields: () => ['id'] }, + }); + expect(posture).toEqual({ kind: 'project', readable: new Set(['id']) }); + }); +}); diff --git a/packages/metadata-core/src/object-schema-fls.ts b/packages/metadata-core/src/object-schema-fls.ts new file mode 100644 index 0000000000..9efa18ff60 --- /dev/null +++ b/packages/metadata-core/src/object-schema-fls.ts @@ -0,0 +1,377 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106] Metadata-plane field-level security — the **one** projection every + * object-schema exit runs before it answers (objectstack#3682, from #3661 ③). + * + * ## What this closes + * + * The data plane enforces FLS everywhere it matters: list reads mask values, + * exports project columns (#3547), and the write path 403s forbidden fields. + * The metadata plane did not — `GET /meta/object/:name`, `GET /meta/object` and + * the runtime `/metadata` catch-all shipped the **full** object schema to any + * authenticated caller. That is not merely field names: a field carries its + * label, type, picklist option values, `formula` expression, `visibleWhen` + * predicate, `defaultValue`, and — via ADR-0066 D3 — the `requiredPermissions` + * capability names guarding it. ADR-0106 D1 removes an unreadable field + * **whole**; partial redaction still leaks existence and invites clients to + * render ghost columns. + * + * ## Why the shared normalizer lives here + * + * Same criterion as {@link applyAuditFieldGovernance} (#4513) and the two + * engine dispatch predicates (#5619): the exits are spread across + * `@objectstack/rest` (three) and `@objectstack/runtime` (four), and a + * per-route copy of the projection is the drift this repo keeps paying for — + * ADR-0106 D5 says so in as many words ("every schema-serving outlet, or the + * mask is decoration"). `@objectstack/metadata-core` depends on + * `{ @objectstack/spec, zod }` only, so both dispatch packages can import it + * with no new edge and no cycle. + * + * ## The shape of one exit + * + * ```ts + * const posture = await resolveObjectSchemaMaskPosture({ objectName, context, security, enabled }); + * const masked = applyObjectSchemaMask(document, posture); // fetch → mask → send + * ``` + * + * `resolveObjectSchemaMaskPosture` is where ADR-0106 D6's three-tier failure + * posture is decided, ONCE, so no exit can invent a fourth answer: + * + * | Condition | Posture | Wire effect | + * |---|---|---| + * | masking disabled (D8 escape hatch) / no `security` service / exempt caller (D4) | `passthrough` | serve unmasked, byte-identical to pre-ADR | + * | `getReadableFields` → `undefined` (field universe unresolvable) | `undetermined` | serve unmasked + structured warn + metric + `Cache-Control: private, no-store`, **no shared ETag** | + * | `getReadableFields` throws | throws {@link ObjectSchemaMaskEvaluationError} | the exit answers 5xx — never the unmasked body, never an empty-fields 200 | + * | otherwise | `project` | fields ∉ readable are deleted whole | + * + * ## D3 — mask AFTER the cache, fingerprint the ETag + * + * The shared metadata cache keeps storing ONE full schema per + * (type, name, locale, environment) — no caller dimension in the key, so cache + * storage stays O(objects) rather than O(users × objects). What varies per + * caller is the **validator**: {@link objectFieldVisibilityFingerprint} hashes + * the caller's *denied* set and {@link foldVisibilityFingerprintIntoEtag} folds + * it into the ETag. An unrestricted caller denies nothing, so the fingerprint + * is the empty string and the ETag is byte-identical to today's; callers in one + * permission cohort share 304s; a permission change moves the fingerprint and + * self-invalidates the stale 304. + */ + +/** + * The `systemPermissions` capabilities that exempt a caller from the mask + * (ADR-0106 D4). + * + * These are exactly the two the `app` filter treats as "builder" — Studio and + * Setup authoring cannot work against a projected schema, and draft/preview + * reads are admin-gated upstream already. The exemption is a **caller** + * property, not a route property: an exempt caller hitting the public route + * gets the full schema, and a non-exempt caller gets the projection on every + * route. + */ +export const OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES: readonly string[] = ['studio.access', 'setup.access']; + +/** + * Environment escape hatch for ADR-0106 D8 — a deployment that explicitly wants + * an unmasked metadata plane. + * + * Named per AGENTS.md Prime Directive #9's "escape hatch / dangerous override" + * shape (`OS_ALLOW_{X}`): deliberately ungrouped and scary-looking, because + * turning it on re-opens a disclosure hole on purpose. + */ +export const OBJECT_SCHEMA_MASK_DISABLE_ENV = 'OS_ALLOW_UNMASKED_OBJECT_METADATA'; + +/** Why an exit is serving the document unprojected. */ +export type ObjectSchemaMaskPassthroughReason = + /** ADR-0106 D8 — the deployment opted out. */ + | 'disabled' + /** ADR-0106 D6 tier 1 — no `security` service at all; the deployment has no FLS posture. */ + | 'no-service' + /** ADR-0106 D4 — `isSystem` or a platform-admin caller. */ + | 'exempt' + /** Not an object schema (no `fields` map to project). */ + | 'not-applicable'; + +/** + * The decision {@link resolveObjectSchemaMaskPosture} reaches for one caller × + * one object, BEFORE the document is fetched. + */ +export type ObjectSchemaMaskPosture = + | { kind: 'passthrough'; reason: ObjectSchemaMaskPassthroughReason } + /** ADR-0106 D6 tier 2 — `getReadableFields` could not answer. */ + | { kind: 'undetermined' } + | { kind: 'project'; readable: ReadonlySet }; + +/** A posture that serves the document unchanged and needs no fingerprint. */ +export const OBJECT_SCHEMA_MASK_NOT_APPLICABLE: ObjectSchemaMaskPosture = + { kind: 'passthrough', reason: 'not-applicable' }; + +/** + * ADR-0106 D6 tier 3 — the security service **threw** while evaluating the + * caller's readable set. + * + * An unhealthy security service must not auto-open a disclosure hole, and the + * only safe closed form is an error: visible, retryable, never cached. Exits + * translate this to 5xx. They must never fall back to the unmasked body (D3's + * fetch → mask → send ordering is what makes that impossible) and never answer + * an empty-fields 200, which is both a silently wrong UI and cacheable poison. + */ +export class ObjectSchemaMaskEvaluationError extends Error { + readonly objectName: string; + /** The error the security service threw, kept for the operator-side log. */ + readonly evaluationError: unknown; + + constructor(objectName: string, evaluationError?: unknown) { + super( + `[ADR-0106] Field visibility for object '${objectName}' could not be evaluated; ` + + 'refusing to serve the object schema rather than disclose unmasked fields.', + ); + this.name = 'ObjectSchemaMaskEvaluationError'; + this.objectName = objectName; + this.evaluationError = evaluationError; + } +} + +/** + * Is per-caller object-schema masking on for this deployment (ADR-0106 D8)? + * + * Default **on** — masking is the platform default and ships with the current + * major. `configured === false` (the REST layer's `metadata.maskObjectFields`) + * or {@link OBJECT_SCHEMA_MASK_DISABLE_ENV} opts out. + * + * @param configured Per-server config value, when the exit has one. + * @param env Environment bag; defaults to `process.env` where one exists. + */ +export function isObjectSchemaMaskingEnabled( + configured?: boolean, + env?: Record, +): boolean { + if (configured === false) return false; + const bag = env ?? (globalThis as { process?: { env?: Record } }).process?.env; + const raw = bag?.[OBJECT_SCHEMA_MASK_DISABLE_ENV]; + if (typeof raw === 'string' && raw.trim() !== '' && raw.trim() !== '0' && raw.trim().toLowerCase() !== 'false') { + return false; + } + return true; +} + +/** + * Is this caller exempt from the mask (ADR-0106 D4)? + * + * `isSystem` (which `getReadableFields` already bypasses) plus a platform-admin + * caller, judged by the same `systemPermissions` reading the `app` filter uses. + */ +export function isObjectSchemaMaskExempt(context: unknown): boolean { + if (!context || typeof context !== 'object') return false; + const ctx = context as { isSystem?: unknown; systemPermissions?: unknown }; + if (ctx.isSystem === true) return true; + if (!Array.isArray(ctx.systemPermissions)) return false; + return ctx.systemPermissions.some( + (p) => typeof p === 'string' && OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES.includes(p), + ); +} + +/** + * The `security` service surface this projection consumes. + * + * Deliberately structural and all-optional: the service is absent in + * deployments without `plugin-security`, and a partial implementation must + * degrade rather than lie (the contract's own feature-detection rule). + */ +export interface ObjectSchemaMaskSecuritySurface { + /** #3547 — the authoritative readable-column set; `undefined` = no answer. */ + getReadableFields?(object: string, context?: unknown): Promise | string[] | undefined; + /** + * [ADR-0106 D7] The metadata-plane variant: identical to + * {@link getReadableFields} except that a caller resolving to **zero** + * permission sets goes through the same fallback-set resolution + * `/auth/me/permissions` uses, instead of falling open to the full field + * set. Preferred when present; exits fall back to `getReadableFields`. + */ + getMetadataReadableFields?(object: string, context?: unknown): Promise | string[] | undefined; +} + +/** Structured-degradation sink for ADR-0106 D6's middle tier. */ +export interface ObjectSchemaMaskTelemetry { + /** A `warn`-level structured record (functional degradation, not durability loss). */ + warn?(message: string, meta: Record): void; + /** A monotonic counter increment. */ + counter?(name: string, labels: Record): void; +} + +/** Metric name for the D6 middle tier — a deployment living here is an operational condition. */ +export const OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC = 'objectstack_meta_field_visibility_undetermined_total'; + +/** + * Decide the masking posture for one caller × one object, BEFORE the document + * is fetched (ADR-0106 D2/D4/D6/D7/D8). + * + * Resolving first is what lets an exit keep today's exact cached-read code path + * when the answer is `passthrough` — the byte-identical guarantee D3 promises + * unrestricted callers is a property of the code path, not just of the body. + * + * @throws {ObjectSchemaMaskEvaluationError} when the security service throws. + */ +export async function resolveObjectSchemaMaskPosture(input: { + /** Object machine name (`req.params.name`). */ + objectName: string; + /** The caller's execution context, or `undefined` when it could not be resolved. */ + context: unknown; + /** The registered `security` service, or `undefined` when none is. */ + security: ObjectSchemaMaskSecuritySurface | undefined; + /** {@link isObjectSchemaMaskingEnabled} for this deployment. */ + enabled: boolean; + /** Optional sink for the D6 middle tier. */ + telemetry?: ObjectSchemaMaskTelemetry; +}): Promise { + const { objectName, context, security, enabled, telemetry } = input; + if (!enabled) return { kind: 'passthrough', reason: 'disabled' }; + // D4 — a caller property. Checked before the service call so an exempt + // caller costs nothing and cannot be turned into an error by a sick + // security service. + if (isObjectSchemaMaskExempt(context)) return { kind: 'passthrough', reason: 'exempt' }; + + const ask = typeof security?.getMetadataReadableFields === 'function' + ? security.getMetadataReadableFields.bind(security) + : (typeof security?.getReadableFields === 'function' ? security.getReadableFields.bind(security) : undefined); + // D6 tier 1 — no FLS posture in this deployment at all. The data plane does + // not mask either, so tightening the metadata plane alone would be theater. + if (!ask) return { kind: 'passthrough', reason: 'no-service' }; + + let readable: string[] | undefined; + try { + readable = await ask(objectName, context); + } catch (error) { + // D6 tier 3 — an unhealthy security service must not auto-open. + throw new ObjectSchemaMaskEvaluationError(objectName, error); + } + + if (!Array.isArray(readable)) { + // D6 tier 2 — the field universe is unresolvable (registry hydration). + // Fail OPEN, loudly: failing closed here bricks every render of the + // object for every user and risks a bootstrap deadlock, because + // permission sets are themselves metadata. + telemetry?.warn?.( + '[ADR-0106] object-schema field visibility undetermined — serving the UNMASKED schema; ' + + 'response downgraded to `private, no-store` and no shared ETag is emitted', + { object: objectName, decision: 'serve-unmasked' }, + ); + telemetry?.counter?.(OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, { object: objectName }); + return { kind: 'undetermined' }; + } + + return { kind: 'project', readable: new Set(readable) }; +} + +/** The result of projecting one document. */ +export interface ObjectSchemaMaskResult { + /** The document to serve. Same reference when nothing was removed. */ + document: T; + /** Field names removed, sorted. Empty for an unrestricted caller. */ + denied: readonly string[]; + /** {@link objectFieldVisibilityFingerprint} over {@link denied}; `''` when nothing was removed. */ + fingerprint: string; + /** + * True when the projection would have left the schema with **no** fields at + * all while the source declared some. + * + * `getReadableFields` answers `[]` only where its own posture read failed + * closed (#3545), so this is a degraded answer wearing a valid shape — and + * ADR-0106 D6 rules an empty-fields `200` out in as many words ("the worst + * option — silently wrong UI **and** cacheable poison"). Exits answer 5xx. + */ + emptied: boolean; +} + +/** + * Project a metadata document's `fields` onto the caller's readable set + * (ADR-0106 D1) — remove an unreadable field **whole**. + * + * Pure and total, with the same tolerance contract as + * {@link applyAuditFieldGovernance}: any input may be handed to it, including a + * bare record that has never been through Zod. A document with no `fields` + * record is returned by reference (a non-object type reaching an object exit, + * or an object schema that declares none), and so is a document from which + * nothing was removed — so an unrestricted caller pays one pass and no copy. + */ +export function applyObjectSchemaMask(document: T, posture: ObjectSchemaMaskPosture): ObjectSchemaMaskResult { + const unchanged: ObjectSchemaMaskResult = { document, denied: [], fingerprint: '', emptied: false }; + if (posture.kind !== 'project') return unchanged; + if (!document || typeof document !== 'object' || Array.isArray(document)) return unchanged; + + const rec = document as unknown as Record; + const fields = rec.fields; + // `fields` is a record keyed by machine name — the one shape `packages/spec` + // declares. Anything else is not an object schema and is left alone rather + // than tolerated as a second dialect (Prime Directive #12). + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return unchanged; + + const declared = fields as Record; + const denied: string[] = []; + for (const name of Object.keys(declared)) { + if (!posture.readable.has(name)) denied.push(name); + } + if (denied.length === 0) return unchanged; + denied.sort(); + + const kept: Record = {}; + for (const [name, def] of Object.entries(declared)) { + if (posture.readable.has(name)) kept[name] = def; + } + + return { + document: { ...rec, fields: kept } as unknown as T, + denied, + fingerprint: objectFieldVisibilityFingerprint(denied), + emptied: Object.keys(kept).length === 0, + }; +} + +/** + * A stable hash of the caller's **denied** field set for one object (ADR-0106 + * D3) — the ETag dimension that keeps `304` semantics correct per permission + * cohort without putting a caller dimension in the cache key. + * + * Empty denied set → empty string, which is what makes an unrestricted caller's + * ETag byte-identical to the pre-ADR one (see + * {@link foldVisibilityFingerprintIntoEtag}). + * + * FNV-1a/32, hex, order-independent (the input is sorted first): two callers in + * the same cohort must hash equal whatever order their sets were computed in. + */ +export function objectFieldVisibilityFingerprint(denied: readonly string[]): string { + if (denied.length === 0) return ''; + const joined = [...denied].sort().join('\u0000'); + let hash = 0x811c9dc5; + for (let i = 0; i < joined.length; i++) { + hash ^= joined.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +/** Separator between the shared validator and the per-cohort fingerprint. */ +const FINGERPRINT_SEPARATOR = '~'; + +/** + * Fold a caller's visibility fingerprint into a shared ETag value (ADR-0106 D3). + * + * An empty fingerprint returns the ETag **unchanged** — the zero-regression + * property the ADR promises unrestricted callers, and the reason this is a fold + * rather than a rewrite. + */ +export function foldVisibilityFingerprintIntoEtag(etag: string, fingerprint: string): string { + return fingerprint === '' ? etag : `${etag}${FINGERPRINT_SEPARATOR}${fingerprint}`; +} + +/** + * Normalize an `If-None-Match` header value to the bare validator string — + * strips `W/` and the surrounding quotes, the same normalization + * `getMetaItemCached` applies before comparing. + */ +export function normalizeIfNoneMatch(header: unknown): string | undefined { + if (typeof header !== 'string') return undefined; + const trimmed = header.trim(); + if (trimmed === '') return undefined; + return trimmed.replace(/^W\/"(.*)"$/, '$1').replace(/^"(.*)"$/, '$1'); +} diff --git a/packages/metadata-core/src/testing.ts b/packages/metadata-core/src/testing.ts index 3a78b236d6..5621e96a84 100644 --- a/packages/metadata-core/src/testing.ts +++ b/packages/metadata-core/src/testing.ts @@ -6,3 +6,8 @@ */ export * from './contract-suite.js'; +// [ADR-0106 / #3682] The metadata-plane FLS case table, driven from the +// `@objectstack/rest` and `@objectstack/runtime` suites so a schema-serving +// exit that forgets the projection fails by name. Test-only: it belongs beside +// the contract suite, not on the runtime entry. +export * from './object-schema-fls-contract.js'; diff --git a/packages/plugins/plugin-security/src/get-metadata-readable-fields.test.ts b/packages/plugins/plugin-security/src/get-metadata-readable-fields.test.ts new file mode 100644 index 0000000000..7cd022d0fb --- /dev/null +++ b/packages/plugins/plugin-security/src/get-metadata-readable-fields.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106 D7] `getMetadataReadableFields` — the metadata-plane variant of + * `getReadableFields`. + * + * The two differ in exactly one place: what a caller resolving to ZERO + * permission sets gets. On the DATA plane, falling open is the drift-free + * answer — the engine middleware skips its whole gate for such a caller, and + * reporting a narrowing the data path would not enforce is its own kind of + * drift. On the METADATA plane the question is disclosure, and D7 rules that a + * guest/public deployment's schema exposure must be a deliberate + * permission-set decision rather than an accidental everything-default. So the + * zero-set caller goes through the same fallback resolution + * `/auth/me/permissions` performs (`security.fallbackPermissionSet`). + * + * Every case below asserts BOTH methods, so the pair can only be changed + * together and the divergence stays exactly one row wide. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SecurityPlugin } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; + +function bootPlugin(permissionSets: PermissionSet[], objectFields: string[], fallback: string | undefined = 'guest_default') { + const fields: Record = {}; + for (const f of objectFields) fields[f] = { name: f }; + const schema: any = { name: 'deal', label: 'Deal', systemFields: false, fields }; + const ql: any = { + registerMiddleware: () => {}, + getSchema: (name: string) => (name === 'deal' ? schema : null), + findOne: async () => null, + }; + const metadata: any = { + get: async (_type: string, name: string) => (name === 'deal' ? schema : null), + list: async () => permissionSets, + }; + const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin( + fallback === undefined ? ({} as any) : { fallbackPermissionSet: fallback }, + ); + return { plugin, ctx }; +} + +/** The deployment's guest baseline: reads `deal`, cannot see `secret`. */ +const GUEST_DEFAULT: PermissionSet = { + name: 'guest_default', + label: 'Guest', + objects: { deal: { allowRead: true } }, + fields: { 'deal.secret': { readable: false, editable: false } }, +} as any; + +const OPEN: PermissionSet = { + name: 'open', + label: 'Open', + objects: { deal: { allowRead: true } }, +} as any; + +async function boot(sets: PermissionSet[], fields: string[], fallback?: string) { + const { plugin, ctx } = bootPlugin(sets, fields, fallback); + await plugin.init(ctx); + await plugin.start(ctx); + return { plugin, ctx }; +} + +describe('[ADR-0106 D7] SecurityPlugin.getMetadataReadableFields', () => { + it('a zero-permission-set caller resolves the FALLBACK set instead of falling open', async () => { + const { plugin } = await boot([GUEST_DEFAULT], ['id', 'name', 'secret']); + // No `userId` → `resolvePermissionSetsForContext` applies no baseline, so + // this caller resolves to nothing at all. + const context = { positions: [], permissions: [] }; + + const dataPlane = await plugin.getReadableFields('deal', context); + const metadataPlane = await plugin.getMetadataReadableFields('deal', context); + + // The data plane keeps its middleware-mirroring fall-open — unchanged. + expect(dataPlane).toEqual(['id', 'name', 'secret']); + // The metadata plane does not. + expect(metadataPlane).toEqual(['id', 'name']); + expect(metadataPlane).not.toContain('secret'); + }); + + it('a caller WITH permission sets is answered identically by both methods', async () => { + const { plugin } = await boot([GUEST_DEFAULT, OPEN], ['id', 'name', 'secret']); + const context = { userId: 'u1', permissions: ['open'] }; + + expect(await plugin.getMetadataReadableFields('deal', context)) + .toEqual(await plugin.getReadableFields('deal', context)); + }); + + it('falls open when the fallback set itself does not resolve — that is the "no FLS posture" tier, not a restricted caller', async () => { + // The deployment names a fallback that does not exist. + const { plugin } = await boot([OPEN], ['id', 'name', 'secret'], 'missing_set'); + + expect(await plugin.getMetadataReadableFields('deal', { positions: [], permissions: [] })) + .toEqual(['id', 'name', 'secret']); + }); + + it('falls open when no fallback set is configured at all', async () => { + const { plugin } = await boot([OPEN], ['id', 'name', 'secret'], undefined); + + const answer = await plugin.getMetadataReadableFields('deal', { positions: [], permissions: [] }); + expect(answer).toEqual(['id', 'name', 'secret']); + }); + + it('`isSystem` bypasses on both planes (ADR-0106 D4 — the exemption is a caller property)', async () => { + const { plugin } = await boot([GUEST_DEFAULT], ['id', 'name', 'secret']); + + expect(await plugin.getMetadataReadableFields('deal', { isSystem: true })).toEqual(['id', 'name', 'secret']); + expect(await plugin.getReadableFields('deal', { isSystem: true })).toEqual(['id', 'name', 'secret']); + }); + + it('an unresolvable object schema answers `undefined` on both planes — "no answer", not "no fields"', async () => { + const { plugin } = await boot([GUEST_DEFAULT], ['id', 'name', 'secret']); + + expect(await plugin.getMetadataReadableFields('unknown_object', { userId: 'u1' })).toBeUndefined(); + expect(await plugin.getReadableFields('unknown_object', { userId: 'u1' })).toBeUndefined(); + }); + + it('is registered on the `security` service so the dispatch layer can feature-detect it', async () => { + const { ctx } = await boot([GUEST_DEFAULT], ['id', 'name', 'secret']); + + const registered = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; + expect(typeof registered?.getMetadataReadableFields).toBe('function'); + // The published-contract methods are still there — the extension is + // additive, and a consumer that only knows `getReadableFields` is unaffected. + expect(typeof registered?.getReadableFields).toBe('function'); + expect(typeof registered?.getReadFilter).toBe('function'); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b945dd0039..d6f5e19857 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -758,8 +758,19 @@ export class SecurityPlugin implements Plugin { dismissAudienceBindingSuggestion: (callerContext: any, id: string) => dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id), }; - ctx.registerService('security', securityService); - ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, canExport, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / #3544 / #3547'); + // [ADR-0106 D7] The metadata-plane readable-field query, registered as an + // EXTENSION of the published contract rather than inside the typed + // literal above: `ISecurityService` lives in `packages/spec`, and the + // seat for this method there is a separate change (consumers already + // feature-detect, which is exactly why a partial surface degrades instead + // of lying). `Object.assign` keeps the literal type-checked against the + // contract while the extension stays visible as an extension. + const registeredSecurityService = Object.assign(securityService, { + getMetadataReadableFields: (object: string, context?: any) => + this.getMetadataReadableFields(object, context), + }); + ctx.registerService('security', registeredSecurityService); + ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, getMetadataReadableFields, canExport, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / ADR-0106 D7 / #3544 / #3547'); } catch (e) { ctx.logger.warn?.('[security] failed to register "security" service', { error: (e as Error).message, @@ -2548,6 +2559,40 @@ export class SecurityPlugin implements Plugin { * `isSystem` skip. */ async getReadableFields(object: string, context?: any): Promise { + return this.computeReadableFields(object, context, { fallbackOnEmptySets: false }); + } + + /** + * [ADR-0106 D7] The METADATA-PLANE variant of {@link getReadableFields}. + * + * Identical in every respect but one: a caller that resolves to **zero** + * permission sets goes through the same fallback-set resolution + * `/auth/me/permissions` uses ({@link fallbackPermissionSet}, default + * `member_default`) instead of falling open to the full field set. + * + * Why the two differ rather than converge. `getReadableFields` mirrors the + * engine middleware, which skips its whole gate for a caller with no + * permission sets — reporting a narrowing the data path would not enforce is + * its own kind of drift, so on the DATA plane falling open is the correct, + * drift-free answer. The metadata plane has no such symmetry to preserve: the + * question there is disclosure, and ADR-0106 D7 rules that a public/guest + * deployment's schema exposure must be a deliberate permission-set decision + * rather than an accidental everything-default. Anonymous callers on a + * `requireAuth` deployment are blocked before any of this. + * + * Still falls open when the fallback set itself resolves to nothing (no + * `member_default` in the deployment at all) — that is the "no FLS posture + * here" tier, not a restricted caller. + */ + async getMetadataReadableFields(object: string, context?: any): Promise { + return this.computeReadableFields(object, context, { fallbackOnEmptySets: true }); + } + + private async computeReadableFields( + object: string, + context: any, + options: { fallbackOnEmptySets: boolean }, + ): Promise { const objectName = String(object ?? ''); if (!objectName) return undefined; // The field universe — the SAME source the RLS field pass uses (ObjectQL's @@ -2559,7 +2604,14 @@ export class SecurityPlugin implements Plugin { // System operations bypass FLS (mirrors the middleware's isSystem skip). if (context?.isSystem) return allFields; - const permissionSets = await this.resolvePermissionSetsForContext(context); + let permissionSets = await this.resolvePermissionSetsForContext(context); + if (permissionSets.length === 0 && options.fallbackOnEmptySets) { + // [ADR-0106 D7] `resolvePermissionSetsForContext` applies the baseline + // only for a caller carrying `userId`, so a guest/anonymous caller lands + // here with nothing. Resolve the configured fallback set explicitly — + // the same two-step `/auth/me/permissions` performs. + permissionSets = await this.resolveFallbackPermissionSets(); + } // No sets resolved (e.g. unauthenticated) → no field mask applies, exactly // as the middleware (getFieldPermissions([]) === {} → nothing deleted). if (permissionSets.length === 0) return allFields; @@ -2715,6 +2767,32 @@ export class SecurityPlugin implements Plugin { return permissionSets; } + /** + * [ADR-0106 D7] Resolve the configured fallback permission set on its own — + * the second step `/auth/me/permissions` takes when a caller's own names + * resolve to nothing (`resolved.length === 0 && fallbackName`). + * + * Distinct from the post-resolution fallback inside + * {@link resolvePermissionSetsForContext}, which is gated on `context.userId` + * (it exists to close an RLS fail-open for AUTHENTICATED callers whose + * positions map to no set). D7 needs the same resolution for a caller with no + * principal at all, so that a guest-facing deployment's metadata exposure is + * a permission-set decision rather than an accidental everything-default. + * + * Returns `[]` when no fallback set is configured or it does not resolve. + */ + private async resolveFallbackPermissionSets(): Promise { + const fallback = this.fallbackPermissionSet; + if (!fallback) return []; + return this.permissionEvaluator.resolvePermissionSets( + [fallback], + this.metadata, + this.bootstrapPermissionSets, + this.dbLoader, + { logger: this.logger }, + ); + } + /** * Resolve a single scalar primary-key id from an update/delete operation * context, mirroring the engine's "single-id vs predicate" rule diff --git a/packages/rest/package.json b/packages/rest/package.json index 2bb0c629cd..c39ad11e9b 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/metadata-core": "workspace:*", "@objectstack/observability": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/service-package": "workspace:*", diff --git a/packages/rest/src/meta-object-fls.test.ts b/packages/rest/src/meta-object-fls.test.ts new file mode 100644 index 0000000000..4e06a407d6 --- /dev/null +++ b/packages/rest/src/meta-object-fls.test.ts @@ -0,0 +1,363 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106 / #3682] Every REST `/meta` outlet that serves an object schema is + * driven through the SAME case table (`@objectstack/metadata-core/testing`). + * + * The invariant, once: for a restricted caller an unreadable field is + * COMPLETELY ABSENT from every exit — not a bare name, not a `null`, not a 200 + * with empty `fields`. The reason the table is shared rather than written per + * suite is D5's own sentence, "every schema-serving outlet, or the mask is + * decoration": five paths on this route reach a body, and before this suite + * nothing would have gone red if a sixth arrived without the projection. + * + * The five outlets pinned here: + * 1. `GET /meta/object/:name`, cached branch (THE DEFAULT — `enableCache` + * defaults to `true`); + * 2. the same route, uncached branch (no `getMetaItemCached`; also what + * `?state=draft` / `?preview=draft` / `?package=` take); + * 3. `?layers=true`, the layered diagnostic view — three full schemas + * (`code` / `overlay` / `effective`) reached by a query flag; + * 4. `GET /meta/:type/:section/:name`, the compound-name read; + * 5. `GET /meta/object`, the list read. + * + * Beyond the table, this file pins the properties the table cannot express: + * D3's ETag fingerprint (byte-identical for an unrestricted caller; cohort + * 304s; a permission change invalidating a stale 304), and D6 tier 2's cache + * downgrade. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + OBJECT_SCHEMA_MASK_CASES, + FLS_CONTRACT_OBJECT, + assertObjectSchemaMaskCase, + securityDoubleFor, + type ObjectSchemaMaskCase, + type ObjectSchemaMaskExit, + type ObjectSchemaMaskOutcome, +} from '@objectstack/metadata-core/testing'; +import { RestServer } from './rest-server'; + +const ACCOUNT = FLS_CONTRACT_OBJECT as unknown as Record; + +/** A fresh deep copy per call — an exit that mutates the cached body must not leak into the next case. */ +const account = () => JSON.parse(JSON.stringify(ACCOUNT)); + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const headers: Record = {}; + return { + headers, + statusCode: 200, + json: vi.fn(), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn((name: string, value: string) => { headers[name] = value; }), + }; +} + +interface BootOptions { + testCase: ObjectSchemaMaskCase; + /** Supply `getMetaItemCached` (cached branch) or leave it off (uncached branch). */ + cached?: boolean; + etag?: string; +} + +function boot(opts: BootOptions) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async () => [account()]), + getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: account(), lock: 'none' })), + getMetaItemLayered: vi.fn(async ({ type, name }: any) => ({ + type, name, code: account(), overlay: account(), effective: account(), + })), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + }; + if (opts.cached) { + protocol.getMetaItemCached = vi.fn(async () => ({ + data: account(), + etag: { value: opts.etag ?? 'shared-validator', weak: false }, + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: false, + })); + } + + const security = securityDoubleFor(opts.testCase); + const rest = new RestServer( + mockServer() as any, + protocol as any, + { + api: { requireAuth: false }, + // ADR-0106 D8 — the escape hatch is a per-server config key. + metadata: opts.testCase.maskingDisabled ? { maskObjectFields: false } : {}, + } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + security ? (async () => security as any) : undefined, + ); + (rest as any).resolveExecCtx = async () => ({ ...opts.testCase.context }); + rest.registerRoutes(); + return { rest, protocol }; +} + +function routeFor(rest: RestServer, path: string) { + return (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); +} + +/** Turn a handler run into the contract's `document | fault` outcome. */ +async function outcomeOf( + run: (res: any) => Promise, + pick: (body: any) => unknown, +): Promise { + const res = mockRes(); + await run(res); + const body = res.json.mock.calls.at(-1)?.[0]; + if (res.statusCode >= 400) return { kind: 'fault', status: res.statusCode, res }; + return { kind: 'document', document: pick(body), res }; +} + +const SINGLE_PATH = '/api/v1/meta/:type/:name'; +const LIST_PATH = '/api/v1/meta/:type'; +const COMPOUND_PATH = '/api/v1/meta/:type/:section/:name'; + +const EXITS: ObjectSchemaMaskExit[] = [ + { + name: 'GET /meta/object/:name — cached branch (default)', + run: (testCase) => { + const { rest } = boot({ testCase, cached: true }); + return outcomeOf( + (res) => routeFor(rest, SINGLE_PATH)!.handler( + { params: { type: 'object', name: 'account' }, query: {}, headers: {} }, res, + ), + (body) => body?.item, + ); + }, + }, + { + name: 'GET /meta/object/:name — uncached branch', + run: (testCase) => { + const { rest } = boot({ testCase, cached: false }); + return outcomeOf( + (res) => routeFor(rest, SINGLE_PATH)!.handler( + { params: { type: 'object', name: 'account' }, query: {}, headers: {} }, res, + ), + (body) => body?.item, + ); + }, + }, + { + name: 'GET /meta/objects/:name?state=draft — uncached branch via the canonical PLURAL spelling', + run: (testCase) => { + // #3984/#6241: the plural spelling is canonical, and a gate keyed on + // the raw `:type` param is a gate it walks past. Driving one row of + // the table through it keeps that from being re-learned. + const { rest } = boot({ testCase, cached: true }); + return outcomeOf( + (res) => routeFor(rest, SINGLE_PATH)!.handler( + { params: { type: 'objects', name: 'account' }, query: { state: 'draft' }, headers: {} }, res, + ), + (body) => body?.item, + ); + }, + }, + { + name: 'GET /meta/object/:name?layers=true — layered diagnostic view (`effective`)', + run: (testCase) => { + const { rest } = boot({ testCase, cached: true }); + return outcomeOf( + (res) => routeFor(rest, SINGLE_PATH)!.handler( + { params: { type: 'object', name: 'account' }, query: { layers: 'true' }, headers: {} }, res, + ), + (body) => body?.effective, + ); + }, + }, + { + name: 'GET /meta/object/:name?layers=true — layered diagnostic view (`code`)', + run: (testCase) => { + const { rest } = boot({ testCase, cached: true }); + return outcomeOf( + (res) => routeFor(rest, SINGLE_PATH)!.handler( + { params: { type: 'object', name: 'account' }, query: { layers: 'true' }, headers: {} }, res, + ), + (body) => body?.code, + ); + }, + }, + { + name: 'GET /meta/:type/:section/:name — compound-name read', + run: (testCase) => { + const { rest } = boot({ testCase, cached: true }); + return outcomeOf( + (res) => routeFor(rest, COMPOUND_PATH)!.handler( + { params: { type: 'object', section: 'crm', name: 'account' }, query: {}, headers: {} }, res, + ), + (body) => body?.item, + ); + }, + }, + { + name: 'GET /meta/object — list read', + run: (testCase) => { + const { rest } = boot({ testCase, cached: true }); + return outcomeOf( + (res) => routeFor(rest, LIST_PATH)!.handler( + { params: { type: 'object' }, query: {}, headers: {} }, res, + ), + (body) => (Array.isArray(body) ? body[0] : body?.items?.[0]), + ); + }, + }, +]; + +describe('[ADR-0106] REST /meta object-schema masking — one contract, every exit', () => { + for (const exit of EXITS) { + describe(exit.name, () => { + for (const testCase of OBJECT_SCHEMA_MASK_CASES) { + it(testCase.id, async () => { + const outcome = await exit.run(testCase); + assertObjectSchemaMaskCase(exit.name, testCase, outcome); + }); + } + }); + } +}); + +describe('[ADR-0106 D3] ETag carries the caller`s field-visibility fingerprint', () => { + const RESTRICTED: ObjectSchemaMaskCase = { + id: 'etag/restricted', why: 'D3', context: { userId: 'u_portal' }, + readable: ['id', 'name'], expect: { kind: 'fields', present: ['id'], absent: ['salary_grade'] }, + }; + const NARROWER: ObjectSchemaMaskCase = { ...RESTRICTED, id: 'etag/narrower', readable: ['id'] }; + const UNRESTRICTED: ObjectSchemaMaskCase = { + ...RESTRICTED, id: 'etag/unrestricted', + readable: ['id', 'name', 'salary_grade', 'bonus_formula'], expect: { kind: 'unmasked' }, + }; + + async function read(testCase: ObjectSchemaMaskCase, ifNoneMatch?: string) { + const { rest } = boot({ testCase, cached: true, etag: 'v1' }); + const res = mockRes(); + await routeFor(rest, SINGLE_PATH)!.handler( + { + params: { type: 'object', name: 'account' }, + query: {}, + headers: ifNoneMatch ? { 'if-none-match': ifNoneMatch } : {}, + }, + res, + ); + return { etag: res.headers.ETag, status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0], res }; + } + + it('an unrestricted caller`s ETag is byte-identical to the shared validator', async () => { + const { etag } = await read(UNRESTRICTED); + expect(etag).toBe('"v1"'); + }); + + it('a restricted caller`s ETag differs from the unrestricted one', async () => { + const restricted = await read(RESTRICTED); + expect(restricted.etag).not.toBe('"v1"'); + expect(restricted.etag).toMatch(/^"v1~[0-9a-f]{8}"$/); + }); + + it('same cohort → 304 (the fingerprinted validator round-trips)', async () => { + const first = await read(RESTRICTED); + const second = await read(RESTRICTED, first.etag!); + expect(second.status).toBe(304); + }); + + it('permission change → the old ETag no longer hits 304, and the body is re-projected', async () => { + const before = await read(RESTRICTED); + const after = await read(NARROWER, before.etag!); + expect(after.status).not.toBe(304); + expect(Object.keys(after.body.item.fields)).toEqual(['id']); + }); + + it('two callers denying the same set share one validator (cohort 304s are real)', async () => { + const a = await read({ ...RESTRICTED, context: { userId: 'a' } }); + const b = await read({ ...RESTRICTED, context: { userId: 'b' } }); + expect(a.etag).toBe(b.etag); + }); + + it('an unrestricted caller`s stale-validator 304 still works (zero regression)', async () => { + const second = await read(UNRESTRICTED, '"v1"'); + expect(second.status).toBe(304); + }); +}); + +describe('[ADR-0106 D6] failure postures on the wire', () => { + const UNDETERMINED: ObjectSchemaMaskCase = { + id: 'wire/undetermined', why: 'D6 tier 2', context: { userId: 'u' }, + readable: undefined, expect: { kind: 'unmasked' }, + }; + const THROWS: ObjectSchemaMaskCase = { + id: 'wire/throws', why: 'D6 tier 3', context: { userId: 'u' }, + readable: 'throw', expect: { kind: 'fault' }, + }; + + async function read(testCase: ObjectSchemaMaskCase) { + const { rest, protocol } = boot({ testCase, cached: true, etag: 'v1' }); + const res = mockRes(); + await routeFor(rest, SINGLE_PATH)!.handler( + { params: { type: 'object', name: 'account' }, query: {}, headers: {} }, res, + ); + return { res, protocol, body: res.json.mock.calls.at(-1)?.[0] }; + } + + it('tier 2 downgrades to `private, no-store` and emits NO shared ETag', async () => { + const { res } = await read(UNDETERMINED); + expect(res.headers['Cache-Control']).toBe('private, no-store'); + expect(res.headers.ETag).toBeUndefined(); + }); + + it('tier 3 answers 5xx and the cached FULL body never reaches the wire', async () => { + const { res, body, protocol } = await read(THROWS); + expect(res.statusCode).toBeGreaterThanOrEqual(500); + expect(JSON.stringify(body)).not.toContain('salary_grade'); + expect(body?.item).toBeUndefined(); + // STRONGER than D3's "fetch → mask → send": the posture is resolved + // before the fetch, so on the throw tier the cached document is never + // even produced. Pinned as measured, because the weaker claim ("we + // fetched it and then withheld it") would still be satisfied by a + // future refactor that reorders the two and leaks on an early `return`. + expect(protocol.getMetaItemCached).not.toHaveBeenCalled(); + }); + + it('a successful projection DOES go through the shared cache (mask-after-cache, not cache-bypass)', async () => { + // The other half of the ordering claim: D3 rejected "bypass the cache + // for restricted callers" as the end state, so a restricted read must + // still take the cached path — this is what stops the implementation + // from quietly degrading into the `doc`/`book` fallback. + const { protocol } = await read({ + id: 'wire/projected', why: 'D3', context: { userId: 'u' }, + readable: ['id'], expect: { kind: 'fields', present: ['id'], absent: ['salary_grade'] }, + }); + expect(protocol.getMetaItemCached).toHaveBeenCalled(); + }); + + it('tier 3 never answers an empty-fields 200', async () => { + const { res, body } = await read({ ...THROWS, id: 'wire/throws-2' }); + expect(res.statusCode).not.toBe(200); + expect(body?.item?.fields).toBeUndefined(); + }); + + it('an all-denied projection refuses rather than serving `fields: {}`', async () => { + const { res, body } = await read({ + id: 'wire/empty', why: 'D6', context: { userId: 'u' }, + readable: [], expect: { kind: 'fault' }, + }); + expect(res.statusCode).toBe(503); + expect(body?.item).toBeUndefined(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 1fdaf67c4d..302a3f9c8d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -11,7 +11,25 @@ import { declaresServerFault, INTERNAL_ERROR_MESSAGE, } from '@objectstack/types'; -import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; +import { + allowPerfDisclosure, + isPerfDisclosurePrincipal, + OBSERVABILITY_METRICS_SERVICE, +} from '@objectstack/observability'; +// [ADR-0106 / #3682] Metadata-plane FLS. One projection, one fingerprint, +// shared with the runtime `/metadata` dispatcher — see +// `@objectstack/metadata-core`'s `object-schema-fls.ts` for why the normalizer +// lives there rather than beside either set of exits. +import { + ObjectSchemaMaskEvaluationError, + applyObjectSchemaMask, + foldVisibilityFingerprintIntoEtag, + isObjectSchemaMaskingEnabled, + normalizeIfNoneMatch, + resolveObjectSchemaMaskPosture, + OBJECT_SCHEMA_MASK_NOT_APPLICABLE, + type ObjectSchemaMaskPosture, +} from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; @@ -1031,6 +1049,28 @@ function sendError(res: any, error: any, object?: string): void { res.status(resolved.status).json(resolved.body); } +/** + * [ADR-0106 D6 tier 3] Refuse an object-schema read whose field visibility + * could not be evaluated. + * + * An unhealthy security service must not auto-open a disclosure hole, and the + * only safe closed form is an *error*: visible, retryable, never cached. The + * two answers this exists to rule out are (a) the unmasked body — D3's + * fetch → mask → send ordering means the cached full document never reaches the + * wire on this path — and (b) an empty-fields `200`, which is a silently wrong + * UI and cacheable poison at once. + * + * 503 rather than 500: the condition is an unhealthy dependency and a retry is + * the right client behaviour. + */ +function sendFieldVisibilityFault(res: any, objectName: string): void { + sendError(res, { + code: 'FIELD_VISIBILITY_UNRESOLVED', + message: `Field visibility for object '${objectName}' could not be evaluated; the object schema is not being served.`, + status: 503, + }); +} + /** * [#5437] Log the ORIGINAL error whenever a server fault's own message was * withheld from the response body. @@ -1574,6 +1614,12 @@ type NormalizedRestServerConfig = { prefix: string; enableCache: boolean; cacheTtl: number; + /** + * [ADR-0106 D8] Per-caller FLS masking of served object schemas. + * Default **on**; `false` opts a deployment out of the metadata-plane + * mask entirely (the data plane is unaffected either way). + */ + maskObjectFields: boolean; endpoints: { types: boolean; items: boolean; @@ -2858,6 +2904,85 @@ export class RestServer { }; } + /** + * [ADR-0106 D2/D4/D6/D7/D8] Build this request's object-schema masker — a + * per-object-name posture resolver whose caller context and `security` + * service are resolved ONCE. + * + * Every exit that serves object schemas (single cached, single uncached, + * layered, compound-name, and the list read) goes through the returned + * function, so "which outlets mask" is one decision rather than five + * (ADR-0106 D5 — "every schema-serving outlet, or the mask is decoration"). + * + * Answers the not-applicable passthrough for every non-`object` type, so a + * call site can stand unconditionally at an exit that serves all types. + * `metaType` must be the NORMALIZED type (`/meta/objects/x` is the canonical + * plural spelling; a gate comparing the raw param is a gate the canonical + * spelling walks past — #3984 / #6241). + * + * The returned function REJECTS with {@link ObjectSchemaMaskEvaluationError} + * on D6 tier 3 — the security service threw. Call sites answer 5xx via + * {@link sendFieldVisibilityFault}; they must never fall back to the + * unmasked body. + */ + private async resolveObjectMasker( + environmentId: string | undefined, + req: any, + metaType: string, + ): Promise<(objectName: string) => Promise> { + if (metaType !== 'object' || !this.config.metadata.maskObjectFields) { + const fixed: ObjectSchemaMaskPosture = metaType !== 'object' + ? OBJECT_SCHEMA_MASK_NOT_APPLICABLE + : { kind: 'passthrough', reason: 'disabled' }; + return async () => fixed; + } + // Resolved ONCE per request, not once per item: the list read asks the + // same caller about every object it serves. + const context = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + const security = await this.resolveSecurityService(environmentId, req); + const telemetry = { + warn: (message: string, meta: Record) => logWarn(message, meta), + counter: (name: string, labels: Record) => { + // Best-effort: the D6 middle tier must be OBSERVABLE, but a + // deployment without a metrics registry still serves the read. + // The structured warn above is the floor. + try { + (context as any)?.__kernel?.getService?.(OBSERVABILITY_METRICS_SERVICE)?.counter?.(name, labels); + } catch { /* metrics are never load-bearing */ } + }, + }; + return (objectName: string) => resolveObjectSchemaMaskPosture({ + objectName, + context, + security: security as any, + enabled: true, + telemetry, + }); + } + + /** + * Apply {@link resolveObjectMaskPosture}'s verdict to one served document + * (ADR-0106 D1/D3). + * + * Returns `null` after answering 5xx when the projection would leave the + * schema with no fields at all — `getReadableFields` answers `[]` only where + * its own posture read failed closed (#3545), and D6 rules an empty-fields + * `200` out ("silently wrong UI **and** cacheable poison"). + */ + private maskObjectDocument( + res: any, + posture: ObjectSchemaMaskPosture, + objectName: string, + document: T, + ): { document: T; fingerprint: string } | null { + const masked = applyObjectSchemaMask(document, posture); + if (masked.emptied) { + sendFieldVisibilityFault(res, objectName); + return null; + } + return { document: masked.document, fingerprint: masked.fingerprint }; + } + /** * Translate a list of metadata documents using `translateMetaItem`. */ @@ -3016,6 +3141,16 @@ export class RestServer { prefix: metadata.prefix ?? '/meta', enableCache: metadata.enableCache ?? true, cacheTtl: metadata.cacheTtl ?? 3600, + // [ADR-0106 D8] Default ON — masking is the platform default and + // ships with the current major. Read through `as any` for the + // same reason `api.enableOpenApi` / `api.enableSearch` above are: + // `MetadataEndpointsConfigSchema` lives in `packages/spec` and + // giving this key a declared seat there is a separate change. + // `isObjectSchemaMaskingEnabled` also honours the + // `OS_ALLOW_UNMASKED_OBJECT_METADATA` escape hatch, which is the + // knob the runtime `/metadata` dispatcher shares (it has no REST + // config to read). + maskObjectFields: isObjectSchemaMaskingEnabled((metadata as any).maskObjectFields), endpoints: { types: metadata.endpoints?.types ?? true, items: metadata.endpoints?.items ?? true, @@ -4132,6 +4267,47 @@ export class RestServer { } } + // [ADR-0106 D5(2)] The list read — each item projected + // the same way, through the same masker. The posture is + // per OBJECT (one caller may read every field of `lead` + // and half of `account`), so the masker is resolved once + // and asked per item. + { + const listMetaType = RestServer.metaTypeSingular(req.params.type); + if (listMetaType === 'object') { + const raw = visible as unknown; + const list = RestServer.metaItemsArray(raw); + if (list.length > 0) { + const masker = await this.resolveObjectMasker(environmentId, req, listMetaType); + const projected: any[] = []; + let undetermined = false; + for (const item of list) { + const objectName = String((item as any)?.name ?? ''); + let posture: ObjectSchemaMaskPosture; + try { + posture = await masker(objectName); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + // D6 tier 3 — one unevaluable + // object fails the whole list + // rather than serving it with a + // silent hole in the projection. + sendFieldVisibilityFault(res, objectName); + return; + } + throw maskError; + } + if (posture.kind === 'undetermined') undetermined = true; + const masked = this.maskObjectDocument(res, posture, objectName, item); + if (!masked) return; + projected.push(masked.document); + } + if (undetermined) res.header('Cache-Control', 'private, no-store'); + visible = Array.isArray(raw) ? projected : { ...(raw as any), items: projected }; + } + } + } + const translated = await this.translateMetaItems(req, req.params.type, environmentId, visible); res.header('Vary', 'Accept-Language'); res.json(translated); @@ -4311,6 +4487,24 @@ export class RestServer { // in scope for it to compare against by accident. const metaType = RestServer.metaTypeSingular(req.params.type); + // [ADR-0106 D2/D5] Resolve the caller's field-visibility + // posture ONCE, here, before any fetch — every exit + // below (layered, cached, uncached) projects through + // THIS value. Resolving per-branch is how an outlet gets + // forgotten; resolving before the fetch is what makes + // D3's `fetch → mask → send` ordering structural rather + // than a convention. + let maskPosture: ObjectSchemaMaskPosture; + try { + maskPosture = await (await this.resolveObjectMasker(environmentId, req, metaType))(req.params.name); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + sendFieldVisibilityFault(res, req.params.name); + return; + } + throw maskError; + } + // Phase 3a-layered-get: opt-in 3-state view when client // asks for `?layers=true` (or any non-empty value). // Skips the cache path entirely — layered view is a @@ -4341,6 +4535,26 @@ export class RestServer { ...(layeredPackageId ? { packageId: layeredPackageId } : {}), ...(environmentId ? { environmentId } : {}), }); + // [ADR-0106 D5(4)] The layered view is a THIRD + // schema-bearing exit on this route, reached by a + // query flag — `code`, `overlay` and `effective` are + // each a full object schema. Leaving it unprojected + // would have made the mask a one-query-param detour, + // which is precisely the "or the mask is decoration" + // case D5 names. Its usual caller (the Studio + // editor) is exempt under D4 and sees no change. + if (maskPosture.kind === 'project') { + for (const layer of ['code', 'overlay', 'effective'] as const) { + const masked = this.maskObjectDocument( + res, maskPosture, req.params.name, (layered as any)?.[layer], + ); + if (!masked) return; + if (layered && typeof layered === 'object') (layered as any)[layer] = masked.document; + } + } + if (maskPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + } res.json(layered); return; } @@ -4436,8 +4650,18 @@ export class RestServer { // the uncached branch below; one predicate, two sites. const isAudienceGatedType = metaType === 'book' || metaType === 'doc'; if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDashboardType && !isDraftRead && !previewDrafts && !packageScoped && !isAudienceGatedType) { + // [ADR-0106 D3] When a projection applies, the + // protocol is NOT allowed to judge the conditional + // request: `getMetaItemCached` hashes the UNFILTERED + // document, so a `304` decided there would pin this + // caller to a body no mask ever touched — the same + // validator-vs-served-body mismatch #5881 recorded + // for the dashboard gate. The comparison moves below, + // against the fingerprinted ETag, which is the one + // that identifies what we are actually sending. + const maskApplies = maskPosture.kind !== 'passthrough'; const cacheRequest = { - ifNoneMatch: req.headers['if-none-match'] as string, + ifNoneMatch: maskApplies ? undefined : (req.headers['if-none-match'] as string), ifModifiedSince: req.headers['if-modified-since'] as string, }; @@ -4462,12 +4686,54 @@ export class RestServer { return; } + // [ADR-0106 D1/D3] fetch → mask → send. The shared + // cache still stores ONE full schema per (type, + // name, locale, environment) — no caller dimension + // in the key — and what varies per caller is this + // projection plus the validator below. + let cachedDocument: any = result.data; + let visibilityFingerprint = ''; + if (maskPosture.kind === 'project') { + const masked = this.maskObjectDocument(res, maskPosture, req.params.name, cachedDocument); + if (!masked) return; + cachedDocument = masked.document; + visibilityFingerprint = masked.fingerprint; + } + + // [ADR-0106 D6 tier 2] Visibility undetermined → + // the body is unmasked, so it must not be stored or + // revalidated under a SHARED validator: a later 304 + // would hand this body to a caller whose projection + // did resolve. No ETag, no Last-Modified, no-store. + if (maskPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + res.header('Vary', 'Accept-Language'); + res.json(await this.translateMetaEnvelope( + req, req.params.type, environmentId, + { type: metaType, name: req.params.name }, + cachedDocument, cacheI18n, + )); + return; + } + // Set cache headers if (result.etag) { + // [ADR-0106 D3] Fold the caller's field-visibility + // fingerprint into the shared validator. An + // unrestricted caller denies nothing → the + // fingerprint is empty → the ETag is byte-identical + // to the pre-ADR one. A cohort shares 304s; a + // permission change moves the fingerprint and + // self-invalidates the stale 304. + const value = foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint); const etagValue = result.etag.weak - ? `W/"${result.etag.value}"` - : `"${result.etag.value}"`; + ? `W/"${value}"` + : `"${value}"`; res.header('ETag', etagValue); + if (maskApplies && normalizeIfNoneMatch(req.headers['if-none-match']) === value) { + res.status(304).send(); + return; + } } if (result.lastModified) { res.header('Last-Modified', new Date(result.lastModified).toUTCString()); @@ -4509,7 +4775,7 @@ export class RestServer { name: req.params.name, }; res.json(await this.translateMetaEnvelope( - req, req.params.type, environmentId, cachedEnvelope, result.data, cacheI18n, + req, req.params.type, environmentId, cachedEnvelope, cachedDocument, cacheI18n, )); } else { // Non-cached version @@ -4641,6 +4907,21 @@ export class RestServer { visible = resolveDocLocale(visible as any, locale); } + // [ADR-0106 D1/D5(1)] The uncached exit. Same + // posture, same projection — this branch serves + // `?state=draft`, `?preview=draft`, `?package=` and + // any deployment with `enableCache: false`, so a + // mask that lived only in the cached branch would be + // walked past by a query parameter (#5881's shape, + // in reverse). + if (maskPosture.kind === 'project') { + const masked = this.maskObjectDocument(res, maskPosture, req.params.name, visible); + if (!masked) return; + visible = masked.document; + } else if (maskPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + } + res.header('Vary', 'Accept-Language'); res.json(await this.translateMetaEnvelope( req, req.params.type, environmentId, envelope, visible, @@ -5021,9 +5302,36 @@ export class RestServer { name: compoundName, packageId, } as any) as Record; + // [ADR-0106 D5(4)] Compound names express sub-resources, + // and no object uses one today — but this route serves + // EVERY type through one generic `getMetaItem`, so the + // question it answers for `object` is the same question + // the single-item route answers. Running the projection + // here costs one predicate on a path that will never + // reach it, and leaves no exit whose coverage depends on + // a naming convention holding. + let compoundDocument: any = envelope?.item; + const compoundType = RestServer.metaTypeSingular(req.params.type); + let compoundPosture: ObjectSchemaMaskPosture; + try { + compoundPosture = await (await this.resolveObjectMasker(environmentId, req, compoundType))(compoundName); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + sendFieldVisibilityFault(res, compoundName); + return; + } + throw maskError; + } + if (compoundPosture.kind === 'project') { + const masked = this.maskObjectDocument(res, compoundPosture, compoundName, compoundDocument); + if (!masked) return; + compoundDocument = masked.document; + } else if (compoundPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + } res.header('Vary', 'Accept-Language'); res.json(await this.translateMetaEnvelope( - req, req.params.type, environmentId, envelope, envelope?.item, + req, req.params.type, environmentId, envelope, compoundDocument, )); } catch (error: any) { handleRouteError(res, error); diff --git a/packages/runtime/src/domains/meta-object-fls.test.ts b/packages/runtime/src/domains/meta-object-fls.test.ts new file mode 100644 index 0000000000..613de1984f --- /dev/null +++ b/packages/runtime/src/domains/meta-object-fls.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106 / #3682] The dispatcher's `/metadata` catch-all serves object + * schemas through five resolvers, and every one of them is driven through the + * SAME case table the REST suite uses + * (`@objectstack/metadata-core/testing`). + * + * D5(3) names two of them ("protocol 与 registry 两条查找路径"); the sweep found + * three more that answer an object schema by a different door — the last-ditch + * protocol read for unscoped kernels, the `/metadata/objects` list, and the + * legacy one-segment `/metadata/:objectName` spelling. All five are here, so a + * caller cannot pick a door that forgot the mask. That is not hypothetical for + * this particular fan-out: #6562 already records the registry-backed and + * overlay-backed reads answering *different field sets* for the same object. + * + * The invariant is the same sentence the REST suite pins: for a restricted + * caller an unreadable field is COMPLETELY ABSENT — no third, quieter answer. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + OBJECT_SCHEMA_MASK_CASES, + FLS_CONTRACT_OBJECT, + assertObjectSchemaMaskCase, + securityDoubleFor, + type ObjectSchemaMaskCase, + type ObjectSchemaMaskExit, + type ObjectSchemaMaskOutcome, +} from '@objectstack/metadata-core/testing'; +import { OBJECT_SCHEMA_MASK_DISABLE_ENV } from '@objectstack/metadata-core'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const account = () => JSON.parse(JSON.stringify(FLS_CONTRACT_OBJECT)); + +function make(services: Record) { + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return new HttpDispatcher(kernel); +} + +const ctxFor = (testCase: ObjectSchemaMaskCase): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { ...testCase.context }, +}); + +/** + * Run one dispatcher request under a case's deployment, translating the answer + * into the contract's `document | fault` outcome. + * + * `maskingDisabled` is the D8 escape hatch: the dispatcher has no per-server + * REST config to read, so its knob is the environment variable — which is also + * the knob a deployment uses to turn BOTH surfaces off at once. + */ +async function runExit( + testCase: ObjectSchemaMaskCase, + services: Record, + path: string, + pick: (data: any) => unknown, +): Promise { + const security = securityDoubleFor(testCase); + const previous = process.env[OBJECT_SCHEMA_MASK_DISABLE_ENV]; + if (testCase.maskingDisabled) process.env[OBJECT_SCHEMA_MASK_DISABLE_ENV] = '1'; + else delete process.env[OBJECT_SCHEMA_MASK_DISABLE_ENV]; + try { + const dispatcher = make({ ...services, ...(security ? { security } : {}) }); + const res = await dispatcher.handleMetadata(path, ctxFor(testCase), 'GET'); + const status = res.response.status; + if (status >= 400) return { kind: 'fault', status }; + return { kind: 'document', document: pick(res.response.body.data) }; + } finally { + if (previous === undefined) delete process.env[OBJECT_SCHEMA_MASK_DISABLE_ENV]; + else process.env[OBJECT_SCHEMA_MASK_DISABLE_ENV] = previous; + } +} + +const EXITS: ObjectSchemaMaskExit[] = [ + { + name: '/metadata/object/:name — protocol-backed (scoped kernel)', + run: (testCase) => runExit( + testCase, + { + protocol: { + getProjectId: () => 'env_1', + getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: account(), lock: 'none' })), + }, + }, + '/object/account', + (data) => data?.item, + ), + }, + { + name: '/metadata/object/:name — registry-backed fallback', + run: (testCase) => runExit( + testCase, + { objectql: { registry: { getObject: vi.fn(() => account()) } } }, + '/object/account', + (data) => data?.item, + ), + }, + { + name: '/metadata/object/:name — last-ditch protocol read (unscoped kernel, registry miss)', + run: (testCase) => runExit( + testCase, + { + protocol: { + getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: account(), lock: 'none' })), + }, + objectql: { registry: { getObject: vi.fn(() => undefined) } }, + }, + '/object/account', + (data) => data?.item, + ), + }, + { + name: '/metadata/objects — protocol-backed list read', + run: (testCase) => runExit( + testCase, + { protocol: { getMetaItems: vi.fn(async () => ({ type: 'object', items: [account()] })) } }, + '/objects', + (data) => data?.items?.[0], + ), + }, + { + name: '/metadata/objects — registry-backed list read', + run: (testCase) => runExit( + testCase, + { objectql: { registry: { getAllObjects: vi.fn(() => [account()]) } } }, + '/objects', + (data) => data?.items?.[0], + ), + }, + { + name: '/metadata/:objectName — legacy one-segment object read', + run: (testCase) => runExit( + testCase, + { + objectql: { + registry: { + getAllObjects: vi.fn(() => []), + listItems: vi.fn(() => []), + getObject: vi.fn((name: string) => (name === 'account' ? account() : undefined)), + }, + }, + }, + '/account', + (data) => data, + ), + }, +]; + +describe('[ADR-0106] dispatcher /metadata object-schema masking — one contract, every resolver', () => { + for (const exit of EXITS) { + describe(exit.name, () => { + for (const testCase of OBJECT_SCHEMA_MASK_CASES) { + it(testCase.id, async () => { + const outcome = await exit.run(testCase); + assertObjectSchemaMaskCase(exit.name, testCase, outcome); + }); + } + }); + } +}); + +describe('[ADR-0106 D6 tier 3] a masking fault is not a lookup miss', () => { + it('a throwing security service does NOT fall through to the registry`s unmasked copy', async () => { + // The scoped-protocol branch swallows its own errors and falls through + // to the registry. If the fault took that path, the caller would get + // the full schema back through the very fallback the refusal exists to + // prevent — so the fault returns from INSIDE the try. + const getObject = vi.fn(() => account()); + const testCase: ObjectSchemaMaskCase = { + id: 'runtime/throws', why: 'D6 tier 3', context: { userId: 'u' }, + readable: 'throw', expect: { kind: 'fault' }, + }; + const dispatcher = make({ + protocol: { + getProjectId: () => 'env_1', + getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: account() })), + }, + objectql: { registry: { getObject } }, + security: securityDoubleFor(testCase), + }); + + const res = await dispatcher.handleMetadata('/object/account', ctxFor(testCase), 'GET'); + + expect(res.response.status).toBe(503); + expect(getObject).not.toHaveBeenCalled(); + expect(JSON.stringify(res.response.body)).not.toContain('salary_grade'); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 55ec4883d0..3c9a9a5de5 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -13,6 +13,17 @@ import { } from '@objectstack/core'; import { pluralToSingular } from '@objectstack/spec/shared'; import { CoreServiceName } from '@objectstack/spec/system'; +// [ADR-0106 / #3682] Metadata-plane FLS — the SAME projection the REST `/meta` +// exits run. Two dispatchers, one normalizer (`@objectstack/metadata-core`), +// because D5's "every schema-serving outlet" is only true if a future exit +// inherits the decision instead of re-deciding it. +import { + ObjectSchemaMaskEvaluationError, + applyObjectSchemaMask, + isObjectSchemaMaskingEnabled, + resolveObjectSchemaMaskPosture, + type ObjectSchemaMaskPosture, +} from '@objectstack/metadata-core'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -43,6 +54,107 @@ function slimDocList(type: string, data: any, query?: Record): a return data; } +/** + * [ADR-0106 D6 tier 3] The dispatcher's answer when the caller's field + * visibility could not be evaluated: a 503, never the unmasked body and never + * an empty-fields 200. Mirrors the REST layer's `sendFieldVisibilityFault`. + */ +function fieldVisibilityFault(deps: DomainHandlerDeps, objectName: string): HttpDispatcherResult { + return { + handled: true, + response: deps.error( + `Field visibility for object '${objectName}' could not be evaluated; the object schema is not being served.`, + 503, + ), + }; +} + +/** + * [ADR-0106 D2/D4/D6/D7/D8] Build this request's object-schema masker — the + * dispatcher-side twin of `RestServer.resolveObjectMasker`. + * + * Resolved once per request and asked per object name, so the `/metadata` list + * read pays one context + service resolution for the whole page. + */ +async function resolveObjectMasker( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): Promise<(objectName: string) => Promise> { + const enabled = isObjectSchemaMaskingEnabled(); + if (!enabled) { + const disabled: ObjectSchemaMaskPosture = { kind: 'passthrough', reason: 'disabled' }; + return async () => disabled; + } + const security = await deps.resolveService(context, 'security').catch(() => undefined); + const execCtx = (context as any)?.executionContext; + return (objectName: string) => resolveObjectSchemaMaskPosture({ + objectName, + context: execCtx, + security: security as any, + enabled: true, + telemetry: { + warn: (message, meta) => { + (globalThis as any).console?.warn?.(message, meta); + }, + }, + }); +} + +/** + * Project one served object schema, or report the D6 tier-3 fault. + * + * `'fault'` covers both the throw tier and the empty-projection case — see + * `applyObjectSchemaMask`'s `emptied` for why an empty-fields 200 is not an + * option the ADR leaves open. + */ +async function maskObjectSchema( + masker: (objectName: string) => Promise, + objectName: string, + document: any, +): Promise<{ ok: true; document: any } | { ok: false }> { + let posture: ObjectSchemaMaskPosture; + try { + posture = await masker(objectName); + } catch (error) { + if (error instanceof ObjectSchemaMaskEvaluationError) return { ok: false }; + throw error; + } + const masked = applyObjectSchemaMask(document, posture); + if (masked.emptied) return { ok: false }; + return { ok: true, document: masked.document }; +} + +/** + * [ADR-0106 D5(2)] Project every object schema in a list answer. + * + * A no-op for any type but `object`/`objects`, and shape-preserving for both + * list shapes the dispatcher hands around (a bare array and an + * `{ type, items }` envelope). One unevaluable object fails the whole list — + * serving the rest would leave a hole in the projection that no client can see. + */ +async function maskObjectSchemaList( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + typeOrName: string, + data: any, +): Promise<{ ok: true; data: any } | { ok: false; object: string }> { + if (pluralToSingular(typeOrName) !== 'object') return { ok: true, data }; + const list: any[] | null = Array.isArray(data) + ? data + : (data && Array.isArray(data.items) ? data.items : null); + if (!list || list.length === 0) return { ok: true, data }; + + const masker = await resolveObjectMasker(deps, context); + const projected: any[] = []; + for (const item of list) { + const objectName = String(item?.name ?? ''); + const masked = await maskObjectSchema(masker, objectName, item); + if (!masked.ok) return { ok: false, object: objectName }; + projected.push(masked.document); + } + return { ok: true, data: Array.isArray(data) ? projected : { ...data, items: projected } }; +} + /** * Handles Metadata requests * Standard: /metadata/:type/:name @@ -199,6 +311,15 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin : protocol?.environmentId; const scoped = scopedEnv !== undefined; + // [ADR-0106 D2/D5(3)] ONE posture for this caller × this object, + // resolved before any lookup, applied to whichever of the three + // lookups below answers. Resolving it per-lookup is how the + // registry-backed path ends up unmasked while the protocol-backed + // one is masked — two answers to one question, which is the + // shape #6562 already records for a different dimension of this + // very fan-out. + const objectMasker = await resolveObjectMasker(deps, _context); + if (scoped && typeof protocol.getMetaItem === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -211,7 +332,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // registry fallback below never ran. The guard now asks the // question its own comment claims it asks. if (data?.item != null) { - return { handled: true, response: deps.success(data) }; + // The fault RETURNS from inside the try on purpose: + // a masking failure is not a lookup miss, and + // falling through to the registry below would answer + // with the very body the fault exists to withhold. + const masked = await maskObjectSchema(objectMasker, name, data.item); + if (!masked.ok) return fieldVisibilityFault(deps, name); + return { handled: true, response: deps.success({ ...data, item: masked.document }) }; } } catch { /* fall through to registry / 404 */ } } @@ -225,7 +352,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // the declared `GetMetaItemResponseSchema` envelope — `type` and // `name` come from the request, the same values the protocol // would have echoed back. - if (data) return { handled: true, response: deps.success({ type: 'object', name, item: data }) }; + if (data) { + const masked = await maskObjectSchema(objectMasker, name, data); + if (!masked.ok) return fieldVisibilityFault(deps, name); + return { handled: true, response: deps.success({ type: 'object', name, item: masked.document }) }; + } } // Last-ditch protocol attempt for unscoped kernels whose @@ -236,7 +367,9 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const organizationId = await deps.resolveActiveOrganizationId(_context); const data = await protocol.getMetaItem({ type: 'object', name, organizationId }); if (data?.item != null) { - return { handled: true, response: deps.success(data) }; + const masked = await maskObjectSchema(objectMasker, name, data.item); + if (!masked.ok) return fieldVisibilityFault(deps, name); + return { handled: true, response: deps.success({ ...data, item: masked.document }) }; } } catch { /* fall through to 404 */ } } @@ -382,7 +515,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts }); // Return any valid response from protocol (including empty items arrays) if (data && (data.items !== undefined || Array.isArray(data))) { - return { handled: true, response: deps.success(slimDocList(typeOrName, data, query)) }; + // [ADR-0106 D5(2)] The dispatcher's list read is the same + // outlet as REST's `GET /meta/object`, reached by a different + // door. + const projected = await maskObjectSchemaList(deps, _context, typeOrName, data); + if (!projected.ok) return fieldVisibilityFault(deps, projected.object); + return { handled: true, response: deps.success(slimDocList(typeOrName, projected.data, query)) }; } } catch { // Protocol doesn't know this type, fall through @@ -415,16 +553,26 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (qlService?.registry) { if (typeOrName === 'objects') { const objs = qlService.registry.getAllObjects(packageId); - return { handled: true, response: deps.success({ type: 'object', items: objs }) }; + const projected = await maskObjectSchemaList(deps, _context, 'object', { type: 'object', items: objs }); + if (!projected.ok) return fieldVisibilityFault(deps, projected.object); + return { handled: true, response: deps.success(projected.data) }; } // Try listing items of the given type const items = qlService.registry.listItems?.(typeOrName, packageId); if (items && items.length > 0) { return { handled: true, response: deps.success({ type: typeOrName, items }) }; } - // Legacy: treat as object name + // Legacy: treat as object name. [ADR-0106 D5(4)] A schema-bearing + // exit reached by a one-segment path — masked like every other, so + // the legacy spelling is not a way around the projection. const obj = qlService.registry.getObject(typeOrName); - if (obj) return { handled: true, response: deps.success(obj) }; + if (obj) { + const masked = await maskObjectSchema( + await resolveObjectMasker(deps, _context), typeOrName, obj, + ); + if (!masked.ok) return fieldVisibilityFault(deps, typeOrName); + return { handled: true, response: deps.success(masked.document) }; + } } return { handled: true, response: deps.error('Not found', 404) }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 091ce13199..cb2b18ee7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1859,6 +1859,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../core + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../metadata-core '@objectstack/observability': specifier: workspace:* version: link:../observability diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json index 30c12a1ac2..d594136c7f 100644 --- a/scripts/adr-anchors.json +++ b/scripts/adr-anchors.json @@ -267,7 +267,7 @@ "invariant": "The transaction seam is same-origin by construction (#5351/#5696, 2026-08-06 ruling). `buildDriverOptions` threads the ambient handle ONLY to the driver that owns it, compared by instance identity via `TransactionScope` — handing it to another driver does not put that driver inside the transaction, it runs the statement on the wrong connection. `enforceTransactionOrigin` then decides the write: an append-only system ledger (`lifecycle.class` in `SYSTEM_LEDGER_LIFECYCLE_CLASSES`, the same tuple ADR-0057 §3.6 routing reads — never a second copy) is CARVED OUT and executed outside the transaction, surviving rollback; any other cross-driver write is REFUSED before hooks run. Never add a companion transaction on the second driver (no two-phase commit in IDataDriver) and never restore the unconditional handle lift." }, { -"file": "packages/spec/src/type-alias-convention.pin.test.ts", + "file": "packages/spec/src/type-alias-convention.pin.test.ts", "adrs": [ "ADR-0122" ], @@ -279,6 +279,20 @@ "ADR-0122" ], "invariant": "ADR-0122 D7: every bare `z.infer` alias in `packages/spec/src/**/*.zod.ts` must either be paired with its `XParsed` or be pinned isomorphic in the pin file. The exemption list deliberately does NOT live in this script — it lives in the pin file where tsc proves each entry, because an exemption a comment merely asserts is the declared-but-unenforced shape this repo keeps paying to fix. Moving the list in here to 'simplify' the gate would make it a phantom check. The gate also belongs HERE rather than in `packages/lint`: that package's contract is an in-memory metadata graph with 'no I/O, no runtime, no filesystem', and a rule reading our own TypeScript source cannot live there." + }, + { + "file": "packages/metadata-core/src/object-schema-fls.ts", + "adrs": [ + "ADR-0106" + ], + "invariant": "ADR-0106 is the WHOLE decision this module realizes: object schemas are projected per caller (D1, unreadable fields removed whole), the projection runs in the dispatch layer after the protocol fetch (D2), the shared cache keeps one full copy and the caller's DENIED-set fingerprint is folded into the ETag (D3), isSystem and platform admins are exempt as a CALLER property (D4), and the failure posture is three tiers — no security service or exempt caller serve unmasked, an unresolvable field universe serves unmasked with telemetry and private/no-store, and a THROW refuses the request (D6). The tiers look asymmetric on purpose: failing closed on the middle tier converts a hydration window into a rendering outage and risks a bootstrap deadlock, because permission sets are themselves metadata. Anyone tempted to \"simplify\" a tier, to widen the exemption set, or to make the fingerprint hash the READABLE set (which would move every unrestricted caller's ETag and forfeit D3's zero-regression promise) is reversing a recorded decision." + }, + { + "file": "packages/plugins/plugin-security/src/security-plugin.ts", + "adrs": [ + "ADR-0106" + ], + "invariant": "ADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this." } ] }