Skip to content

Commit 2eb1b70

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0106 — metadata-plane FLS, per-caller masking of object schemas (#3661 step ③) (#3678)
* 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 * chore: empty changeset for ADR-0106 (docs-only, releases nothing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEb4XCVb7iLEBVe9HghjTt * docs(adr): ADR-0106 — add Business context section Distills what the disclosure boundary is worth: the claimable FLS invariant (pentest/questionnaire/Salesforce-describe parity), the platform-leverage shape (customers cannot fix /meta exposure in their own tier), unlocked mixed-sensitivity modeling (no object splitting for portal scenarios), and schema shape as business information — against a deliberately near-zero cost side (D3/D8). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEb4XCVb7iLEBVe9HghjTt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 524151c commit 2eb1b70

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
---
3+
4+
ADR-0106 (Proposed): metadata-plane FLS — per-caller masking of object
5+
schemas (#3661 step ③). Docs-only; releases nothing.
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
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+
### Business context — what masking is worth
89+
90+
The value scale of this ADR is not "prevents data breaches" (the data plane
91+
already enforces reads and writes); it is what the *disclosure* boundary
92+
buys the platform and its customers:
93+
94+
- **The invariant becomes claimable.** After this ADR the platform can state
95+
— in security questionnaires, audits, and competitive POCs — one complete,
96+
testable sentence: *a field the caller cannot read does not exist for that
97+
caller, on any plane*. Today a competent pentester falsifies the "we have
98+
field-level security" claim with a single unauthenticated-of-privilege
99+
`curl /meta/object/<name>`; a pentest finding is remediation cost and a
100+
deal blocker. The industry benchmark behaves this way already —
101+
Salesforce's `describe` omits fields the caller cannot read — so "FLS
102+
comparable to Salesforce" is only true once this ships.
103+
104+
- **Only the platform can fix it.** Data-plane exposure is configurable by
105+
customers through permission sets; what `/meta` serves is not. A customer
106+
building a dealer/supplier/patient portal has **no** remediation available
107+
in their own tier today except modeling discipline ("keep sensitive fields
108+
off portal-visible objects"). One platform-side fix is inherited by every
109+
deployment — the classic platform-leverage shape.
110+
111+
- **It unlocks mixed-sensitivity modeling.** Without masking, the honest
112+
guidance forces *object splitting*: the same business entity duplicated
113+
into an internal object and a portal object, with synchronization,
114+
reporting, and flow costs doubled. With masking, "one `account` object —
115+
40 fields for staff, 12 for dealers" is a supported, first-class modeling
116+
style. That is a product capability (external users on the primary data
117+
model), not merely hygiene.
118+
119+
- **Schema shape is itself business information.** Field names alone answer
120+
"what does this company record" (`salary_grade`, `layoff_flag`, a
121+
diagnosis field); picklist option sets are operational taxonomies
122+
(customer tiers, risk bands); formula expressions are pricing and scoring
123+
IP; `requiredPermissions` names hand an attacker the exact capability to
124+
escalate toward. Removing the schema removes the reconnaissance map — the
125+
cheapest layer of defense in depth.
126+
127+
Against this, the cost side is deliberately near-zero (D3: unrestricted
128+
callers get byte-identical responses; cache storage unchanged) with an
129+
escape hatch (D8) — which is the business case for shipping it in the
130+
current major rather than deferring.
131+
132+
### What is already in place
133+
134+
- `security.getReadableFields(object, ctx)` (#3547): the authoritative
135+
readable-column set for a caller — `isSystem` bypass, permission-set
136+
resolution, ADR-0066 D3 `requiredPermissions` AND-gate folded in. Returns
137+
`undefined` when the field universe is unresolvable. The export path
138+
already consumes it.
139+
- The write path already 403s forbidden fields (ObjectQL security middleware
140+
step 2.5), so this ADR is about **disclosure**, not write authorization.
141+
- `/auth/me/permissions` already serves per-caller `{readable, editable}`
142+
bits to the UI, and after objectui#2866 (steps ① ② of #3661) every
143+
client-side field gate consumes that channel; the dead declared-but-never-
144+
enforced `field.permissions` schema shape was removed per ADR-0049.
145+
- The `doc`/`book` audience gate already established "per-caller metadata
146+
reads bypass the shared cache" as an acceptable pattern — for cold reads.
147+
Object schemas are the hottest metadata read (every list/form/detail render
148+
fetches them), which is why this ADR does better than a bypass (D3).
149+
150+
---
151+
152+
## Decisions
153+
154+
### D1 — Mask by `readable` projection; remove masked fields entirely
155+
156+
Serving an object schema to caller *C* projects `fields` onto
157+
`getReadableFields(object, C)`. A field outside the set is removed **whole**
158+
— name, label, type, options, formula, `visibleWhen`, `defaultValue`,
159+
`requiredPermissions`, everything. Partial redaction (keep the name, strip
160+
the rest) still leaks existence and invites clients to render ghost columns.
161+
162+
Masking keys on `readable` **only**. A readable-but-not-editable field stays
163+
in the schema — the UI must render it (disabled where relevant), and the
164+
`editable` affordance is already served per caller by
165+
`/auth/me/permissions`. Consequently this ADR needs **no**
166+
`getEditableFields` dual: the import-template concern that motivated one
167+
(#3661 §5) was resolved client-side by objectui#2866, and the server's write
168+
gate remains the enforcement boundary.
169+
170+
Division of labor after this ADR:
171+
172+
| Layer | Mechanism | Question it answers |
173+
|---|---|---|
174+
| Data plane — read | engine middleware `maskResults` | "what values can I see" |
175+
| Data plane — write | middleware step 2.5 explicit 403 | "what fields can I write" |
176+
| Metadata plane | **this ADR** — schema projection | "what fields exist, shaped how" |
177+
| UI affordance | `/auth/me/permissions` + `checkField` | "how should the UI behave" |
178+
179+
### D2 — Enforce in the dispatch layer; the protocol stays caller-free
180+
181+
Masking runs in `@objectstack/rest` (`/meta` handlers) and
182+
`@objectstack/runtime` (`domains/meta.ts`) after the item is fetched,
183+
calling the registered `security` service. `protocol.getMetaItem` /
184+
`getMetaItems` signatures are **unchanged**: the protocol remains a
185+
caller-free, cacheable source of truth, which is precisely what makes D3's
186+
single-copy cache sound. Threading `userId`/permission sets into the
187+
protocol was rejected (see Alternatives).
188+
189+
### D3 — Mask after cache; fold a visibility fingerprint into the ETag
190+
191+
The shared metadata cache (`getMetaItemCached`) keeps storing **one full
192+
schema per (type, name, locale, environment)** — no caller dimension in the
193+
cache key. The pipeline becomes:
194+
195+
```
196+
fetch (shared cache or protocol) → mask for caller → send
197+
```
198+
199+
with the **ETag** extended by the caller's field-visibility fingerprint for
200+
that object: a stable hash of the caller's *denied* field set (empty for
201+
unrestricted callers). Properties:
202+
203+
- Unrestricted callers (the overwhelming majority in most deployments)
204+
produce an empty fingerprint → byte-identical ETags and bodies to today;
205+
zero regression.
206+
- Callers in the same permission cohort share `304`s.
207+
- A permission change alters the fingerprint → the stale `304` path
208+
self-invalidates.
209+
- Cache storage stays O(objects), not O(users × objects).
210+
211+
The masking step is *inside* the cached path, before headers are emitted —
212+
there must be no code path on which the cached full body reaches the wire
213+
without passing the mask (this ordering is also what makes D6's error tier
214+
safe). A full cache bypass for restricted callers (the `doc`/`book`
215+
pattern) is the acknowledged fallback if fingerprinting proves awkward in
216+
implementation — correct, simpler, but it forfeits `304`s on the hottest
217+
metadata read, so it is a stepping stone, not the end state.
218+
219+
### D4 — Exemptions: `isSystem` and platform admins
220+
221+
`getReadableFields` already bypasses for `isSystem`. Platform-admin callers
222+
(the same `systemPermissions`-based judgment the `app` filter uses) are
223+
likewise exempt: Studio/Setup authoring requires the full schema, and
224+
draft/preview reads are admin-gated upstream already. The exemption is a
225+
*caller* property, not a route property — an admin hitting the public route
226+
gets the full schema; a non-admin hitting any route gets the projection.
227+
228+
### D5 — Coverage: every schema-serving outlet, or the mask is decoration
229+
230+
1. `GET /meta/object/:name`**both** the `getMetaItemCached` fast path and
231+
the uncached `getMetaItem` path.
232+
2. `GET /meta/object` — the list read (`getMetaItems`); each item projected
233+
the same way.
234+
3. Runtime `/metadata` catch-all (`domains/meta.ts` object branch) — both the
235+
protocol-backed and registry-backed lookups.
236+
4. An implementation-time audit of other schema-bearing outlets (OData
237+
`$metadata`/describe-style surfaces, client SDK describe calls); anything
238+
found either routes through the same projection or is filed per Prime
239+
Directive #10.
240+
241+
### D6 — Failure posture: three tiers, never silent, never empty-200
242+
243+
| Failure | Posture | Rationale |
244+
|---|---|---|
245+
| `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. |
246+
| 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. |
247+
| 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. |
248+
249+
Reconciliation with ADR-0049: enforce-or-remove targets *silent* unenforced
250+
properties (exactly what the removed `field.permissions` shape was). Here
251+
enforcement exists; the degraded tier is an explicit, argued, **observable**
252+
decision — the `undefined` tier must emit telemetry loud enough that a
253+
deployment living in it is a visible operational condition, not a quiet
254+
default. The `doc`/`book` fail-closed precedent is *access control on
255+
content* (the payload itself is the secret, and one unrendered doc does not
256+
cascade); schema masking is *disclosure control on infrastructure* (every
257+
surface depends on it) — different class, different posture.
258+
259+
### D7 — Zero-permission-set callers resolve the fallback set
260+
261+
Today `getReadableFields` returns the full field set when no permission sets
262+
resolve (mirroring the engine middleware). For masked metadata reads, a
263+
caller resolving to zero sets goes through the same fallback resolution
264+
`/auth/me/permissions` uses (`security.fallbackPermissionSet`, default
265+
`member_default`; guest-facing deployments point it at their guest set). A
266+
public deployment's schema exposure thereby becomes a deliberate
267+
permission-set decision, not an accidental everything-default. Anonymous
268+
callers on `requireAuth` deployments remain blocked before any of this.
269+
270+
### D8 — Default on, current major, config escape hatch
271+
272+
Masking is the platform default and ships with the major release already in
273+
flight (the same train as objectui#2866's breaking removal). A server config
274+
key (`metadata.maskObjectFields: false`-style, exact name at implementation)
275+
opts a deployment out — after #2866 the UI reads all field affordances from
276+
`/auth/me/permissions`, so toggling the mask changes disclosure only, never
277+
UI correctness. ①'s client-side gates are **not** superseded: the schema
278+
mask handles existence; the client channel still drives
279+
readable-but-disabled rendering and payload stripping. The two layers are
280+
complementary by design.
281+
282+
---
283+
284+
## Alternatives considered
285+
286+
- **Document "no masking" as the explicit default** (the pre-platform-premise
287+
disposition). Defensible only while every authenticated caller is trusted;
288+
a development platform cannot promise that on customers' behalf. Rejected.
289+
- **Per-caller cache keys.** Correct, simple, O(users × objects) cache
290+
entries. Rejected for blowup; the fingerprint-ETag gets cohort-correct
291+
`304`s at O(objects) storage.
292+
- **Thread caller context into `metadata-protocol`.** Largest radius: every
293+
`getMetaItem` call site, and the protocol's single-copy cacheability —the
294+
property D3 exploits — is lost. The dispatch layer already resolves
295+
execution contexts for the `app`/`dashboard`/`doc` gates. Rejected.
296+
- **Full cache bypass for object reads** (`doc`/`book` pattern) **as the end
297+
state.** Correct and simple, but object schemas are the hottest metadata
298+
read; forfeiting shared `304`s there is a real cost. Kept as the
299+
acknowledged implementation fallback (D3), rejected as the target.
300+
- **Fail-closed on `undefined`** (D6 middle tier). Symmetric-looking but
301+
wrong class: it converts an operational hydration window into a full
302+
rendering outage with a bootstrap deadlock risk, to prevent a bounded
303+
disclosure that two same-class precedents already accept. Rejected —
304+
with the compensating requirement that the open tier be loudly observable.
305+
306+
---
307+
308+
## Consequences
309+
310+
- A caller without read access to a field no longer learns the field exists,
311+
let alone its label, options, formula, or guarding capabilities — the
312+
metadata plane joins the data plane's FLS posture.
313+
- Unrestricted callers see byte-identical responses; restricted cohorts get
314+
correct per-cohort `304` semantics.
315+
- `metadata-protocol` is untouched; implementation lands in `rest`,
316+
`runtime`, and (for D7 fallback resolution) `plugin-security`.
317+
- The `undefined` tier introduces a monitored fail-open window; deployments
318+
that cannot tolerate even that can front it with the escape hatch inverted
319+
(mask-or-503 mode) if ever demanded — deliberately out of scope now.
320+
- Follow-ups at implementation time: the D5(4) outlet audit, and wiring the
321+
fingerprint into any CDN/proxy caching guidance in the deployment docs.

0 commit comments

Comments
 (0)