Skip to content

[finding] The InMemory driver silently ignores the engine's tenant scope for objects that OMIT a tenancy block — its guard only refuses an explicit enabled: true, so memory-driver runs show cross-organization rows a SQL driver refuses #16589

Description

@os-sam

Found while verifying the isolation model of a real app (objectstack-ai/ats) on both drivers and getting two different answers to the same question. The app is not the point — the point is that the memory driver is the permissive one, and nothing says so, which makes every memory-driver measurement of an isolation property weaker than it looks.

The asymmetry

Two predicates decide "is this object tenant-scoped", and they disagree on the default case.

The engine scopes unless the object opts OUTEngine.buildDriverOptions (packages/objectql/src/engine.ts):

const hasTenant =
  execCtx?.tenantId !== undefined &&
  !isTenancyDisabled(objectSchema) &&
  !isFederated;

with its own docstring: "Carries tenantId from the active ExecutionContext so the driver can enforce per-tenant isolation (SQL driver auto-scopes reads and auto-injects the tenant column on writes) — EXCEPT for the two object postures below" — those two being tenancy.enabled: false and federated. ⭐ There is no posture check here: single is scoped exactly like the others. Posture only appears further down, widening to tenantIds under group.

The memory driver refuses only an explicit opt-INpackages/drivers/driver-memory/src/memory-tenancy-guard.ts:

export function declaresTenantScope(schema: unknown): boolean {
  return (schema as TenancyAwareSchema | null | undefined)?.tenancy?.enabled === true;
}

and it says why:

Only an explicit tenancy.enabled === true counts. An absent tenancy block is not treated as a multi-tenant signal here: platform-wide tenant scoping is driven by the deployment posture (checked separately by assertSingleTenantPosture), and every object in a single-tenant deployment omits the block.

⇒ An object that omits tenancy entirely — the common case — is tenant-scoped by the engine and invisible to the guard. assertObjectsNotTenantScoped (called at memory-driver.ts:1907) never fires, assertSingleTenantPosture passes because the posture really is single, and the driver then does nothing with the scope: tenantId, tenantIds and organization_id do not occur anywhere in the 2318-line memory-driver.ts — zero hits across the whole driver-memory/src tree. By contrast driver-sql carries tenantId: string | null in its options and documents the auto-scope at sql-driver.ts:4360.

Why the guard's assumption does not hold

The load-bearing sentence is "every object in a single-tenant deployment omits the block" — which reads "single posture ⇒ one tenant ⇒ nothing to scope."

A single-posture deployment can hold many organizations. In the run below there are 13 sys_organization rows: twelve seeded by the app plus one the platform itself mints ([auth] created Default Organization … for the platform admin (single-org)). single constrains the wall, not the number of organizations, and rows carry whichever organization_id they were written with. So the engine's scope is meaningful under single, and skipping it changes results.

Measured

objectstack-ai/ats at 8e6d7d4, @objectstack/cli 17.3.0, same build, same seed (801 records, 0 errored), same account (admin@platform.example), each run --fresh, counts taken after [Seeder] Seed loading complete:

object tenancy declared sqlite memory
ats_employer (omitted) 0 12
ats_employer_member (omitted) 0 30
ats_interview (omitted) 0 40
ats_offer (omitted) 0 14
ats_job { enabled: false } 40 40
ats_application { enabled: false } 200 200
ats_candidate { enabled: false } 80 80

The split is exactly the declaration: every object that omits the block diverges, every explicit enabled: false agrees. Both drivers log the identical posture line at boot:

[security] tenancy posture 'single' — Layer 0 is inert; the platform's own
tenant-scoped RLS policies are stripped (app-authored ones are retained and
fail closed, ADR-0105 D3)

The caller's active organization is the minted Default Organization; the rows carry the app's org_ats_* ids. sqlite's (organization_id = :tenant OR organization_id IS NULL) therefore matches nothing — correct. Memory returns everything.

The platform owner reads the same zeros on sqlite. admin_full_access carries wildcard viewAllRecords, and it does not lift this: driver-level scoping sits below the permission evaluator, so a read bypass never sees the rows to bypass. Worth stating because it is the natural first hypothesis and it is wrong.

Why this is worth fixing rather than documenting

The failure direction is toward exposure in the place where isolation is tested. A suite that asserts "tenant A cannot see tenant B's rows" passes trivially on memory — not because isolation works, but because both tenants' rows come back to everyone and the assertion was written against a single tenant's fixture. An app proving out its isolation model on the memory driver, which is the fast path everyone reaches for, is measuring nothing. In the app above every isolation measurement taken on memory has to be re-taken.

Three coherent resolutions

  1. Make the guard match the engine. declaresTenantScope becomes the engine's predicate — scoped unless tenancy.enabled === false or federated — so the driver refuses at syncSchema on any object the engine will scope. Safest and loudest; it would refuse the app above outright, which is honest but a real break for existing memory-driver users.
  2. Implement the scope. Honour tenantId / tenantIds in the memory driver's read path the way driver-sql does. Most useful — memory stays the fast test driver and becomes truthful — and the semantics are already specified by the SQL driver.
  3. Warn, at minimum. If neither of the above is wanted now, the driver should log once per object that the engine handed it a tenantId it is discarding. ⛔ Silence is the one option that should not survive: today the two drivers disagree and neither says a word.

Preference for 2, falling back to 1. Whichever lands, declaresTenantScope's docstring needs the correction that a single-posture deployment can hold many organizations.

Evidence boundary

Source read in this repository at c463d03e0 (2026-09-05), packages at 17.3.0. Runtime measurements are against the published @objectstack/cli 17.3.0 as consumed by the app, not against a build of this tree — so the code paths quoted are this tree's and the counts are the released runtime's. I did not re-run the app against a locally built framework.

Related but distinct: #16518 (accessible_org_ids reserved but never populated into the RLS bag) — a different layer, also fails silently.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions