|
| 1 | +--- |
| 2 | +"@objectstack/plugin-security": patch |
| 3 | +--- |
| 4 | + |
| 5 | +fix(plugin-security): the tenant wall no longer scopes a federated object by a column it does not have (#7835) |
| 6 | + |
| 7 | +A **federated** object (ADR-0015 — `external`, bound to a remote table) is |
| 8 | +registered like any other, which means the ObjectQL registry injects the |
| 9 | +platform's system anchors into it: `organization_id`, `owner_id`, |
| 10 | +`owning_business_unit_id` and the audit `*_by` lookups. But the platform issues |
| 11 | +no DDL for a federated object — `Engine.syncObjectSchema` returns early, because |
| 12 | +the remote schema is owned externally. Those columns therefore exist in the |
| 13 | +registered schema and **in no backing store**. |
| 14 | + |
| 15 | +Layer 0 (the tenant wall, ADR-0095 D1) decides "is this a tenant object?" by |
| 16 | +asking whether the object carries `organization_id`, so it was answered yes about |
| 17 | +a phantom and AND-composed `organization_id = <active org>` onto every federated |
| 18 | +read under a walled (`isolated` / `group`) posture. Measured on the shipped |
| 19 | +showcase: the composed read filter for `showcase_ext_customer` was |
| 20 | +`{ organization_id: 'org_alpha' }`, and `GET /data/showcase_ext_customer` |
| 21 | +answered **HTTP 200 with zero rows**. |
| 22 | + |
| 23 | +The symptom is dialect-dependent and the defect is not. On SQLite an identifier |
| 24 | +that resolves to no column is reinterpreted as a string literal, so the |
| 25 | +comparison is constant-false: no error, no rows, a success status. Postgres and |
| 26 | +MySQL raise `column "organization_id" does not exist` instead. Either way the |
| 27 | +wall isolates nothing while the federated catalog stops answering the moment a |
| 28 | +deployment turns the organization wall on. |
| 29 | + |
| 30 | +Layer 0 now discounts an `organization_id` that is the **platform's injected |
| 31 | +anchor** on a federated object, so it contributes no predicate there. What is |
| 32 | +unchanged: |
| 33 | + |
| 34 | +- **Local objects.** The platform provisions their `organization_id`, so the |
| 35 | + anchor is real and the wall is untouched. |
| 36 | +- **A federated object that DECLARES a real remote `organization_id`.** The test |
| 37 | + is provenance — identity against the shipped column definition the registry |
| 38 | + spreads — not "is this object federated", so an author who exposes a genuine |
| 39 | + remote tenant column keeps their wall. Any inexact match is read as "not the |
| 40 | + platform's anchor" and leaves the wall in place: the fail direction is toward |
| 41 | + isolation. |
| 42 | +- **Layer 1 (business RLS).** App-authored policies still reach the compiler |
| 43 | + untouched (ADR-0049), including on federated objects. |
| 44 | + |
| 45 | +This is the plugin-security sibling of the engine-layer fix that withheld |
| 46 | +`DriverOptions.tenantId` for the same objects; that one cannot reach here, |
| 47 | +because Layer 0 is a `where` predicate composed into the query AST rather than a |
| 48 | +driver option. |
| 49 | + |
| 50 | +Record-ownership scoping (`__readScope` `own`/`unit` lowered to an `owner_id` |
| 51 | +predicate) reaches federated objects through the same phantom column set and is |
| 52 | +**not** addressed here — it is produced in `@objectstack/plugin-sharing` and is |
| 53 | +tracked separately. |
0 commit comments