Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/federated-tenant-layer0-phantom-anchor.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-security": patch
---

fix(plugin-security): the tenant wall no longer scopes a federated object by a column it does not have (#7835)

A **federated** object (ADR-0015 — `external`, bound to a remote table) is
registered like any other, which means the ObjectQL registry injects the
platform's system anchors into it: `organization_id`, `owner_id`,
`owning_business_unit_id` and the audit `*_by` lookups. But the platform issues
no DDL for a federated object — `Engine.syncObjectSchema` returns early, because
the remote schema is owned externally. Those columns therefore exist in the
registered schema and **in no backing store**.

Layer 0 (the tenant wall, ADR-0095 D1) decides "is this a tenant object?" by
asking whether the object carries `organization_id`, so it was answered yes about
a phantom and AND-composed `organization_id = <active org>` onto every federated
read under a walled (`isolated` / `group`) posture. Measured on the shipped
showcase: the composed read filter for `showcase_ext_customer` was
`{ organization_id: 'org_alpha' }`, and `GET /data/showcase_ext_customer`
answered **HTTP 200 with zero rows**.

The symptom is dialect-dependent and the defect is not. On SQLite an identifier
that resolves to no column is reinterpreted as a string literal, so the
comparison is constant-false: no error, no rows, a success status. Postgres and
MySQL raise `column "organization_id" does not exist` instead. Either way the
wall isolates nothing while the federated catalog stops answering the moment a
deployment turns the organization wall on.

Layer 0 now discounts an `organization_id` that is the **platform's injected
anchor** on a federated object, so it contributes no predicate there. What is
unchanged:

- **Local objects.** The platform provisions their `organization_id`, so the
anchor is real and the wall is untouched.
- **A federated object that DECLARES a real remote `organization_id`.** The test
is provenance — identity against the shipped column definition the registry
spreads — not "is this object federated", so an author who exposes a genuine
remote tenant column keeps their wall. Any inexact match is read as "not the
platform's anchor" and leaves the wall in place: the fail direction is toward
isolation.
- **Layer 1 (business RLS).** App-authored policies still reach the compiler
untouched (ADR-0049), including on federated objects.

This is the plugin-security sibling of the engine-layer fix that withheld
`DriverOptions.tenantId` for the same objects; that one cannot reach here,
because Layer 0 is a `where` predicate composed into the query AST rather than a
driver option.

Record-ownership scoping (`__readScope` `own`/`unit` lowered to an `owner_id`
predicate) reaches federated objects through the same phantom column set and is
**not** addressed here — it is produced in `@objectstack/plugin-sharing` and is
tracked separately.
4 changes: 2 additions & 2 deletions packages/plugins/plugin-security/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@objectstack/plugin-security",
"version": "17.0.0-rc.6",
"license": "Apache-2.0",
"description": "Security Plugin for ObjectStack RBAC, RLS, and Field-Level Security Runtime",
"description": "Security Plugin for ObjectStack \u2014 RBAC, RLS, and Field-Level Security Runtime",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
Expand All@@ -20,11 +20,11 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"@objectstack/metadata-core": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
"@objectstack/service-i18n": "workspace:*",
"@types/node": "^26.1.2",
Expand Down
134 changes: 134 additions & 0 deletions packages/plugins/plugin-security/src/federated-phantom-anchors.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#7835] Provenance for the platform anchors a FEDERATED object carries but
* does not have.
*
* ## The fact this module exists for
*
* `applySystemFields` (the ObjectQL registry) injects `organization_id`,
* `owner_id`, `owning_business_unit_id` and the audit `*_by` lookups into every
* object that has not opted out — **including federated ones** (ADR-0015
* `external`). But `Engine.syncObjectSchema` returns EARLY for `external != null`
* and issues no DDL: the remote schema is owned externally. So for a federated
* object those columns exist in the registered schema and **nowhere else**.
* Measured on the shipped showcase at `origin/main` @ `b54aaab`:
*
* ```
* showcase_ext_customer (external → remote table `customers`)
* registered fields: organization_id, created_at, created_by, updated_at,
* updated_by, owner_id, owning_business_unit_id,
* name, email, region, lifetime_value
* remote columns: name, email, region, lifetime_value (+ the remote pk)
* ```
*
* Every consumer that decides something by asking "does this object carry
* column X?" is therefore answered YES about a column the query will never
* find. `computeTenantLayer0Filter` is one such consumer: it reads
* `objectHasOrgIdField` and, under a walled posture, AND-composes
* `organization_id = <active org>` onto the read. On a federated object that
* predicate cannot resolve, and the failure is **dialect-dependent**: SQLite
* reinterprets the unresolvable identifier as a string literal, so the
* comparison is constant-false — 0 rows, no error, HTTP 200 — while
* Postgres/MySQL raise `column "organization_id" does not exist`. The wall
* fails OPEN as a wall (it isolates nothing) and CLOSED as a read (the
* federated object simply stops answering).
*
* This is the plugin-security sibling of #7738 / PR #7833, which withheld
* `DriverOptions.tenantId` for `external` objects one layer down in
* `@objectstack/objectql`. That fix cannot reach here: Layer 0 is a `where`
* predicate composed into the query AST, not a driver option.
*
* ## Why PROVENANCE and not "is it federated?"
*
* A federated object MAY legitimately expose a real remote `organization_id`
* column by declaring it — and then the tenant wall is meaningful and must keep
* working. Suppressing Layer 0 for every `external` object would delete a wall
* that was doing its job.
*
* So the question is not "is this object federated?" but "is this object's
* `organization_id` the anchor the PLATFORM injected, or a column the AUTHOR
* declared?" — the same provenance discipline `platform-tenant-policies.ts`
* records for ADR-0105 finding F1 and `platform-ownership-policies.ts` for
* #5492: identity against the shipped declaration, never a pattern match on a
* public grammar. Here the shipped declaration is
* {@link TENANT_SCOPE_FIELD_DEF} itself — `applySystemFields` spreads it
* verbatim (`additions.organization_id = { ...TENANT_SCOPE_FIELD_DEF }`) and an
* authored field of the same name suppresses the injection entirely, so a
* registered def that equals the constant can only have come from the platform.
*
* Deliberately NOT an authorable "this column is phantom" flag: provenance is a
* fact about who wrote the column, and letting metadata claim it would hand
* authors a switch that turns their own tenant wall off.
*
* ## Direction of an inexact match
*
* Any mismatch — the registry adds a key, a parse stamps a default, the field
* arrives in the array shape without a recognisable body — answers `false`
* ("not the platform's anchor"), which leaves Layer 0 enforcing exactly as it
* does today. The fail direction is toward isolation, never toward exposure.
*/

// [#6562] The injected-column DEFINITION table lives in `@objectstack/metadata-core`
// (the registry that provisions the columns re-exports it from there, and the `/meta`
// read path consumes the same one). Importing the constant — rather than restating
// its shape here — is what makes the provenance test track the producer instead of a
// copy that can drift silently.
import { TENANT_SCOPE_FIELD_DEF } from '@objectstack/metadata-core';

/** The one column Layer 0 ever emits a predicate for. */
const TENANT_SCOPE_COLUMN = 'organization_id';

/** Pick a field definition out of either registered `fields` shape. */
function readFieldDef(schema: unknown, name: string): unknown {
const fields = (schema as { fields?: unknown } | null | undefined)?.fields;
if (Array.isArray(fields)) {
return fields.find((f) => (f as { name?: unknown } | null)?.name === name);
}
if (fields && typeof fields === 'object') {
return (fields as Record<string, unknown>)[name];
}
return undefined;
}

/**
* Structural identity against the shipped constant. Flat by construction —
* every value in {@link TENANT_SCOPE_FIELD_DEF} is a primitive — so a flat
* comparison is exact rather than a shortcut, and an extra or missing key is a
* mismatch (see "Direction of an inexact match" above).
*
* The array shape carries an additional `name` key that the object shape
* expresses as the map key; it is excluded so both shapes reach the same
* verdict about the same column.
*/
function equalsShippedDef(def: unknown, shipped: Readonly<Record<string, unknown>>): boolean {
if (!def || typeof def !== 'object' || Array.isArray(def)) return false;
const actual = { ...(def as Record<string, unknown>) };
delete actual.name;
const shippedKeys = Object.keys(shipped);
if (Object.keys(actual).length !== shippedKeys.length) return false;
return shippedKeys.every((k) => actual[k] === shipped[k]);
}

/**
* Is `schema` a federated (ADR-0015 `external`) object binding a remote table?
* The platform provisions no storage for one, so nothing it injects is real.
*/
export function isFederatedObject(schema: unknown): boolean {
return (schema as { external?: unknown } | null | undefined)?.external != null;
}

/**
* Does this object's `organization_id` exist only in the registry — i.e. is it
* the platform's injected anchor on an object whose storage the platform never
* provisioned?
*
* `true` ⇒ Layer 0 must treat the object as carrying NO tenant column, because
* it does not (see the module docs). `false` for every local object (the
* platform DID provision the column there) and for a federated object whose
* author declared a real remote `organization_id`.
*/
export function hasPhantomTenantAnchor(schema: unknown): boolean {
if (!isFederatedObject(schema)) return false;
return equalsShippedDef(readFieldDef(schema, TENANT_SCOPE_COLUMN), TENANT_SCOPE_FIELD_DEF);
}
Loading
Loading