Skip to content

tenant-scoped objects get GLOBAL unique indexes: 409-vs-201 enumerates other tenants' values, and a user's preferences silently stop persisting in their second org #8323

Description

@baozhoutao

On a deployment running OS_TENANCY_POSTURE=isolated, reads are tenant-scoped but field-level unique: true materializes a global unique index. The two disagree, and that gap is both an information leak and a live data-plane failure.

1. The oracle: 409 vs 201 enumerates values you cannot see

Same user, two organizations they belong to (甲 / 乙), sys_capability:

as 甲: POST /data/sys_capability {name:"probe_cap_xtenant"} → 201 (stamped organization_id = 甲)
as 乙: GET /data/sys_capability?filter=["name","=","probe_cap_xtenant"] → total 0 ← invisible
as 乙: POST /data/sys_capability {name:"probe_cap_xtenant"} → 409 UNIQUE_VIOLATION ← but it collides
as 乙: POST /data/sys_capability {name:"probe_cap_only_in_b"} → 201 ← control

A tenant can therefore decide, for any value, whether some other tenant already holds it — while being unable to read the row. It also leaks platform-reserved rows: POST {name:"manage_metadata"} returns 409 in an org whose visible capability count is 0.

2. Not one object — the same on ordinary tenant data

sys_user_preference, unique on (user_id, key), same user in both orgs:

as 乙: POST {user_id:<self>, key:"probe.uniq.xtenant"} → 201
as 甲: GET ?filter=["key","=","probe.uniq.xtenant"] → total 0
as 甲: POST {user_id:<self>, key:"probe.uniq.xtenant"} → 409 UNIQUE_VIOLATION

Any tenant-scoped object with a unique field inherits this. For customer-authored objects the enumerable values are the ones people actually mark unique — national ID, phone, email, customer code. Under isolated, the posture sold as "legal-entity / sovereignty isolation — the hard wall" (packages/spec/src/security/tenancy-posture.ts:15), that is exactly the wall this crosses.

3. It is already breaking the platform's own feature

The console's own preference rows collide across organizations:

zhangsan in 甲: keys = ["ui.recent"]
zhangsan in 乙: keys = ["probe.uniq.xtenant"] ← no ui.recent
zhangsan in 乙: POST {key:"ui.recent"} → 409 UNIQUE_VIOLATION

So a user who belongs to two organizations can never persist ui.recent (or any preference key they already used elsewhere) in the second one. It fails silently, because the client swallows it by design — packages/data-objectstack/src/userState.ts:

save() resolves without throwing, so UI mutations never surface a toast.
[…] Swallow — provider falls back to localStorage as source of truth.

Net effect: "recent items" and similar preferences quietly never persist server-side in a user's second workspace, with no error anywhere.

Cause

uniqueIndexesFromFields (packages/drivers/driver-sql/src/schema-drift.ts:921) only puts the tenant column in the index when the field opted in:

constscoped=isOrganizationScopedUnique(field.unique)&&tenantField!=null&&tenantField!==name;constcolumns=scoped ? [tenantField,name] : [name];

unique: true (and 'global') → single-column global index. Only unique: 'organization' (ADR-0120 D1) → (organization_id, field). The machinery for the correct shape exists and is used for tenanted autonumber — sql-driver.ts:4318 documents that index as (COALESCE(organization_id,'__global__'), field) — but ordinary unique fields do not get it, and the platform's own objects (sys_capability, sys_user_preference) are declared with the plain form.

The decision

  1. Default should follow the posture. Under group/isolated, a bare unique: true on a tenant-scoped object arguably means "unique within the tenant". Making the default posture-aware fixes every existing object at once, at the cost of an index migration.
  2. At minimum, fix the platform's own objectssys_user_preference (user_id, key) and sys_capability (name) should be unique: 'organization'. This alone clears §3 and most of §2's blast radius on system tables.
  3. Whatever is decided, the 409 must stop being an oracle for values in other tenants — a collision against an invisible row should read as an ordinary refusal, not a distinguishable "taken" signal.

I did not assume which of these you want, hence one issue rather than a patch.

Environment

objectos-ee-deploy (Caddy → app → postgres:16, NODE_ENV=production, OS_TENANCY_POSTURE=isolated) on http://localhost:8080, observed 2026-08-13, via the REST API with real sessions of a user who is a member of both organizations. Probe rows created during this test were deleted afterwards.

Related: #8093 (the same sys_user_preference write path, surfacing a spurious warning toast).

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions