Skip to content
Merged
14 changes: 14 additions & 0 deletions .changeset/25230-engine-declares-wall-exemption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/objectql': patch
---

fix(objectql): the engine declares the organization wall's exemption to the driver, so an exempt platform admin's predicate write is not told to declare an option it cannot set

Clause-②: no
Metadata-protocol: none

Under a walled posture an exempt platform admin's predicate update or delete reaches the driver with no `tenantId`, because the wall lifted the scope on purpose (ADR-0131 D8). The SQL driver's `[tenant-audit]` line then told the caller to declare `bypassTenantAudit: true`, an option no REST caller can set.

The engine now fills in `bypassTenantAudit: true` on the driver options when the Layer 0 verdict the security layer recorded for the operation is `none` and the same decision without the exemption would have walled it. It does not fill it in when no verdict was recorded (a by-id write, a system context, no security layer), under the `single` posture, for an insert, for an object outside the tenant machinery, or when the caller supplied its own value.

Diagnostics only: the driver's scope was already the recorded verdict's, and the option only silences the warning.
14 changes: 14 additions & 0 deletions .changeset/25230-tenant-audit-insert-carries-organization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/driver-sql': patch
---

fix(driver-sql): the `[tenant-audit]` warning no longer fires for an insert whose rows carry their organization

Clause-②: no
Metadata-protocol: none

A `create` or `bulkCreate` that reaches the SQL driver with no `options.tenantId` used to print `[tenant-audit] … without options.tenantId` even when every row already named its organization in its tenant column (`organization_id`). Such an insert is confined to the organizations its rows name, so the line had nothing to report. A walled boot (`OS_TENANCY_POSTURE=isolated`) printed one for every elevated seed insert: 32 of its 55 lines.

The line is now skipped when every inserted row carries a non-empty tenant column. It still fires for an insert where any row lacks the column, and for every `update`, `delete`, `updateMany`, `deleteMany`, `bulkDelete` and `upsert` with no `tenantId`, whatever the payload says: there the column is a value being written, not the statement's scope. The line's text and prefix are unchanged.

Diagnostics only: no write changes. Turso's local face and the SQLite WASM driver inherit the change; the in-memory and MongoDB drivers have no such warning.
24 changes: 16 additions & 8 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,29 @@ now costs nothing, and the population is **re-derivable** rather than quoted.

## What the control does, and what a call site controls

`auditMissingTenant(object, op, options)` is gated in this order:
`auditMissingTenant(object, op, options, insertRows)` is gated in this order:

1. `OS_TENANT_AUDIT=0` — off entirely;
2. `options.bypassTenantAudit` — on an `ExecutionContext.isSystem` write the
engine sets it only where the object's schema puts the write out of scope:
an object with no tenant field, or a federated one (ObjectQL's
`buildDriverOptions`). A caller may still pass it explicitly;
`buildDriverOptions`). It also sets it on a predicate write the
organization wall exempted (ADR-0131 D8: the recorded Layer 0 verdict is
`none` where the wall would otherwise apply). A caller may still pass it
explicitly;
3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`;
4. the deployment posture — the warning is only meaningful on a walled install;
5. the object having a tenant field.

Both of the guards a *caller* can move are downstream of one decision at the call
site: **whether it threads an execution context at all**. Thread an elevated one
and guard 2 silences the warning; thread a tenant-bearing one and guard 3 does;
thread none and the site is what this census counts.
5. the object having a tenant field;
6. for `create` and `bulkCreate` only, every inserted row carrying that field —
an insert is confined to the organizations its rows name. Updates, deletes
and upserts are audited whatever their payload says.

The guards a *caller* can move are downstream of one decision at the call
site: **whether it threads an execution context at all**. Thread a
tenant-bearing one and guard 3 silences the warning; thread an elevated one and
an object with a tenant column is still audited, unless the write inserts rows
that name their organization (guard 6); thread none and the site is what this
census counts.

## How the population is decided

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [ADR-0131 8c-1] The `[tenant-audit]` line skips an INSERT whose row carries
// its tenant column, and only an insert.
//
// An insert names its organization in two places: `options.tenantId`, which
// `injectTenantOnInsert` stamps onto the row, and the row's own tenant column.
// A `create` / `bulkCreate` whose every row already carries that column is
// confined to the organizations it names, so the line had nothing to report —
// yet a walled boot printed one for every elevated seed insert (they reach the
// driver with no `tenantId` and an `organization_id` on the row; a system
// insert with none is refused above the driver, ADR-0131 D9).
//
// The other half is what must NOT move: an update or a delete with no
// `tenantId` still warns whatever its payload says (the row's column is a value
// being written there, not the statement's scope), and so does an upsert, whose
// merge leg can reach another organization's row. The prefix is byte-identical,
// because `delivery-claim-tenant-audit.integration.test.ts` matches it.
//
// Real `SqlDriver` against a real in-memory SQLite; the logger is the
// assertion surface.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;

let driver: SqlDriver;
let warns: Array<{ msg: string; meta: any }>;

/**
* An organization-less row is refused by the NOT NULL organization column
* (ADR-0131 D1) — AFTER the audit has run, which is the order these controls
* read: the line is printed, then the statement fails.
*/
const refused = (write: Promise<unknown>) =>
write.then(
() => { throw new Error('expected the organization-less insert to be refused'); },
() => undefined,
);

const lines = (op?: string) =>
warns.filter((w) => w.msg.startsWith(op === undefined ? '[tenant-audit] ' : `[tenant-audit] ${op} on tenant-scoped object "account"`));

beforeEach(async () => {
process.env.OS_TENANCY_POSTURE = 'isolated';
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
warns = [];
(driver as any).logger = { warn: (msg: string, meta: any) => warns.push({ msg, meta }) };
await driver.initObjects([
{ name: 'account', fields: { organization_id: { type: 'string' }, name: { type: 'string' } } },
]);
});
afterEach(async () => {
await driver.disconnect();
if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
else process.env.OS_TENANCY_POSTURE = OLD_POSTURE;
});

describe('[ADR-0131 8c-1] an insert whose row carries its organization is not audited', () => {
it('create: the row names its organization, no tenantId — no line', async () => {
await driver.create('account', { id: 'a1', organization_id: 'org_a', name: 'A1' });
expect(lines()).toEqual([]);
});

it('bulkCreate: every row names its organization — no line, across organizations too', async () => {
await driver.bulkCreate('account', [
{ id: 'a1', organization_id: 'org_a', name: 'A1' },
{ id: 'b1', organization_id: 'org_b', name: 'B1' },
]);
expect(lines()).toEqual([]);
});

it('create: a row WITHOUT its organization still warns (control)', async () => {
await refused(driver.create('account', { id: 'a1', name: 'A1' }));
expect(lines('create')).toHaveLength(1);
expect(lines('create')[0].meta).toMatchObject({ object: 'account', op: 'create', tenantField: 'organization_id' });
});

it('create: an empty organization is no organization — it still warns', async () => {
await driver.create('account', { id: 'a1', organization_id: '', name: 'A1' });
expect(lines('create')).toHaveLength(1);
});

it('create: a null organization is no organization — it still warns', async () => {
await refused(driver.create('account', { id: 'a1', organization_id: null, name: 'A1' }));
expect(lines('create')).toHaveLength(1);
});

it('bulkCreate: ONE row without its organization keeps the line', async () => {
await refused(driver.bulkCreate('account', [
{ id: 'a1', organization_id: 'org_a', name: 'A1' },
{ id: 'a2', name: 'A2' },
]));
expect(lines('bulkCreate')).toHaveLength(1);
});

it('the prefix is byte-identical', async () => {
await refused(driver.create('account', { id: 'a1', name: 'A1' }));
expect(lines()[0].msg.startsWith(
'[tenant-audit] create on tenant-scoped object "account" without options.tenantId — ',
)).toBe(true);
});
});

describe('[ADR-0131 8c-1] what must not move: non-insert writes with no tenantId still warn', () => {
beforeEach(async () => {
await driver.create('account', { id: 'a1', organization_id: 'org_a', name: 'A1' }, { tenantId: 'org_a' });
warns = [];
});

it('update, even when its payload names an organization', async () => {
await driver.update('account', 'a1', { organization_id: 'org_a', name: 'renamed' });
expect(lines('update')).toHaveLength(1);
});

it('delete', async () => {
await driver.delete('account', 'a1');
expect(lines('delete')).toHaveLength(1);
});

it('updateMany', async () => {
await driver.updateMany('account', { where: { name: 'A1' } } as any, { organization_id: 'org_a', name: 'x' });
expect(lines('updateMany')).toHaveLength(1);
});

it('deleteMany', async () => {
await driver.deleteMany('account', { where: { name: 'A1' } } as any);
expect(lines('deleteMany')).toHaveLength(1);
});

it('upsert, even when its row names an organization — the merge leg is an update', async () => {
await driver.upsert('account', { id: 'a1', organization_id: 'org_a', name: 'merged' });
expect(lines('upsert')).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ const bootDriver = async () => {
await driver.initObjects(objects);
};

/** Writes WITHOUT a tenantId — the case the audit exists to catch. */
/**
* Writes WITHOUT a tenantId — the case the audit exists to catch. A delete:
* an insert whose row carries its organization is not audited (ADR-0131 8c-1,
* pinned in `sql-driver-tenant-audit-insert-carries-tenant.test.ts`), and one
* whose row does not is refused by the NOT NULL organization column after the
* audit has run. The row need not exist: the audit precedes the statement.
*/
const writeWithoutTenant = async (id: string) => {
await driver.create('account', { id, organization_id: 'org_a', name: id });
await driver.delete('account', id);
};

const tenantAuditWarned = () => warns.some((w) => w.msg.includes('[tenant-audit]'));
Expand Down Expand Up @@ -93,7 +99,7 @@ describe('#5262 — tenant-audit fires on a posture-only walled deployment', ()
await writeWithoutTenant('x1');

expect(tenantAuditWarned()).toBe(true);
expect(warns[0].meta).toMatchObject({ object: 'account', op: 'create', tenantField: 'organization_id' });
expect(warns[0].meta).toMatchObject({ object: 'account', op: 'delete', tenantField: 'organization_id' });
});

it('`group` is a walled posture too', async () => {
Expand Down Expand Up @@ -140,11 +146,7 @@ describe('#5262 — tenant-audit fires on a posture-only walled deployment', ()

it('bypassTenantAudit still silences a walled deployment', async () => {
setEnv({ posture: 'isolated' });
await driver.create(
'account',
{ id: 'x1', organization_id: 'org_a', name: 'X1' },
{ bypassTenantAudit: true },
);
await driver.delete('account', 'x1', { bypassTenantAudit: true });
expect(tenantAuditWarned()).toBe(false);
});

Expand Down Expand Up @@ -210,7 +212,7 @@ describe('#5262 — the verdict is taken LIVE, never frozen at first write', ()
describe('the [tenant-audit] line names both readings and the remedy for each', () => {
it('a wall-exempt cross-organization updateMany is not told to pass tenantId', async () => {
setEnv({ posture: 'isolated' });
await writeWithoutTenant('x1');
await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'x1' });
warns = [];
// The exempt admin's shape: a predicate update with no tenantId.
await driver.updateMany('account', { where: { name: 'x1' } } as any, { name: 'renamed' });
Expand Down
10 changes: 6 additions & 4 deletions packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,12 @@ describe('SqlDriver tenant scope (organization_id)', () => {
try {
await driver.initObjects(objects);

await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' });
await driver.create('account', { id: 'x2', organization_id: 'org_a', name: 'X2' });
// Second create on same object:op should NOT add another warn (throttle).
expect(warnSpy.filter(w => w.meta?.op === 'create')).toHaveLength(1);
// Deletes: an insert whose row carries its organization is not audited
// at all (ADR-0131 8c-1), so a create cannot exercise the throttle.
await driver.delete('account', 'x1');
await driver.delete('account', 'x2');
// Second delete on same object:op should NOT add another warn (throttle).
expect(warnSpy.filter(w => w.meta?.op === 'delete')).toHaveLength(1);
} finally {
if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE;
else process.env.OS_TENANCY_POSTURE = priorPosture;
Expand Down
24 changes: 22 additions & 2 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7681,7 +7681,7 @@ export class SqlDriver implements IDataDriver {
toInsert.id = nanoid(DEFAULT_ID_LENGTH);
}

this.auditMissingTenant(object, 'create', options);
this.auditMissingTenant(object, 'create', options, [toInsert]);
this.injectTenantOnInsert(object, toInsert, options);

// #5495. The autonumber counter is bootstrapped from the data table exactly
Expand Down Expand Up @@ -10141,7 +10141,6 @@ export class SqlDriver implements IDataDriver {
* contract's type now, pinned by `sql-driver-doors-declared-types.test.ts`.
*/
async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
this.auditMissingTenant(object, 'bulkCreate', options);
// Same client-side id assignment as create() (id/_id normalization,
// nanoid fallback when neither is supplied) — a row missing an id must
// not be silently inserted with a NULL primary key just because the
Expand All @@ -10158,6 +10157,7 @@ export class SqlDriver implements IDataDriver {
}
return toInsert;
});
this.auditMissingTenant(object, 'bulkCreate', options, rows);
for (const row of rows) {
if (row && typeof row === 'object') this.injectTenantOnInsert(object, row, options);
}
Expand Down Expand Up @@ -16085,11 +16085,24 @@ export class SqlDriver implements IDataDriver {
* `tenantId` (ADR-0131 D8). Telling that second write to "pass tenantId"
* prescribes the one change that would break it, so ⛔ the remedy sentence
* never addresses the write as if it were only the first kind.
*
* [ADR-0131 8c-1] An INSERT names its organization in a second place: the
* row's own tenant column, the slot {@link injectTenantOnInsert} fills from
* `tenantId`. A `create` / `bulkCreate` whose every row already carries that
* column is confined to the organizations its rows name, so there is nothing
* to report — and that is how the elevated seed and replay inserts of a
* walled boot arrive (a system insert with no organization is refused above
* this driver, ADR-0131 D9). `insertRows` is those rows, passed by the two
* insert doors only; one row without the column keeps the line. ⛔ Never for
* update, delete or upsert: there the column is a value being written, not
* the statement's scope (the line says so), and an upsert's merge leg can
* reach a row of another organization. Diagnostics only: no write changes.
*/
protected auditMissingTenant(
object: string,
op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert',
options?: DriverOptions,
insertRows?: readonly unknown[],
): void {
if (process.env.OS_TENANT_AUDIT === '0') return;
if (options?.bypassTenantAudit === true) return;
Expand All @@ -16109,6 +16122,13 @@ export class SqlDriver implements IDataDriver {
if (!this.isMultiTenantMode()) return;
const field = this.resolveTenantField(object);
if (!field) return;
// Empty is spelled as `injectTenantOnInsert` spells it: a row it would
// stamp is a row that does not carry its organization.
const carriesTenant = (row: unknown): boolean => {
const value = row && typeof row === 'object' ? (row as Record<string, unknown>)[field] : undefined;
return value !== undefined && value !== null && value !== '';
};
if (insertRows !== undefined && insertRows.length > 0 && insertRows.every(carriesTenant)) return;
const key = `${object}:${op}`;
if (this.tenantAuditWarned.has(key)) return;
this.tenantAuditWarned.add(key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,12 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => {
try {
await driver.initObjects(objects);

await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' });
await driver.create('account', { id: 'x2', organization_id: 'org_a', name: 'X2' });
// Second create on same object:op should NOT add another warn (throttle).
expect(warnSpy.filter(w => w.meta?.op === 'create')).toHaveLength(1);
// Deletes: an insert whose row carries its organization is not audited
// at all (ADR-0131 8c-1), so a create cannot exercise the throttle.
await driver.delete('account', 'x1');
await driver.delete('account', 'x2');
// Second delete on same object:op should NOT add another warn (throttle).
expect(warnSpy.filter(w => w.meta?.op === 'delete')).toHaveLength(1);
} finally {
if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE;
else process.env.OS_TENANCY_POSTURE = priorPosture;
Expand Down
Loading
Loading