diff --git a/.changeset/federated-tenant-layer0-phantom-anchor.md b/.changeset/federated-tenant-layer0-phantom-anchor.md new file mode 100644 index 0000000000..9508479eed --- /dev/null +++ b/.changeset/federated-tenant-layer0-phantom-anchor.md @@ -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 = ` 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. diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index aeabeef5d5..6a2b97d039 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -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": { @@ -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", diff --git a/packages/plugins/plugin-security/src/federated-phantom-anchors.ts b/packages/plugins/plugin-security/src/federated-phantom-anchors.ts new file mode 100644 index 0000000000..0085e1e362 --- /dev/null +++ b/packages/plugins/plugin-security/src/federated-phantom-anchors.ts @@ -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 = ` 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)[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>): boolean { + if (!def || typeof def !== 'object' || Array.isArray(def)) return false; + const actual = { ...(def as Record) }; + 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); +} diff --git a/packages/plugins/plugin-security/src/federated-tenant-layer0.test.ts b/packages/plugins/plugin-security/src/federated-tenant-layer0.test.ts new file mode 100644 index 0000000000..9c88d2815a --- /dev/null +++ b/packages/plugins/plugin-security/src/federated-tenant-layer0.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7835] Layer 0 must not wall a FEDERATED object with a column it does not have. + * + * ## The defect these pins hold down + * + * The ObjectQL registry injects `organization_id` into every object that has not + * opted out, federated ones included — but issues no DDL for a federated object, + * because its remote schema is owned externally. `computeLayeredRlsFilter` then + * reads the registered field set, answers `objectHasOrgIdField: true`, and Layer 0 + * AND-composes `organization_id = ` onto a read whose backing table has + * no such column. + * + * The symptom is dialect-dependent and the defect is not: SQLite reinterprets the + * unresolvable identifier as a string literal, so the predicate is constant-false — + * **0 rows, no error, HTTP 200**; Postgres/MySQL raise `column "organization_id" + * does not exist`. Both are the same wall isolating nothing. + * + * These cases are **dialect-free by construction**: they assert the composed + * `FilterCondition`, which is what plugin-security produces before any driver sees + * it. The end-to-end half — a real boot, the real registry's field set, a real + * federated object, on SQLite — is + * `packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts`. + * + * ## Why the fixtures below are not circular + * + * The injected-anchor fixture spreads {@link TENANT_SCOPE_FIELD_DEF} exactly as + * `applySystemFields` does, and the provenance test compares against the same + * constant — so this file proves the DECISION, not that the registry still spreads + * it verbatim. That second fact has an independent witness: the dogfood pin reads + * what the real registry produced. If the registry ever stops spreading the + * constant, that pin goes red here while these stay green, which is the correct + * division of labour rather than a gap. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { TENANT_SCOPE_FIELD_DEF } from '@objectstack/metadata-core'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +/** A member with plain read/write CRUD and NO row-level policies, so the only + * thing `getReadFilter` can return is Layer 0 — the layer under test. */ +const PLAIN_MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +/** The caller: an ordinary member of `org-1`, no superuser bit, no positions. */ +const MEMBER_CTX = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; + +/** + * Boot a SecurityPlugin over a fake ObjectQL that serves exactly `schema`. + * `orgScoping` mounts the sentinel service the plugin probes for the `isolated` + * posture (the same lever `security-plugin.test.ts` uses); `tenancy` overrides it + * with an explicit posture where a case needs `group`. + */ +async function bootWithSchema(schema: Record, opts: { tenancy?: { posture: string } } = {}) { + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { registerMiddleware: vi.fn(), getSchema: () => schema, findOne: vi.fn(async () => null) }, + metadata: { get: async () => schema, list: async () => [PLAIN_MEMBER] }, + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + }; + if (opts.tenancy) services['tenancy'] = opts.tenancy; + const ctx: Record = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.init(ctx as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.start(ctx as any); + return plugin; +} + +/** The remote table's real columns — the only ones a federated query can name. */ +const REMOTE_COLUMNS = { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, +}; + +/** What the registry hands plugin-security for a federated object today: the + * remote columns PLUS the platform anchors it provisions no storage for. */ +const federatedSchema = (extra: Record = {}) => ({ + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: { + // `applySystemFields`: `additions.organization_id = { ...TENANT_SCOPE_FIELD_DEF }` + organization_id: { ...TENANT_SCOPE_FIELD_DEF }, + ...REMOTE_COLUMNS, + }, + ...extra, +}); + +describe('[#7835] Layer 0 vs federated (external) objects', () => { + it('federated object with the INJECTED anchor: no organization_id predicate under `isolated`', async () => { + const plugin = await bootWithSchema(federatedSchema()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('ext_customer', MEMBER_CTX); + // No Layer 1 policies and no Layer 0 contribution → nothing to AND at all. + expect(filter).toBeUndefined(); + }); + + it('federated object with the INJECTED anchor: no organization_id predicate under `group` either', async () => { + // `group` is the other walled posture (ADR-0105 D2). Its predicate widens to a + // membership union; the column it names is just as absent, so the same + // suppression must hold — otherwise the fix would cover one posture and leave + // the other emitting `organization_id IN (…)` against a table without it. + const plugin = await bootWithSchema(federatedSchema(), { tenancy: { posture: 'group' } }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('ext_customer', { + ...MEMBER_CTX, + accessible_org_ids: ['org-1', 'org-2'], + }); + expect(filter).toBeUndefined(); + }); + + it('federated object whose AUTHOR DECLARED a real remote `organization_id`: the wall STAYS', async () => { + // The provenance half. A remote table may genuinely carry a tenant column, and + // then Layer 0 is doing real work — suppressing it for every `external` object + // would delete a working wall. This is the case that goes red if + // `hasPhantomTenantAnchor` is ever simplified to `external != null`. + const plugin = await bootWithSchema({ + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: { + organization_id: { type: 'text', label: 'Org (real remote column)' }, + ...REMOTE_COLUMNS, + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('ext_customer', MEMBER_CTX); + expect(filter).toEqual({ organization_id: 'org-1' }); + }); + + it('LOCAL object carrying the same injected anchor: the wall STAYS', async () => { + // The platform DID provision this column, so the anchor is real. Nothing about + // the fix may reach a non-federated object. + const plugin = await bootWithSchema({ + name: 'task', + fields: { organization_id: { ...TENANT_SCOPE_FIELD_DEF }, ...REMOTE_COLUMNS }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('task', MEMBER_CTX); + expect(filter).toEqual({ organization_id: 'org-1' }); + }); + + it('federated object with NO active org: still fails closed, not open', async () => { + // Before the fix a federated read with no active organization got + // RLS_DENY_FILTER (0 rows). After it, Layer 0 contributes nothing — which must + // not be read as "the fix opens a hole": there is no tenant column to compare, + // so there was never isolation here to lose. Object-level CRUD is still the + // gate, and a caller without read permission is refused before this point. + const plugin = await bootWithSchema(federatedSchema()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('ext_customer', { ...MEMBER_CTX, tenantId: undefined }); + expect(filter).toBeUndefined(); + }); + + it('an APP-AUTHORED wildcard tenant policy still reaches the compiler on a federated object', async () => { + // ADR-0049 / ADR-0105 finding F1: a declared security policy must never be + // silently dropped. The fix touches Layer 0 only — Layer 1 keeps compiling + // authored predicates exactly as before, so an author who scopes a federated + // object by a real remote column keeps their scoping, and one who names a + // column that isn't there still gets the field-existence net's fail-closed + // answer instead of silence. + const authored: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { name: 'app_region_scope', object: '*', operation: 'all', using: 'region == current_user.organization_id' }, + ], + } as unknown as PermissionSet; + const schema = federatedSchema(); + (schema.fields as Record).region = { type: 'text', label: 'Region' }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { registerMiddleware: vi.fn(), getSchema: () => schema, findOne: vi.fn(async () => null) }, + metadata: { get: async () => schema, list: async () => [authored] }, + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + }; + const ctx: Record = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.init(ctx as any); await plugin.start(ctx as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter = await (plugin as any).getReadFilter('ext_customer', MEMBER_CTX); + // Layer 0 gone, Layer 1's authored predicate intact — no `$and`, no org column. + expect(filter).toEqual({ region: 'org-1' }); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1b9bb871a6..eb82d6be77 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -50,6 +50,7 @@ import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js'; +import { hasPhantomTenantAnchor } from './federated-phantom-anchors.js'; import { normalizeTenancyPosture, postureEnforcesWall, @@ -163,6 +164,19 @@ interface ObjectSecurityMeta { isPrivate: boolean; tenancyDisabled: boolean; isBetterAuthManaged: boolean; + /** + * [#7835] The object is FEDERATED (ADR-0015 `external`) and its + * `organization_id` is the anchor the REGISTRY injected, not a column the + * author declared — so the column exists in the registered schema and in no + * backing store, because `syncObjectSchema` issues no DDL for a federated + * object. Layer 0 reads this to answer "is this a tenant object?" truthfully; + * see `federated-phantom-anchors.ts` for the provenance test and for why the + * question is provenance rather than `external != null`. + * + * `false` on every local object and on a federated object that declares a + * real remote `organization_id` — both keep the tenant wall exactly as it is. + */ + tenantAnchorIsPhantom: boolean; requiredPermissions: NormalizedRequiredPermissions; fieldRequiredPermissions: Record; /** @@ -4027,7 +4041,25 @@ export class SecurityPlugin implements Plugin { // [ADR-0105 D2] The `group` wall's predicate. Resolved by // `resolveAuthzContext` and carried on the context — never re-derived here. accessibleOrgIds: context?.accessible_org_ids, - objectHasOrgIdField: objectFields ? objectFields.has('organization_id') : undefined, + // [#7835] A FEDERATED object's `organization_id` may be the registry's + // injected anchor rather than a remote column — present in the field set, + // absent from the store the query actually runs against (the platform + // issues no DDL for `external` objects). Answering "yes, tenant object" + // there AND-composes `organization_id = ` onto a federated read, + // where it isolates nothing and — on SQLite — degrades to a constant-false + // string comparison: 0 rows, no error, HTTP 200. This is the + // plugin-security sibling of #7738 / PR #7833, which withheld + // `DriverOptions.tenantId` for the same objects one layer down; that fix + // cannot reach a `where` predicate composed into the AST. + // + // Only the PLATFORM's anchor is discounted (provenance, per + // `federated-phantom-anchors.ts`): a federated object that DECLARES a real + // remote `organization_id` keeps its wall, and every local object is + // untouched. `undefined` (schema unresolvable) still means "assume tenant + // object" — the fail-toward-isolation default is unchanged. + objectHasOrgIdField: objectFields + ? objectFields.has('organization_id') && !meta.tenantAnchorIsPhantom + : undefined, tenancyDisabled, posturePermitsCrossTenant: posturePermits, isPlatformAdmin, @@ -4498,6 +4530,9 @@ export class SecurityPlugin implements Plugin { // exemption; their `_self` carve-outs are their Layer 1 scoping, and Layer // 0 stays inert on a column-less table), so it can never leak to non-admins. isBetterAuthManaged: (obj as any)?.managedBy === 'better-auth', + // [#7835] Federated object carrying the registry's INJECTED + // `organization_id` — a column the platform provisions no storage for. + tenantAnchorIsPhantom: hasPhantomTenantAnchor(obj), requiredPermissions: normalizeRequiredPermissions((obj as any)?.requiredPermissions), fieldRequiredPermissions, unresolved: !obj, diff --git a/packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts b/packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts new file mode 100644 index 0000000000..4e096d5335 --- /dev/null +++ b/packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7835] plugin-security must not wall a FEDERATED object with a column the + * remote table does not have — measured against the REAL registry, on a REAL + * boot, with the SHIPPED showcase federated objects. + * + * ## Why this file exists next to the plugin-security unit pins + * + * `packages/plugins/plugin-security/src/federated-tenant-layer0.test.ts` proves + * the DECISION: given a field set that carries the platform's injected + * `organization_id` anchor on an `external` object, Layer 0 must contribute + * nothing. Its fixtures build that field set by hand, so it cannot also prove + * that the real registry still produces one — expectation and reality would + * share a source, and the pin could not fail. + * + * This file supplies the independent witness. Nothing here constructs a schema: + * the showcase's `showcase_ext_customer` is registered by `applySystemFields` + * exactly as a deployed app's would be, and the assertions read what that + * produced. If the registry ever stops injecting phantom anchors into federated + * objects — the root fix this card deliberately did not take (it lives in + * `@objectstack/objectql`, outside this lane) — the premise case below goes red + * and says so, which is the correct way to learn that the ground moved. + * + * ## Dialect + * + * The showcase's external datasource is **SQLite**, and that is exactly where + * the pre-fix defect is invisible: an unresolvable identifier degrades to a + * string literal, so `organization_id = 'org_…'` is constant-false — 0 rows, no + * error, HTTP 200. Postgres/MySQL raise instead. So the HTTP case here proves + * the SQLite-shaped symptom only; the filter-shape cases above it are what + * generalise, because plugin-security composes that `FilterCondition` before any + * driver — and any dialect — sees it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import type { IObjectQLEngine, ISecurityService } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; + +/** The federated object the showcase ships, bound to remote table `customers`. */ +const FEDERATED = 'showcase_ext_customer'; +/** A LOCAL showcase object — the control: its `organization_id` IS provisioned. */ +const LOCAL = 'showcase_project'; + +function fieldNames(schema: unknown): string[] { + const fields = (schema as { fields?: unknown } | null)?.fields; + if (Array.isArray(fields)) return fields.map((f) => String((f as { name?: unknown }).name)); + return Object.keys((fields ?? {}) as Record); +} + +function listOf(body: unknown): unknown[] { + if (Array.isArray(body)) return body; + const b = body as { records?: unknown[]; data?: unknown[] }; + return b?.records ?? b?.data ?? []; +} + +/** Every `organization_id` key anywhere in a composed FilterCondition tree. */ +function mentionsOrgColumn(filter: unknown): boolean { + if (Array.isArray(filter)) return filter.some(mentionsOrgColumn); + if (!filter || typeof filter !== 'object') return false; + return Object.entries(filter as Record).some( + ([k, v]) => k === 'organization_id' || mentionsOrgColumn(v), + ); +} + +describe('[#7835] federated objects and the plugin-security tenant wall', () => { + let stack: VerifyStack; + let ql: IObjectQLEngine; + let security: ISecurityService; + + beforeAll(async () => { + // Stand up the "remote" database (the showcase's fixture provisioner), then + // boot with a real, NON-degraded `isolated` posture — the only posture in + // which Layer 0 emits anything at all. `posture-only` performs no isolation + // of its own, which is precisely right here: what is under test is the + // PREDICATE plugin-security composes, not whether an org wall holds. + await onEnable({ logger: { info() {}, warn() {} } } as never); + stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + // `ObjectKernel.getService` is already generic over the slot's contract, so + // neither the kernel handle nor either result needs erasing: `objectql` is + // `IObjectQLEngine` and `security` is `ISecurityService`, both declared in + // `packages/spec/src/contracts/core-service-contracts.ts`. + ql = stack.kernel.getService('objectql'); + security = stack.kernel.getService('security'); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('PREMISE: the registry injects a phantom `organization_id` into the federated object', () => { + // Not an aspiration — the state of the tree this fix was written against. + // `Engine.syncObjectSchema` returns early for `external != null` and issues + // no DDL, so this column exists in the registry and in no table. + // `IObjectQLEngine.getSchema` declares `unknown` on purpose — the engine's + // own return type (`ServiceObject | undefined`, `engine.ts:4766`) is a spec + // type, but the contract keeps its edges loose so `spec` never depends on + // the engine package, and tells consumers to narrow at the call site. This + // is that narrowing, to the type the implementation already declares. + const schema = ql.getSchema(FEDERATED) as ServiceObject | undefined; + expect(schema?.external, `${FEDERATED} must be a federated object`).toBeTruthy(); + expect(fieldNames(schema)).toContain('organization_id'); + // The remote table's real columns, for contrast. + expect(fieldNames(schema)).toEqual(expect.arrayContaining(['name', 'email', 'region'])); + }); + + it('a walled posture composes NO organization_id predicate for the federated object', async () => { + const ctx = { userId: 'usr_dogfood_member', tenantId: 'org_alpha', positions: [] as string[] }; + const filter = await security.getReadFilter(FEDERATED, ctx); + expect( + mentionsOrgColumn(filter), + `read filter for the federated ${FEDERATED} must not name organization_id, got ${JSON.stringify(filter)}`, + ).toBe(false); + }); + + it('CONTROL: the same caller on a LOCAL object still gets the tenant wall', async () => { + // Without this, the case above would pass just as well if the wall had been + // switched off everywhere — which is the failure mode the fix must not have. + const ctx = { userId: 'usr_dogfood_member', tenantId: 'org_alpha', positions: [] as string[] }; + const filter = await security.getReadFilter(LOCAL, ctx); + expect( + mentionsOrgColumn(filter), + `read filter for the local ${LOCAL} must still name organization_id, got ${JSON.stringify(filter)}`, + ).toBe(true); + }); + + it('end-to-end (SQLite): the federated object still answers with rows under a walled posture', async () => { + // The user-visible half. Before the fix this returned HTTP 200 with ZERO + // rows: Layer 0 treated the object as tenant-scoped, and with no active + // organization on the context that resolves to the deny sentinel — a + // federated catalog that silently stops existing the moment a deployment + // turns the organization wall on. + const admin = await stack.signIn(); + const res = await stack.apiAs(admin, 'GET', `/data/${FEDERATED}`); + expect(res.status).toBe(200); + const rows = listOf(await res.json()); + expect(rows.length, 'federated rows must survive a walled posture').toBeGreaterThanOrEqual(3); + }, 120_000); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 418df8f44f..774959f5a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1703,6 +1703,9 @@ importers: '@objectstack/formula': specifier: workspace:* version: link:../../formula + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../../metadata-core '@objectstack/platform-objects': specifier: workspace:* version: link:../../platform-objects @@ -1710,9 +1713,6 @@ importers: specifier: workspace:* version: link:../../spec devDependencies: - '@objectstack/metadata-core': - specifier: workspace:* - version: link:../../metadata-core '@objectstack/plugin-sharing': specifier: workspace:* version: link:../plugin-sharing