|
| 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. |
0 commit comments