Skip to content

fix(driver-sql): honor tenancy.enabled:false in driver org-scoping (ADR-0066) - #2241

Merged
xuyushun441-sys merged 2 commits into
mainfrom
fix/sql-driver-tenancy-optout
Jun 23, 2026
Merged

xuyushun441-sys merged 2 commits into
mainfrom
fix/sql-driver-tenancy-optout

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Collaborator

Problem

SqlDriver auto-detects organization_id as a tenant-isolation column and, when the caller passes DriverOptions.tenantId, injects WHERE organization_id = <tenantId> on reads/updates/deletes (and the column on inserts) — inside the query builder, not the AST. The detection had two branches: a declarative one that correctly respected tenancy.enabled !== false, and an implicit organization_id-column fallback that did not. So an object that explicitly opts out of tenancy still got org-scoped whenever it happened to carry an organization_id column.

Impact (surfaced verifying ADR-0066 Phase 1)

sys_license is platform-global (tenancy.enabled:false) but keeps an optional, often-NULL organization_id owner FK. A platform admin with an active org reads it through the data API with ctx.tenantId = <org>, which the engine threads into DriverOptions, so the driver silently filtered to organization_id = <org> — excluding the NULL-org rows. Net: the admin saw zero licenses while an unscoped/anonymous read still returned them. Because the filter lives in the driver's query builder (not the AST), it presented as an empty result with no visible RLS where (astWhere=undefined) — easy to misdiagnose as a datasource-routing bug. It is neither routing nor the ADR-0066 security layer (which correctly applies no filter / fires the RLS read-bypass); it's purely the driver's tenant-column detection.

Fix

Extract a single computeTenantField() helper (shared by initObjects and registerExternalObject, which had drifted) that returns null for any schema with tenancy.enabled === false, before the implicit column heuristic. Genuine org-scoped objects (no tenancy decl, or enabled:true) are unaffected.

TursoDriver extends SqlDriver and delegates initObjects to super, so the prod control plane (Neon/Postgres and libsql) is covered by the same fix.

Tests

Adds regression coverage to sql-driver-tenant-scope.test.ts: a tenancy-disabled object registers no tenant field, reads are unscoped regardless of tenantId, scoped/unscoped reads agree, and inserts don't auto-inject organization_id.

  • pnpm --filter @objectstack/driver-sql test207/207 pass (incl. the 4 new cases).
  • Standalone repro confirms: tenantFieldByTable['sys_license'] is now null, the admin (with tenantId) reads the NULL-org row, and a genuine org-scoped object (sys_environment) is still scoped — no regression to real tenant isolation.

🤖 Generated with Claude Code

…DR-0066)

The SqlDriver auto-detects `organization_id` as a tenant-isolation column and,
when the caller passes `DriverOptions.tenantId`, injects `WHERE organization_id
= <tenantId>` on reads/updates/deletes (and the column on inserts). The
detection had two branches: a declarative one that correctly respected
`tenancy.enabled !== false`, and an implicit `organization_id`-column fallback
that did NOT — so an object explicitly opting OUT of tenancy still got
org-scoped whenever it happened to carry an `organization_id` column.

Impact (surfaced verifying ADR-0066 Phase 1): `sys_license` is platform-global
(`tenancy.enabled:false`) but keeps an optional, often-NULL `organization_id`
owner FK. A platform admin with an active org reads it through the data API
with `ctx.tenantId = <org>`, so the engine threads that into DriverOptions and
the driver silently filtered to `organization_id = <org>` — excluding the
NULL-org rows. Net: the admin saw ZERO licenses while an unscoped/anonymous
read still returned them. The filter is injected inside the driver's query
builder, not the AST, which is why it presented as an empty result with no
visible RLS `where` (astWhere=undefined).

Fix: extract a single `computeTenantField()` helper (shared by `initObjects`
and `registerExternalObject`, which had drifted) that returns `null` for any
schema with `tenancy.enabled === false`, before the implicit column heuristic.
Genuine org-scoped objects (no tenancy decl, or `enabled:true`) are unaffected.

TursoDriver extends SqlDriver and delegates initObjects to super, so the prod
control plane (Neon/Postgres and libsql) is covered by the same fix.

Adds regression tests: tenancy-disabled object registers no tenant field, reads
are unscoped regardless of `tenantId`, scoped/unscoped reads agree, and inserts
don't auto-inject `organization_id`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 23, 2026 12:01pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/core/plugins.mdx (via @objectstack/driver-sql)
  • content/docs/concepts/implementation-status.mdx (via @objectstack/driver-sql)
  • content/docs/concepts/terminology.mdx (via @objectstack/driver-sql)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql)
  • content/docs/guides/driver-configuration.mdx (via @objectstack/driver-sql)
  • content/docs/guides/packages.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectos/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectos/lifecycle.mdx (via @objectstack/driver-sql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants