Skip to content

Commit ab746e5

Browse files
committed
docs(adr): ADR-0106 — metadata-plane FLS, per-caller masking of object schemas (#3661 step ③)
Object-schema reads currently ship the full schema (field names, labels, picklist option values, formula expressions, visibleWhen predicates, requiredPermissions capability names) to any authenticated caller — the metadata-plane twin of the export-header leak the #3391 series closed on the data plane. ObjectStack is a development platform: whether a deployment's authenticated callers are trusted is the customer's call, so the platform must enforce rather than assume. Decisions (Proposed): - D1 mask by readable projection, remove masked fields entirely - D2 enforce in the dispatch layer; metadata-protocol stays caller-free - D3 mask-after-cache with a field-visibility fingerprint folded into the ETag (single full copy per object, cohort-correct 304s) - D4 isSystem / platform-admin exemption (Studio needs full schema) - D5 coverage: single read (cached + uncached), list read, runtime /metadata catch-all, plus an outlet audit - D6 three-tier failure posture: no-service → open; undefined → open with telemetry + private cache headers; evaluation throws → 5xx (never the unmasked body on an error path, never an empty-fields 200) - D7 zero-set callers resolve the fallback permission set - D8 default on, current major, config escape hatch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEb4XCVb7iLEBVe9HghjTt
1 parent 984396b commit ab746e5

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# ADR-0106: Metadata-Plane Field-Level Security — Per-Caller Masking of Object Schemas
2+
3+
**Status**: Proposed (2026-07-27)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**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)
6+
**Tracking**: #3661 (steps ① ② shipped client-side as objectui#2866; this ADR is step ③)
7+
**Consumers**: `@objectstack/rest` (`/meta` routes), `@objectstack/runtime` (`/metadata` dispatch), `@objectstack/plugin-security` (`getReadableFields`), `@objectstack/metadata-protocol` (explicitly **unchanged**), ObjectUI console
8+
9+
---
10+
11+
## TL;DR
12+
13+
The data plane enforces field-level security everywhere it matters — list
14+
reads mask values, exports project columns (#3498#3547#3561#3649),
15+
and the write path 403s forbidden fields — but the **metadata plane ships the
16+
full object schema to any authenticated caller**. A caller with no read access
17+
to a field still receives its name, label, type, picklist option values,
18+
formula expression, `visibleWhen` predicate, default value, and the
19+
`requiredPermissions` capability names guarding it. ObjectStack is a
20+
development platform: whether a deployment's authenticated callers are trusted
21+
is the customer's call, not ours, so the platform must enforce, not assume.
22+
23+
- **D1** — Object-schema reads are FLS-masked per caller: fields the caller
24+
cannot read are removed from the served schema **entirely**. Masking keys on
25+
the `readable` bit only; `editable` remains the domain of
26+
`/auth/me/permissions`.
27+
- **D2** — Masking runs in the dispatch layer (REST + runtime) *after* the
28+
protocol fetch, via the already-registered
29+
`security.getReadableFields(object, ctx)` (#3547). `getMetaItem` stays
30+
caller-free.
31+
- **D3** — The shared metadata cache keeps storing **one full copy** per
32+
object; masking is applied after cache retrieval, and the caller's
33+
field-visibility fingerprint is folded into the ETag so `304` semantics stay
34+
correct per permission cohort. No per-user cache blowup; unrestricted
35+
callers get byte-identical responses to today.
36+
- **D4**`isSystem` and platform-admin callers are exempt (Studio/Setup
37+
authoring requires the full schema).
38+
- **D5** — Coverage is the complete set of schema-serving outlets: single-item
39+
read (cached + uncached), list read, and the runtime `/metadata` catch-all —
40+
plus an audit of any other schema-bearing endpoint.
41+
- **D6** — Failure posture is three-tier: *no security service* → serve
42+
unmasked (the deployment has no FLS posture); *cannot determine*
43+
(`undefined`) → serve unmasked **with telemetry and private cache headers**;
44+
*evaluation throws***fail the request (5xx)** — never serve the unmasked
45+
body on an error path, and never serve an empty-fields `200`.
46+
- **D7** — Callers that resolve to zero permission sets go through the same
47+
fallback-set resolution `/auth/me/permissions` uses, so guest/public
48+
deployments get a deliberate posture instead of an accidental full-schema
49+
default.
50+
- **D8** — Default **on**, ships with the current major; config escape hatch
51+
for deployments that explicitly want an unmasked metadata plane.
52+
53+
**Rejected**: documenting "the metadata plane does not mask" as the platform
54+
default (indefensible once callers are untrusted); per-caller cache keys
55+
(cache blowup); threading caller identity into `metadata-protocol` (radius,
56+
and it would break the single-copy cache); serving object reads fully
57+
cache-bypassed as the end state (acceptable fallback, not the target). See
58+
Alternatives.
59+
60+
---
61+
62+
## Context
63+
64+
### What leaks today, and why it matters on a platform
65+
66+
`GET /meta/object/:name` (REST), `GET /meta/object` (list), and the runtime
67+
`/metadata` catch-all all return the object schema exactly as the protocol
68+
stores it. The only gate is the anonymous/`requireAuth` check. Per-caller
69+
filtering exists in the same handler for **other** types — `app` goes through
70+
`filterAppForUser`, `dashboard` through the `requiresService` gate, `book`/
71+
`doc` through the ADR-0046 §6.7 audience gate — but `object`, the type whose
72+
fields FLS is *about*, has none.
73+
74+
The leak is not just field names. A schema field carries its label, type,
75+
**picklist option values** (which may themselves be sensitive enumerations),
76+
**formula expressions** (business logic that may reference masked fields),
77+
`visibleWhen` CEL predicates, default values, and — via ADR-0066 D3
78+
`requiredPermissions` — the names of the capabilities guarding it. This is
79+
the metadata-plane twin of the export-header leak the #3391 series closed on
80+
the data plane.
81+
82+
An earlier disposition ("target deployments have no untrusted authenticated
83+
users, so document no-masking as the default") died on the platform premise:
84+
ObjectStack customers build portals and products whose authenticated callers
85+
the platform cannot vouch for. Enforcement must be the platform default
86+
(ADR-0049).
87+
88+
### What is already in place
89+
90+
- `security.getReadableFields(object, ctx)` (#3547): the authoritative
91+
readable-column set for a caller — `isSystem` bypass, permission-set
92+
resolution, ADR-0066 D3 `requiredPermissions` AND-gate folded in. Returns
93+
`undefined` when the field universe is unresolvable. The export path
94+
already consumes it.
95+
- The write path already 403s forbidden fields (ObjectQL security middleware
96+
step 2.5), so this ADR is about **disclosure**, not write authorization.
97+
- `/auth/me/permissions` already serves per-caller `{readable, editable}`
98+
bits to the UI, and after objectui#2866 (steps ① ② of #3661) every
99+
client-side field gate consumes that channel; the dead declared-but-never-
100+
enforced `field.permissions` schema shape was removed per ADR-0049.
101+
- The `doc`/`book` audience gate already established "per-caller metadata
102+
reads bypass the shared cache" as an acceptable pattern — for cold reads.
103+
Object schemas are the hottest metadata read (every list/form/detail render
104+
fetches them), which is why this ADR does better than a bypass (D3).
105+
106+
---
107+
108+
## Decisions
109+
110+
### D1 — Mask by `readable` projection; remove masked fields entirely
111+
112+
Serving an object schema to caller *C* projects `fields` onto
113+
`getReadableFields(object, C)`. A field outside the set is removed **whole**
114+
— name, label, type, options, formula, `visibleWhen`, `defaultValue`,
115+
`requiredPermissions`, everything. Partial redaction (keep the name, strip
116+
the rest) still leaks existence and invites clients to render ghost columns.
117+
118+
Masking keys on `readable` **only**. A readable-but-not-editable field stays
119+
in the schema — the UI must render it (disabled where relevant), and the
120+
`editable` affordance is already served per caller by
121+
`/auth/me/permissions`. Consequently this ADR needs **no**
122+
`getEditableFields` dual: the import-template concern that motivated one
123+
(#3661 §5) was resolved client-side by objectui#2866, and the server's write
124+
gate remains the enforcement boundary.
125+
126+
Division of labor after this ADR:
127+
128+
| Layer | Mechanism | Question it answers |
129+
|---|---|---|
130+
| Data plane — read | engine middleware `maskResults` | "what values can I see" |
131+
| Data plane — write | middleware step 2.5 explicit 403 | "what fields can I write" |
132+
| Metadata plane | **this ADR** — schema projection | "what fields exist, shaped how" |
133+
| UI affordance | `/auth/me/permissions` + `checkField` | "how should the UI behave" |
134+
135+
### D2 — Enforce in the dispatch layer; the protocol stays caller-free
136+
137+
Masking runs in `@objectstack/rest` (`/meta` handlers) and
138+
`@objectstack/runtime` (`domains/meta.ts`) after the item is fetched,
139+
calling the registered `security` service. `protocol.getMetaItem` /
140+
`getMetaItems` signatures are **unchanged**: the protocol remains a
141+
caller-free, cacheable source of truth, which is precisely what makes D3's
142+
single-copy cache sound. Threading `userId`/permission sets into the
143+
protocol was rejected (see Alternatives).
144+
145+
### D3 — Mask after cache; fold a visibility fingerprint into the ETag
146+
147+
The shared metadata cache (`getMetaItemCached`) keeps storing **one full
148+
schema per (type, name, locale, environment)** — no caller dimension in the
149+
cache key. The pipeline becomes:
150+
151+
```
152+
fetch (shared cache or protocol) → mask for caller → send
153+
```
154+
155+
with the **ETag** extended by the caller's field-visibility fingerprint for
156+
that object: a stable hash of the caller's *denied* field set (empty for
157+
unrestricted callers). Properties:
158+
159+
- Unrestricted callers (the overwhelming majority in most deployments)
160+
produce an empty fingerprint → byte-identical ETags and bodies to today;
161+
zero regression.
162+
- Callers in the same permission cohort share `304`s.
163+
- A permission change alters the fingerprint → the stale `304` path
164+
self-invalidates.
165+
- Cache storage stays O(objects), not O(users × objects).
166+
167+
The masking step is *inside* the cached path, before headers are emitted —
168+
there must be no code path on which the cached full body reaches the wire
169+
without passing the mask (this ordering is also what makes D6's error tier
170+
safe). A full cache bypass for restricted callers (the `doc`/`book`
171+
pattern) is the acknowledged fallback if fingerprinting proves awkward in
172+
implementation — correct, simpler, but it forfeits `304`s on the hottest
173+
metadata read, so it is a stepping stone, not the end state.
174+
175+
### D4 — Exemptions: `isSystem` and platform admins
176+
177+
`getReadableFields` already bypasses for `isSystem`. Platform-admin callers
178+
(the same `systemPermissions`-based judgment the `app` filter uses) are
179+
likewise exempt: Studio/Setup authoring requires the full schema, and
180+
draft/preview reads are admin-gated upstream already. The exemption is a
181+
*caller* property, not a route property — an admin hitting the public route
182+
gets the full schema; a non-admin hitting any route gets the projection.
183+
184+
### D5 — Coverage: every schema-serving outlet, or the mask is decoration
185+
186+
1. `GET /meta/object/:name`**both** the `getMetaItemCached` fast path and
187+
the uncached `getMetaItem` path.
188+
2. `GET /meta/object` — the list read (`getMetaItems`); each item projected
189+
the same way.
190+
3. Runtime `/metadata` catch-all (`domains/meta.ts` object branch) — both the
191+
protocol-backed and registry-backed lookups.
192+
4. An implementation-time audit of other schema-bearing outlets (OData
193+
`$metadata`/describe-style surfaces, client SDK describe calls); anything
194+
found either routes through the same projection or is filed per Prime
195+
Directive #10.
196+
197+
### D6 — Failure posture: three tiers, never silent, never empty-200
198+
199+
| Failure | Posture | Rationale |
200+
|---|---|---|
201+
| `security` service not registered | Serve unmasked | The deployment has no FLS posture at all — the data plane doesn't mask either; the metadata plane tightening alone would be theater. |
202+
| Service present, `getReadableFields``undefined` (field universe unresolvable) | Serve unmasked **+ structured warn + metric**, and downgrade the response to `Cache-Control: private, no-store`, no shared ETag | Matches the two same-class precedents (engine middleware: unresolvable → no mask; #3547 export: `undefined` → no projection). The window is operational (registry hydration), not attacker-inducible — the request carries only `type`/`name`; a caller cannot construct the failing state without authoring rights. Failing closed here bricks every render of the object for every user and risks a bootstrap deadlock: permission sets are themselves metadata, and the console cannot reach "permissions resolvable" if `/meta` refuses to serve while security warms up. |
203+
| Permission evaluation **throws** | **Fail the request (5xx)** | An unhealthy security service must not auto-open a disclosure hole — infra failures are far more frequent than hydration windows. The only safe closed form is an *error*: visible, retryable, never cached. Serving an empty-fields `200` is the worst option — silently wrong UI **and** cacheable poison. D3's ordering guarantees the cached full body cannot leak on this path. |
204+
205+
Reconciliation with ADR-0049: enforce-or-remove targets *silent* unenforced
206+
properties (exactly what the removed `field.permissions` shape was). Here
207+
enforcement exists; the degraded tier is an explicit, argued, **observable**
208+
decision — the `undefined` tier must emit telemetry loud enough that a
209+
deployment living in it is a visible operational condition, not a quiet
210+
default. The `doc`/`book` fail-closed precedent is *access control on
211+
content* (the payload itself is the secret, and one unrendered doc does not
212+
cascade); schema masking is *disclosure control on infrastructure* (every
213+
surface depends on it) — different class, different posture.
214+
215+
### D7 — Zero-permission-set callers resolve the fallback set
216+
217+
Today `getReadableFields` returns the full field set when no permission sets
218+
resolve (mirroring the engine middleware). For masked metadata reads, a
219+
caller resolving to zero sets goes through the same fallback resolution
220+
`/auth/me/permissions` uses (`security.fallbackPermissionSet`, default
221+
`member_default`; guest-facing deployments point it at their guest set). A
222+
public deployment's schema exposure thereby becomes a deliberate
223+
permission-set decision, not an accidental everything-default. Anonymous
224+
callers on `requireAuth` deployments remain blocked before any of this.
225+
226+
### D8 — Default on, current major, config escape hatch
227+
228+
Masking is the platform default and ships with the major release already in
229+
flight (the same train as objectui#2866's breaking removal). A server config
230+
key (`metadata.maskObjectFields: false`-style, exact name at implementation)
231+
opts a deployment out — after #2866 the UI reads all field affordances from
232+
`/auth/me/permissions`, so toggling the mask changes disclosure only, never
233+
UI correctness. ①'s client-side gates are **not** superseded: the schema
234+
mask handles existence; the client channel still drives
235+
readable-but-disabled rendering and payload stripping. The two layers are
236+
complementary by design.
237+
238+
---
239+
240+
## Alternatives considered
241+
242+
- **Document "no masking" as the explicit default** (the pre-platform-premise
243+
disposition). Defensible only while every authenticated caller is trusted;
244+
a development platform cannot promise that on customers' behalf. Rejected.
245+
- **Per-caller cache keys.** Correct, simple, O(users × objects) cache
246+
entries. Rejected for blowup; the fingerprint-ETag gets cohort-correct
247+
`304`s at O(objects) storage.
248+
- **Thread caller context into `metadata-protocol`.** Largest radius: every
249+
`getMetaItem` call site, and the protocol's single-copy cacheability —the
250+
property D3 exploits — is lost. The dispatch layer already resolves
251+
execution contexts for the `app`/`dashboard`/`doc` gates. Rejected.
252+
- **Full cache bypass for object reads** (`doc`/`book` pattern) **as the end
253+
state.** Correct and simple, but object schemas are the hottest metadata
254+
read; forfeiting shared `304`s there is a real cost. Kept as the
255+
acknowledged implementation fallback (D3), rejected as the target.
256+
- **Fail-closed on `undefined`** (D6 middle tier). Symmetric-looking but
257+
wrong class: it converts an operational hydration window into a full
258+
rendering outage with a bootstrap deadlock risk, to prevent a bounded
259+
disclosure that two same-class precedents already accept. Rejected —
260+
with the compensating requirement that the open tier be loudly observable.
261+
262+
---
263+
264+
## Consequences
265+
266+
- A caller without read access to a field no longer learns the field exists,
267+
let alone its label, options, formula, or guarding capabilities — the
268+
metadata plane joins the data plane's FLS posture.
269+
- Unrestricted callers see byte-identical responses; restricted cohorts get
270+
correct per-cohort `304` semantics.
271+
- `metadata-protocol` is untouched; implementation lands in `rest`,
272+
`runtime`, and (for D7 fallback resolution) `plugin-security`.
273+
- The `undefined` tier introduces a monitored fail-open window; deployments
274+
that cannot tolerate even that can front it with the escape hatch inverted
275+
(mask-or-503 mode) if ever demanded — deliberately out of scope now.
276+
- Follow-ups at implementation time: the D5(4) outlet audit, and wiring the
277+
fingerprint into any CDN/proxy caching guidance in the deployment docs.

0 commit comments

Comments
 (0)