Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/tenant-index-follows-the-wall.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the tenant-scope index follows the WALL's derivation, so an object that opts out with `systemFields: false` while declaring its own `organization_id` stops running the wall predicate unindexed (#8608)

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is
added, renamed, retired or tombstoned. One internal predicate in
`packages/objectql/src/registry.ts` is rebound from the spec's injection plan to
the two clauses plugin-security already derives `tenancyDisabled` from, so the
index the platform declares now covers the same objects the wall filters. -->

Two places answered *"is this object tenant-scoped?"* and read different
declarations. The platform's tenant-scope index was gated on the spec's
**injection plan** (`resolveInjectedSystemColumns(...).tenant`), while
plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two
clauses:

```ts
tenancy.enabled === false || systemFields.tenant === false
```

`systemFields: false` — the hard object-level opt-out — is in the plan and in
neither of those clauses. So an object using that opt-out **while declaring its
own `organization_id`** had `organization_id = <org>` AND-composed onto
essentially every read, with no index behind it: the deployment's hottest
predicate, unindexed. Not a security hole — isolation still held; it was slow,
not wrong, which is why nothing surfaced it.

**Both halves were measured end to end** rather than read off the source. On the
pre-fix tree, for one such object, the registry answered `indexes: null` while
`SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for an
ordinary member.

The wall's derivation is authoritative and the index now follows it: the index
is declared when tenancy is not disabled by the wall's two clauses **and** the
object carries `organization_id` — whether the platform provisions the column or
the author declared it. `managedBy: 'better-auth'` is deliberately not re-added
as a third clause, because the wall does not read it either; the one shipped
platform object whose answer changes is `sys_member`, which is walled on
`organization_id` and whose only tenant-leading index was the composite
`['organization_id', 'user_id']`.

Unchanged, and pinned beside the fix: `systemFields.tenant: false` and
`tenancy.enabled: false` still declare no index (the wall composes no predicate
there, so an index would serve nothing), a single-tenant deployment still
declares none at all, an author's own tenant index still suppresses the
platform's, and the hard opt-out still injects no platform columns — only the
index decision was ever owed at that exit.
52 changes: 52 additions & 0 deletions packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -330,6 +330,58 @@ describe('[#8375] the write path takes back the tenant index the read added (#43
}
});

it('[#8608] round-trips a `systemFields: false` object that declares its own organization_id', async () => {
// The row #8608 moved: the hard object-level opt-out is not one of the
// wall's two `tenancyDisabled` clauses, so plugin-security composes
// `organization_id = <org>` on such an object and the platform now
// indexes the column it filters. The stamp reaching a new row means the
// STRIP owes that row too — and the write path is where the cost of
// getting it wrong is permanent rather than recomputed: an entry that
// is not taken back is baked into `sys_metadata.metadata`, its checksum
// and every history diff (#4326).
//
// It is not a separate implementation to check — the strip re-stamps
// the remainder through `provisionTenantScopeIndex` itself — but this
// row exercises the branch the OTHER cases cannot: on the hard opt-out
// the injected-column strip removes nothing (the plan's `names` is
// `{ id }`), so the author's declared column is still present when the
// re-stamp asks. That is what makes the field-map half of the predicate
// safe here; a stripped body on any other row is answered by the
// injection-plan half.
//
// Two cycles and the STORED ROW, for the reason the head of this file
// gives: one cycle read at the served document cannot separate a strip
// that is bounded from one that never fires.
const authored = {
...clone(AUTHORED),
systemFields: false,
fields: {
...clone(AUTHORED).fields,
organization_id: { type: 'lookup', reference: 'sys_organization', label: 'Org' },
},
};
const host = await seed(true, authored);
const firstStored = host.storedBody()!;
expect(firstStored.indexes).toBeUndefined();
expect(firstStored.fields.organization_id).toEqual(authored.fields.organization_id);

for (const cycle of [1, 2]) {
const item = await served(host);
expect(item.indexes, `cycle ${cycle} served`).toEqual([PLATFORM_TENANT_INDEX]);
// The hard opt-out still injects nothing: the index travels, the
// platform columns do not.
expect(Object.keys(item.fields).sort(), `cycle ${cycle} fields`)
.toEqual(['code_label', 'name', 'organization_id']);

await host.protocol.saveMetaItem({
type: 'object', name: AUTHORED.name, item,
} as never);

expect(host.storedBody()!.indexes, `cycle ${cycle} stored`).toBeUndefined();
expect(host.storedBody(), `cycle ${cycle} body`).toEqual(firstStored);
}
});

it('adds and strips NOTHING on an object that opts out of the tenant column', async () => {
// The stamp is gated on the spec's own derivation, not on the
// deployment flag alone: `systemFields.tenant: false` withholds the
Expand Down
Loading
Loading