Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .changeset/metadata-plane-fls-object-schema-masking.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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). |

---
Expand Down
47 changes: 47 additions & 0 deletions content/docs/deployment/production-readiness.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `~<fingerprint>` 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` /
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0106-metadata-plane-fls-object-schema-masking.md
Original file line numberDiff line numberDiff line change
@@ -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 ③)
Expand Down
9 changes: 9 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Loading
Loading