Skip to content

The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103

Description

@baozhoutao

Routed here from cloud#1406 (target:v17, release blocker on the Route-1 flagship shape). All readings below were taken against framework 8798cd2a60649f9a54d794a97da314d41be4ab7b — the sha cloud/.objectstack-sha pins — and every file cited is byte-identical at framework main7552e0337508972585b09e65a398a0f115fa5d26 (blob-hash compared 2026-08-20), so a pin bump does not change any of it.

The symptom, measured on a real deployment

cloud#1406 measured this over HTTP on the standalone HotCRM SaaS rig (OS_TENANCY_POSTURE=isolated, three organizations, one SQLite database), as two different real principals signed in through the ordinary auth path:

 org owner platform admin rows in DB
GET /api/v1/data/sys_position 0 / 200 0 / 200 18
GET .../sys_permission_set 0 / 200 0 / 200 14
GET .../sys_sharing_rule 0 / 200 0 / 200 10
GET .../sys_user 1 / 200 3 / 200
GET .../sys_member 1 / 200 1 / 200
GET .../crm_account 10 / 200 9 / 200

Every table reading empty carries organization_id = NULL on 100% of its rows; every table reading normally is org-stamped. It is not a permission refusal — a refusal on this deployment is an unambiguous 403 (POST sys_user_position as an org owner), and the platform admin's POST sys_user_position returns 201 with the row correctly stamped.

Product consequence: on this shape no principal can list positions, permission sets or sharing rules, so nothing can be bound through Setup and hierarchy-security can never be armed by an operator however loudly an app declares it in requires.

Cause 1 — two implementations of the tenant wall predicate disagree, and the ungoverned one wins

The governed side.SqlDriver.applyTenantScope (packages/drivers/driver-sql/src/sql-driver.ts) is the single read-side tenant chokepoint, re-derived from the AST by pnpm check:tenant-chokepoint on every run. It composes:

(organization_id = :tenant OR organization_id IS NULL)

and its own comment states the ruling and this exact symptom, already paid for once:

(field = :tenantId OR field IS NULL) — a NULL tenant column marks a GLOBAL/platform row (bootstrap-seeded positions and permission sets, business units, pre-org first-boot seeds). Such a row belongs to no OTHER tenant, so the cross-tenant wall must not hide it: with strict equality every tenant admin saw ZERO RBAC rows on a fresh deployment, because every platform row is org-less (#2734).

suggested-audience-bindings.ts names the same rule as ADR-0120 D3's platform bucket and records it as measured driver behaviour, not an inference.

The ungoverned side.computeTenantLayer0Filter (packages/plugins/plugin-security/src/tenant-layer.ts) composes, for the isolated posture:

// `isolated`: the hard wall. Missing active org -> fail closed.if(!input.organizationId)return{ ...RLS_DENY_FILTER};return{organization_id: input.organizationId};

Strict equality, no NULL arm. The security middleware AND-composes it into the read AST (security-plugin.ts: computeRlsFilter -> andComposeLayers(layer0, layer1) -> extra.push(rlsFilter) -> opCtx.ast.where = { $and: [...] }), and the driver then adds its own predicate on top of that same query:

(organization_id = X OR organization_id IS NULL) AND organization_id = X == organization_id = X

The driver's NULL arm is annihilated by conjunction on every authenticated read. #2734's fix therefore holds only for paths that never traverse plugin-security's Layer 0 — which is no authenticated HTTP read on a walled deployment. The group arm has the same shape (driver whereIn(...).orWhereNull(...) vs Layer 0's bare { $in: [...] }).

This is the #8608 shape again — one question, two implementations, the governed side being overridden by the ungoverned duplicate — except here the disagreement is on the wall's own predicate rather than on the index behind it.

Cause 2 — the platform admin's zero is a SECOND, separate gate (tested separately, as cloud#1406 required)

The Layer-0 cross-tenant exemption cannot fire on these three objects for any principal, whatever their rung:

constposturePermits=meta.isPrivate||meta.tenancyDisabled||meta.isBetterAuthManaged;// security-plugin.tsconstsuperuserBypass=posturePermits ? (... hasSuperuserReadBypass ...) : false;constisPlatformAdmin=superuserBypass&&platformPosture;

sys_position, sys_permission_set and sys_sharing_rule all declare managedBy: 'config', no access.default: 'private', and no tenancy opt-out — so all three disjuncts are false, superuserBypass is short-circuited to false, and isPlatformAdmin is false regardless of the caller's actual PLATFORM_ADMIN rung. This is declared behaviour ("a platform admin stays org-scoped on public tenant business objects"), not a defect on its own.

Positive control from the same table, produced by this same branch: sys_user is managedBy: 'better-auth' => posturePermits true => the platform admin reads 3 where the org owner reads 1.

Consequence for fix design: marking these objects access.default: 'private' repairs the platform-admin arm and leaves the org owner at zero. It is not a fix.

Cause 3 — the writers, for completeness (⛔ not the place to fix it)

bootstrapDeclaredPositions, bootstrapBuiltinRoles, bootstrapDeclaredPermissions (plugin-security) and bootstrapDeclaredSharingRules (plugin-sharing) all run once inside runBootstrap on kernel:ready, insert under { isSystem: true } with no tenantId, and never write organization_id. cloud#1406 measured sys_position.min(created_at) 13:25:14.377 against sys_organization.min(created_at) 13:25:15.505 — the first insert lands about a second before the deployment's first organization exists.

⛔ Stamping an organization id onto these rows is ruled out on cloud#1406: there is no correct id to stamp, and stamping one converts a shared platform catalog into the first organization's private property.

⚠️ The fork — this needs a maintainer ruling, not a patch

The framework carries two contradictory doctrines about org-less rows at this same pin:

cloud#1406 sits where the two collide, and every available repair picks one of them. ⛔ None should be implemented before the ruling.

Option A — rebind Layer 0 to the driver's declared rule. Make the isolated arm { $or: [{ organization_id: X }, { organization_id: null }] } and the group arm the matching union, spelled to match applyTenantScope verbatim. One function; restores #2734 at the layer that defeats it; applies the standing meta-rule (#8608) that the governed side wins and the ungoverned duplicate rebinds.
⚠️ Cost: it converts every accidentally org-less row — cloud#1395's class (sys_approval_request, sys_automation_run, written by producers that DO have an organization in hand and drop it) — from invisible-to-everyone into visible-to-everyone. An availability defect becomes a confidentiality one, on the arm cloud#1239 already measured, and it contradicts the direction cloud#1395 was ruled.

Option B — narrow the platform bucket to a DECLARED row class. NULL alone stops meaning global; managed_by = 'platform' (the marking these rows already carry, A4 #2920) becomes the thing both layers read, spelled once and identically at the driver and at Layer 0. Keeps #2734's outcome for the catalog, keeps #1395's accidental NULL rows invisible, and makes "platform-level" something a writer must say rather than something it can forget.
Cost: a new scoping semantic on a public contract, and managed_by becomes security-relevant, so its write gates must be as strict as a wall.

Option C — per-organization materialization (recommended). Move the four seeders from upsert-by-name to upsert-by-(name, organization_id) and run them once per organization under a walled posture, following #8617's landed pattern in this same plugin — including its reap of pre-fix org-less rows and its single-posture carve-out (exactly one organization-less pass, no reap). The wall needs no change at all, at either layer, and no NULL row is load-bearing anywhere.
Cost: N-times the rows; the seeders must also run on organization creation, not only at kernel:ready (today runBootstrap re-runs on sys_user insert only); a backfill/reap decision for existing NULL rows; and the framework-reserved built-ins (platform_admin, org_*, everyone, guest) get a copy per organization — arguably correct, since sys_user_position assignments are already per-organization, but it is itself a semantic call.

Recommendation: Option C, with Option B as fallback if per-organization copies of the reserved built-ins are refused

  • Real business need — measured, not speculative. Setup's position / permission-set / sharing-rule administration is unreachable on the flagship shape, and a declared hierarchy-security can never be armed. This is the release blocker, and it is a real operator workflow with a real app shipping the declarations.
  • Long-term soundness for this project — C leaves exactly ONE answer to "which organization owns this row", which is the invariant the wall exists to enforce and the same answer cloud#1395 was ruled to. It retires the NULL-means-global sentinel instead of entrenching it, and it is not a new design: it is the pattern already accepted for The audience-binding-suggestion reconciler writes with a tenant-less system context, so one organization-less row serves every tenant — the second half of the #8577 install-path dead end #8617 in this very plugin, so it buys no new contract. A entrenches the sentinel at the wall and contradicts fix: align provisioning-adapter service key; clarify turso lives in cloud repo #1395; B keeps a sentinel but makes it explicit.
  • Making AI-written metadata hard to get wrong — decisive against A. Under A, omitting the organization stamp is a privilege-widening accident that no gate catches, which is precisely the failure mode an AI-authored writer produces (cloud#1395 documents two live instances). Under C an org-less row is invalid state and can be refused or reaped loudly. B is second best: the widening requires a positive managed_by: 'platform' claim.
  • Startup scope discipline — C is bounded to four seeders plus an organization-create hook plus a backfill, all inside plugin-security / plugin-sharing, with a landed precedent to copy. A is one line but buys an indefinite security liability. B is the most contract surface of the three, which is why it is the fallback rather than the recommendation.

Whichever is ruled, D-global and D-invalid should stop coexisting as unqualified general rules — that coexistence is what let this reach a release-blocking shape without either side looking wrong locally.

Files

Refs: cloud#1406 (this card's source and the HTTP measurements), cloud#1395 (write side, ruled Option A 2026-08-17), cloud#1239 (the visible-to-everyone arm), cloud#1396, cloud#1338, objectstack#2734, objectstack#8617, objectstack#8608, ADR-0095 D1, ADR-0105 D1/D2, ADR-0120 D3.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions