Skip to content

Commit e17af0e

Browse files
committed
test(meta): drive every object-schema exit from one ADR-0106 case table
Adds the shared contract table (metadata-core/testing), the REST and runtime exit suites, the pure-module unit tests and the plugin-security D7 suite, plus the changeset, the deployment CDN/proxy guidance, the ADR-0106 status flip and its adr-anchors entries. Refs #3682
1 parent ad0235b commit e17af0e

11 files changed

Lines changed: 1357 additions & 2 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
"@objectstack/metadata-core": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/rest": minor
6+
---
7+
8+
feat(meta): object schemas served by `/meta` and `/metadata` are masked per caller (ADR-0106, #3682)
9+
10+
The data plane has enforced field-level security everywhere it matters for
11+
several releases — list reads mask values, exports project columns, and the
12+
write path 403s forbidden fields. The **metadata** plane did not: any
13+
authenticated caller who asked `GET /meta/object/:name` received the full object
14+
schema, including fields they have no read access to at all.
15+
16+
That is more than a list of names. A field carries its label, type, **picklist
17+
option values** (often a sensitive operational taxonomy), its **formula**
18+
expression (pricing and scoring IP), its `visibleWhen` predicate, its
19+
`defaultValue`, and — via ADR-0066 D3 — the `requiredPermissions` capability
20+
names guarding it. For a customer running a dealer, supplier or patient portal
21+
on ObjectStack, the only remediation available in their own tier was modelling
22+
discipline: keep sensitive fields off portal-visible objects, or split one
23+
business entity into an internal object and a portal object and synchronize
24+
them. This is a platform-side fix, so every deployment inherits it.
25+
26+
**What changes.** Serving an object schema now projects `fields` onto the set
27+
the caller may read, and a field outside that set is removed **whole** — no
28+
name, no label, no options, no formula, no `requiredPermissions`. Partial
29+
redaction was rejected: keeping the name still leaks existence and invites
30+
clients to render ghost columns. Masking keys on the `readable` bit only; a
31+
readable-but-not-editable field stays in the schema, because the UI must render
32+
it and the `editable` affordance is already served per caller by
33+
`/auth/me/permissions`.
34+
35+
Every outlet that serves an object schema goes through one shared projection,
36+
so coverage is not a per-route promise:
37+
38+
- `GET /meta/object/:name` — the cached branch (the default) **and** the
39+
uncached branch, which is what `?state=draft`, `?preview=draft` and
40+
`?package=` take;
41+
- `GET /meta/object/:name?layers=true` — the layered diagnostic view, all three
42+
of `code` / `overlay` / `effective`;
43+
- `GET /meta/:type/:section/:name` — the compound-name read;
44+
- `GET /meta/object` — the list read, each item projected independently;
45+
- the runtime `/metadata` catch-all — the protocol-backed, registry-backed and
46+
last-ditch single reads, the `/metadata/objects` list (protocol and registry),
47+
and the legacy one-segment `/metadata/:objectName` spelling.
48+
49+
**Caching is unchanged in cost and correct per cohort.** The shared metadata
50+
cache still stores one full schema per (type, name, locale, environment) — no
51+
caller dimension in the key — and the mask runs after retrieval. What varies
52+
per caller is the validator: a stable hash of the caller's *denied* field set is
53+
folded into the ETag. A caller who can read everything denies nothing, so their
54+
fingerprint is empty and both their ETag and their response body are
55+
**byte-identical** to previous releases. Callers in one permission cohort share
56+
`304`s; a permission change moves the fingerprint and self-invalidates the stale
57+
`304`, so nothing needs purging after a permission-set edit.
58+
59+
**Exemptions** are a property of the caller, not of the route: `isSystem` and
60+
platform-admin callers (holders of `studio.access` / `setup.access`, the same
61+
judgement the app filter uses) receive the full schema on any route, because
62+
Studio and Setup authoring cannot work against a projected schema.
63+
64+
**Failure posture is explicit and three-tiered.** With no `security` service
65+
registered the schema is served unmasked — that deployment has no FLS posture at
66+
all and tightening only the metadata plane would be theater. When field
67+
visibility cannot be *determined* (a registry-hydration window), the schema is
68+
served unmasked but loudly: a structured warning, a new
69+
`objectstack_meta_field_visibility_undetermined_total` counter, and a response
70+
downgraded to `Cache-Control: private, no-store` with no shared ETag. Failing
71+
closed there would brick every render of the object for every user and can
72+
deadlock console bootstrap, since permission sets are themselves metadata. When
73+
permission evaluation **throws**, the request fails with `503
74+
FIELD_VISIBILITY_UNRESOLVED` — an unhealthy security service must not auto-open
75+
a disclosure hole, and an empty-fields `200` would be both a silently wrong UI
76+
and cacheable poison.
77+
78+
**Guest and public deployments** get a deliberate posture rather than an
79+
accidental one: `@objectstack/plugin-security` gains
80+
`getMetadataReadableFields`, which resolves the configured fallback permission
81+
set (`security.fallbackPermissionSet`, default `member_default`) for a caller
82+
who resolves to zero sets, exactly as `/auth/me/permissions` does.
83+
`getReadableFields` is unchanged — on the data plane, mirroring the engine
84+
middleware's fall-open is what keeps it drift-free.
85+
86+
**Escape hatch.** Masking is the platform default. A deployment that explicitly
87+
wants an unmasked metadata plane sets `OS_ALLOW_UNMASKED_OBJECT_METADATA=1`, or
88+
`metadata.maskObjectFields: false` on the REST server. Toggling it changes
89+
disclosure only: the console reads every field affordance from
90+
`/auth/me/permissions`, so UI correctness is unaffected either way.
91+
92+
Operators fronting the runtime with a CDN or reverse proxy should read the new
93+
"CDN / reverse-proxy caching of `/meta` object schemas" section in the
94+
production-readiness guide before tuning anything — in particular, do not
95+
configure a proxy to ignore `Cache-Control: private`, and do not strip or
96+
rewrite `ETag` on these routes.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from
301301
|:---|:---|:---|:---|
302302
| `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. |
303303
| `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). |
304+
| `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. |
304305
| `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). |
305306

306307
---

‎content/docs/deployment/production-readiness.mdx‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,53 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/ma
104104
compliance regime needs longer, and register an `archive` datasource
105105
if audit data must move to cold storage instead of being retained hot.
106106

107+
## CDN / reverse-proxy caching of `/meta` object schemas
108+
109+
Object schemas are the hottest metadata read — every list, form and detail
110+
render fetches one — so they are the first thing an operator reaches for when
111+
putting a CDN or reverse proxy in front of the runtime. Since
112+
[ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md)
113+
those responses are **per-caller**, and the rules below are what keep that safe.
114+
115+
- **The response is already marked private.** `GET /meta/object/:name` answers
116+
`Cache-Control: private, no-cache` — store it in the *client*, revalidate on
117+
every use, never in a shared cache. A proxy configured to ignore `private`
118+
(`proxy_ignore_headers Cache-Control`, an "override TTL" rule, an edge worker
119+
that caches by URL alone) would serve one user's projection to another. Do
120+
not add such a rule for `/meta` or `/metadata`.
121+
- **The ETag carries the caller's field visibility.** The validator is the
122+
shared document hash plus, for a restricted caller, a `~<fingerprint>` suffix
123+
hashing the set of fields that caller cannot read. Consequences worth knowing
124+
before tuning anything:
125+
- A caller who can read every field produces **no** suffix, so their ETag and
126+
body are byte-identical to pre-ADR-0106 releases — existing cache hit rates
127+
on unrestricted deployments are unchanged.
128+
- Callers in the same permission cohort share one validator, and therefore
129+
share `304`s. Cache storage stays O(objects), not O(users × objects).
130+
- A permission change moves the fingerprint, so a stale `304` self-invalidates
131+
on the next revalidation. There is nothing to purge after editing a
132+
permission set.
133+
- **Never strip or rewrite `ETag`** on these routes (some proxies drop it when
134+
they compress). Without it every read falls back to a full body, and a proxy
135+
that *replaces* it with its own hash of the served body erases the cohort
136+
dimension.
137+
- **A `private, no-store` response means field visibility could not be
138+
resolved.** In that degraded window the runtime serves the unmasked schema
139+
deliberately — failing closed would brick every render of the object and can
140+
deadlock console bootstrap, since permission sets are themselves metadata —
141+
and marks it un-storable so it cannot be replayed to anyone else. It also
142+
emits a structured warning and increments
143+
`objectstack_meta_field_visibility_undetermined_total`. **Alert on that
144+
counter**: a deployment sitting in this state is disclosing more than it
145+
intends, and it is meant to be a hydration blip, not a resting state.
146+
- **A `503 FIELD_VISIBILITY_UNRESOLVED` is the security service being
147+
unhealthy**, not the metadata store. The runtime refuses the read rather than
148+
disclose an unmasked schema. It is safe to retry and must not be cached.
149+
- To deliberately restore the old, unmasked behaviour on a trusted deployment,
150+
the supported switch is `OS_ALLOW_UNMASKED_OBJECT_METADATA=1` (or per-server
151+
`metadata.maskObjectFields: false`) — not a caching rule that hides the
152+
per-caller dimension.
153+
107154
## What's NOT in the runtime (yet)
108155

109156
- **OTel context propagation.** We export `parseTraceparent` /

‎docs/adr/0106-metadata-plane-fls-object-schema-masking.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0106: Metadata-Plane Field-Level Security — Per-Caller Masking of Object Schemas
22

3-
**Status**: Proposed (2026-07-27)
3+
**Status**: Accepted (2026-07-27; implemented 2026-08-08 — #3682)
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**Tracking**: #3661 (steps ① ② shipped client-side as objectui#2866; this ADR is step ③)

0 commit comments

Comments
 (0)