|
| 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