Skip to content

Commit b62f959

Browse files
fix(objectql,driver-sql): the tenant-audit warning skips an insert whose rows carry their organization, and the engine declares the D8 wall-exemption to the driver (#25274)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg7XX31HkENJWqk9mfoyv6Mw)_ Fixes #25230 Part of #15212 Clause-②: no Metadata-protocol: none ## What changes Diagnostics only. No write changes: no statement, scope, row or refusal moves. The `[tenant-audit]` prefix and text are byte-identical. 1. **driver-sql: an insert whose rows carry their organization is not audited.** `SqlDriver.auditMissingTenant` takes the inserted rows from the two insert doors (`create`, `bulkCreate`). When every row carries a non-empty tenant column (empty is spelled as `injectTenantOnInsert` spells it), the line is skipped: an insert is confined to the organizations its rows name. One row without the column keeps the line. `update`, `delete`, `updateMany`, `deleteMany`, `bulkDelete` and `upsert` with no `tenantId` still warn, whatever the payload says. For an update or delete the column is a value being written, not the scope, and an upsert's merge leg can reach another organization's row. 2. **objectql: the engine declares the D8 wall-exemption to the driver.** `buildDriverOptions` fills in `bypassTenantAudit: true` when the Layer 0 verdict that plugin-security recorded on the operation is `none` and the engine's own fallback asking of the same ladder (`computeTenantLayer0Verdict` with the exemption withheld, the scope an unrecorded operation already gets) would have walled it. This is plugin-security's own `crossedByPlatformAdmin` test, read from the recorded value. It is the existing option, so there is no `DriverOptions` widening and no new `OperationContext` member, and the driver reads no posture it did not read before. It is not filled in when no verdict was recorded (a by-id write, a system context, no security layer), under `single`, for an insert (write target), for an object outside the tenant machinery (tenancy-disabled, federated, no tenant field), for a verdict that does not parse, or when the caller supplied its own value. Other drivers: Turso's local face and the SQLite WASM driver inherit `SqlDriver.create` / `bulkCreate`, so they get (1). Turso's remote face never called the audit. `driver-memory` and `driver-mongodb` have no tenant-audit warning. ## Measured: a walled `os serve` boot, before and after Setup (as on #25210's R4): `pnpm dev -- --fresh` on the showcase with `OS_TENANCY_POSTURE=isolated`, a 32-byte `OS_SECRET_KEY`, `OS_AUTH_MEMBERSHIP_POLICY=invite-only` and `OS_PLATFORM_OWNER_EMAIL`. `@objectstack/organizations` was declared in the showcase's `package.json` and linked for the run only; both were reverted and are not in this diff. Health 200 both times; 20 s settle after health; seeded admin sign-up included. | `[tenant-audit]` lines | base `0f1eeeeeb` | this branch | |---|---|---| | `create` | 16 (2 `showcase_*`, 14 `sys_*`) | **0** | | `bulkCreate` | 16 (all `showcase_*`) | **0** | | `update` | 5 | 5 | | `updateMany` | 18 | 18 | | **total** | **55** | **23** | Every insert line is gone; every update and updateMany line stays, as the card requires. ## Tests - `sql-driver-tenant-audit-insert-carries-tenant.test.ts` (new, real `SqlDriver` on in-memory SQLite, 11 tests). Pins: `create` and `bulkCreate` whose rows carry the organization print nothing. Controls: a row without it, an empty organization, one row of a batch without it, and the byte-identical prefix all still warn; `update` (payload naming an organization), `delete`, `updateMany`, `deleteMany` and `upsert` (row naming an organization) all still warn. - `engine-tenant-audit-wall-exemption.test.ts` (new, the `DriverOptions` seam and harness of `engine-driver-read-scope.test.ts`, 10 tests). Declared: recorded `none` under `isolated` on `updateMany` and `deleteMany`, and under `group`. Not declared: a walled verdict, no recorded verdict, `single`, an insert, a tenancy-disabled object, an unparseable verdict, a caller's own `bypassTenantAudit: false`. - Two existing driver pins used a `create` whose row carried `organization_id` as the "write without tenant" shape: `sql-driver-tenant-audit-posture.test.ts` now uses a `delete` for that helper (and its `bypassTenantAudit` case), and `sql-driver-tenant-scope.test.ts`'s throttle pin now uses two deletes. Their assertions are unchanged. - Package suites: `@objectstack/objectql` test 464 files, 8686 passed; typecheck exit 0. `@objectstack/driver-sql` typecheck exit 0; test 3773 passed, 1 failed on the first run (the throttle pin above, which encoded the old insert behaviour), and the fixed file passes 61/61 with its two sibling audit files. Cross-package readers of the prefix: `service-messaging` `delivery-claim-tenant-audit` and `delivery-update-tenant-audit` integration tests 13/13; `service-storage` `tenant-audit-update-delete-half-repairs` 30/30. ## Ablation Both run through `scripts/ablation-replace.mjs` with the fix committed, and each restore was verified (blob equals `HEAD`, `git diff HEAD` empty). The tests import source, so no build leg is needed. - Deleted the row-carries-tenant skip in `auditMissingTenant`: the 2 insert pins go red and the 9 controls stay green (2 failed, 9 passed). - Removed the engine's `opts.bypassTenantAudit = true` for the exemption: the 3 "declared" pins go red and the 7 "not declared" stay green (3 failed, 7 passed). My first attempt was refused by the tool before running anything, because the replacement text was a substring of the anchor. The second attempt used a distinct replacement. ## Gates Run on the final head `d323330402`. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 74 commands over 8 changed paths. I ran each one and captured its exit code before any pipe: **74/74 exit 0**. `--ran` reports "74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED". Two shallow-clone prerequisites were met first, by fetching `621a48760` (`check-plugin-teardown-shape --self-test`) and running `--shallow-since=2026-07-17` (`check-engine-split-ratio --days 90`). On the earlier head `b57e83d5`, the same 72 were green and those two exited 3 and 2 on the shallow clone. CI runs everything else; this PR does not touch a governed surface. ## Acceptance notes - The 23 lines left at a walled boot are seed-time `update` and `updateMany` on `showcase_*` objects with no `tenantId`. That is the case the line exists for, and the card keeps it. Whether the showcase seed path should run those writes in an organization's context is outside this card. I'm noting it here and not filing it, since nothing is shown wrong. - An exempt admin's **by-id** write records no verdict, so the engine scopes it by its own facts (the active organization), and nothing is declared. Only predicate writes carry the recorded `none`. This is ADR-0131 D8 as written; this PR does not move it. - I did not exercise the exemption end to end through REST. It is pinned at the `DriverOptions` seam, where plugin-security's recorded verdict meets the engine. Boundary: `NOT MEASURED: live exempt-admin predicate write`. --- _Generated by [Claude Code](https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a835858 commit b62f959

10 files changed

Lines changed: 450 additions & 45 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.
13+
14+
Diagnostics only: the driver's scope was already the recorded verdict's, and the option only silences the warning.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): the `[tenant-audit]` warning no longer fires for an insert whose rows carry their organization
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.
13+
14+
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.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,29 @@ now costs nothing, and the population is **re-derivable** rather than quoted.
4343

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

46-
`auditMissingTenant(object, op, options)` is gated in this order:
46+
`auditMissingTenant(object, op, options, insertRows)` is gated in this order:
4747

4848
1. `OS_TENANT_AUDIT=0` — off entirely;
4949
2. `options.bypassTenantAudit` — on an `ExecutionContext.isSystem` write the
5050
engine sets it only where the object's schema puts the write out of scope:
5151
an object with no tenant field, or a federated one (ObjectQL's
52-
`buildDriverOptions`). A caller may still pass it explicitly;
52+
`buildDriverOptions`). It also sets it on a predicate write the
53+
organization wall exempted (ADR-0131 D8: the recorded Layer 0 verdict is
54+
`none` where the wall would otherwise apply). A caller may still pass it
55+
explicitly;
5356
3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`;
5457
4. the deployment posture — the warning is only meaningful on a walled install;
55-
5. the object having a tenant field.
56-
57-
Both of the guards a *caller* can move are downstream of one decision at the call
58-
site: **whether it threads an execution context at all**. Thread an elevated one
59-
and guard 2 silences the warning; thread a tenant-bearing one and guard 3 does;
60-
thread none and the site is what this census counts.
58+
5. the object having a tenant field;
59+
6. for `create` and `bulkCreate` only, every inserted row carrying that field —
60+
an insert is confined to the organizations its rows name. Updates, deletes
61+
and upserts are audited whatever their payload says.
62+
63+
The guards a *caller* can move are downstream of one decision at the call
64+
site: **whether it threads an execution context at all**. Thread a
65+
tenant-bearing one and guard 3 silences the warning; thread an elevated one and
66+
an object with a tenant column is still audited, unless the write inserts rows
67+
that name their organization (guard 6); thread none and the site is what this
68+
census counts.
6169

6270
## How the population is decided
6371

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0131 8c-1] The `[tenant-audit]` line skips an INSERT whose row carries
4+
// its tenant column, and only an insert.
5+
//
6+
// An insert names its organization in two places: `options.tenantId`, which
7+
// `injectTenantOnInsert` stamps onto the row, and the row's own tenant column.
8+
// A `create` / `bulkCreate` whose every row already carries that column is
9+
// confined to the organizations it names, so the line had nothing to report —
10+
// yet a walled boot printed one for every elevated seed insert (they reach the
11+
// driver with no `tenantId` and an `organization_id` on the row; a system
12+
// insert with none is refused above the driver, ADR-0131 D9).
13+
//
14+
// The other half is what must NOT move: an update or a delete with no
15+
// `tenantId` still warns whatever its payload says (the row's column is a value
16+
// being written there, not the statement's scope), and so does an upsert, whose
17+
// merge leg can reach another organization's row. The prefix is byte-identical,
18+
// because `delivery-claim-tenant-audit.integration.test.ts` matches it.
19+
//
20+
// Real `SqlDriver` against a real in-memory SQLite; the logger is the
21+
// assertion surface.
22+
23+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
24+
import { SqlDriver } from '../src/index.js';
25+
26+
const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
27+
28+
let driver: SqlDriver;
29+
let warns: Array<{ msg: string; meta: any }>;
30+
31+
/**
32+
* An organization-less row is refused by the NOT NULL organization column
33+
* (ADR-0131 D1) — AFTER the audit has run, which is the order these controls
34+
* read: the line is printed, then the statement fails.
35+
*/
36+
const refused = (write: Promise<unknown>) =>
37+
write.then(
38+
() => { throw new Error('expected the organization-less insert to be refused'); },
39+
() => undefined,
40+
);
41+
42+
const lines = (op?: string) =>
43+
warns.filter((w) => w.msg.startsWith(op === undefined ? '[tenant-audit] ' : `[tenant-audit] ${op} on tenant-scoped object "account"`));
44+
45+
beforeEach(async () => {
46+
process.env.OS_TENANCY_POSTURE = 'isolated';
47+
driver = new SqlDriver({
48+
client: 'better-sqlite3',
49+
connection: { filename: ':memory:' },
50+
useNullAsDefault: true,
51+
});
52+
warns = [];
53+
(driver as any).logger = { warn: (msg: string, meta: any) => warns.push({ msg, meta }) };
54+
await driver.initObjects([
55+
{ name: 'account', fields: { organization_id: { type: 'string' }, name: { type: 'string' } } },
56+
]);
57+
});
58+
afterEach(async () => {
59+
await driver.disconnect();
60+
if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
61+
else process.env.OS_TENANCY_POSTURE = OLD_POSTURE;
62+
});
63+
64+
describe('[ADR-0131 8c-1] an insert whose row carries its organization is not audited', () => {
65+
it('create: the row names its organization, no tenantId — no line', async () => {
66+
await driver.create('account', { id: 'a1', organization_id: 'org_a', name: 'A1' });
67+
expect(lines()).toEqual([]);
68+
});
69+
70+
it('bulkCreate: every row names its organization — no line, across organizations too', async () => {
71+
await driver.bulkCreate('account', [
72+
{ id: 'a1', organization_id: 'org_a', name: 'A1' },
73+
{ id: 'b1', organization_id: 'org_b', name: 'B1' },
74+
]);
75+
expect(lines()).toEqual([]);
76+
});
77+
78+
it('create: a row WITHOUT its organization still warns (control)', async () => {
79+
await refused(driver.create('account', { id: 'a1', name: 'A1' }));
80+
expect(lines('create')).toHaveLength(1);
81+
expect(lines('create')[0].meta).toMatchObject({ object: 'account', op: 'create', tenantField: 'organization_id' });
82+
});
83+
84+
it('create: an empty organization is no organization — it still warns', async () => {
85+
await driver.create('account', { id: 'a1', organization_id: '', name: 'A1' });
86+
expect(lines('create')).toHaveLength(1);
87+
});
88+
89+
it('create: a null organization is no organization — it still warns', async () => {
90+
await refused(driver.create('account', { id: 'a1', organization_id: null, name: 'A1' }));
91+
expect(lines('create')).toHaveLength(1);
92+
});
93+
94+
it('bulkCreate: ONE row without its organization keeps the line', async () => {
95+
await refused(driver.bulkCreate('account', [
96+
{ id: 'a1', organization_id: 'org_a', name: 'A1' },
97+
{ id: 'a2', name: 'A2' },
98+
]));
99+
expect(lines('bulkCreate')).toHaveLength(1);
100+
});
101+
102+
it('the prefix is byte-identical', async () => {
103+
await refused(driver.create('account', { id: 'a1', name: 'A1' }));
104+
expect(lines()[0].msg.startsWith(
105+
'[tenant-audit] create on tenant-scoped object "account" without options.tenantId — ',
106+
)).toBe(true);
107+
});
108+
});
109+
110+
describe('[ADR-0131 8c-1] what must not move: non-insert writes with no tenantId still warn', () => {
111+
beforeEach(async () => {
112+
await driver.create('account', { id: 'a1', organization_id: 'org_a', name: 'A1' }, { tenantId: 'org_a' });
113+
warns = [];
114+
});
115+
116+
it('update, even when its payload names an organization', async () => {
117+
await driver.update('account', 'a1', { organization_id: 'org_a', name: 'renamed' });
118+
expect(lines('update')).toHaveLength(1);
119+
});
120+
121+
it('delete', async () => {
122+
await driver.delete('account', 'a1');
123+
expect(lines('delete')).toHaveLength(1);
124+
});
125+
126+
it('updateMany', async () => {
127+
await driver.updateMany('account', { where: { name: 'A1' } } as any, { organization_id: 'org_a', name: 'x' });
128+
expect(lines('updateMany')).toHaveLength(1);
129+
});
130+
131+
it('deleteMany', async () => {
132+
await driver.deleteMany('account', { where: { name: 'A1' } } as any);
133+
expect(lines('deleteMany')).toHaveLength(1);
134+
});
135+
136+
it('upsert, even when its row names an organization — the merge leg is an update', async () => {
137+
await driver.upsert('account', { id: 'a1', organization_id: 'org_a', name: 'merged' });
138+
expect(lines('upsert')).toHaveLength(1);
139+
});
140+
});

‎packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,15 @@ const bootDriver = async () => {
5757
await driver.initObjects(objects);
5858
};
5959

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

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

95101
expect(tenantAuditWarned()).toBe(true);
96-
expect(warns[0].meta).toMatchObject({ object: 'account', op: 'create', tenantField: 'organization_id' });
102+
expect(warns[0].meta).toMatchObject({ object: 'account', op: 'delete', tenantField: 'organization_id' });
97103
});
98104

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

141147
it('bypassTenantAudit still silences a walled deployment', async () => {
142148
setEnv({ posture: 'isolated' });
143-
await driver.create(
144-
'account',
145-
{ id: 'x1', organization_id: 'org_a', name: 'X1' },
146-
{ bypassTenantAudit: true },
147-
);
149+
await driver.delete('account', 'x1', { bypassTenantAudit: true });
148150
expect(tenantAuditWarned()).toBe(false);
149151
});
150152

@@ -210,7 +212,7 @@ describe('#5262 — the verdict is taken LIVE, never frozen at first write', ()
210212
describe('the [tenant-audit] line names both readings and the remedy for each', () => {
211213
it('a wall-exempt cross-organization updateMany is not told to pass tenantId', async () => {
212214
setEnv({ posture: 'isolated' });
213-
await writeWithoutTenant('x1');
215+
await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'x1' });
214216
warns = [];
215217
// The exempt admin's shape: a predicate update with no tenantId.
216218
await driver.updateMany('account', { where: { name: 'x1' } } as any, { name: 'renamed' });

‎packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -429,10 +429,12 @@ describe('SqlDriver tenant scope (organization_id)', () => {
429429
try {
430430
await driver.initObjects(objects);
431431

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

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7681,7 +7681,7 @@ export class SqlDriver implements IDataDriver {
76817681
toInsert.id = nanoid(DEFAULT_ID_LENGTH);
76827682
}
76837683

7684-
this.auditMissingTenant(object, 'create', options);
7684+
this.auditMissingTenant(object, 'create', options, [toInsert]);
76857685
this.injectTenantOnInsert(object, toInsert, options);
76867686

76877687
// #5495. The autonumber counter is bootstrapped from the data table exactly
@@ -10141,7 +10141,6 @@ export class SqlDriver implements IDataDriver {
1014110141
* contract's type now, pinned by `sql-driver-doors-declared-types.test.ts`.
1014210142
*/
1014310143
async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
10144-
this.auditMissingTenant(object, 'bulkCreate', options);
1014510144
// Same client-side id assignment as create() (id/_id normalization,
1014610145
// nanoid fallback when neither is supplied) — a row missing an id must
1014710146
// not be silently inserted with a NULL primary key just because the
@@ -10158,6 +10157,7 @@ export class SqlDriver implements IDataDriver {
1015810157
}
1015910158
return toInsert;
1016010159
});
10160+
this.auditMissingTenant(object, 'bulkCreate', options, rows);
1016110161
for (const row of rows) {
1016210162
if (row && typeof row === 'object') this.injectTenantOnInsert(object, row, options);
1016310163
}
@@ -16085,11 +16085,24 @@ export class SqlDriver implements IDataDriver {
1608516085
* `tenantId` (ADR-0131 D8). Telling that second write to "pass tenantId"
1608616086
* prescribes the one change that would break it, so ⛔ the remedy sentence
1608716087
* never addresses the write as if it were only the first kind.
16088+
*
16089+
* [ADR-0131 8c-1] An INSERT names its organization in a second place: the
16090+
* row's own tenant column, the slot {@link injectTenantOnInsert} fills from
16091+
* `tenantId`. A `create` / `bulkCreate` whose every row already carries that
16092+
* column is confined to the organizations its rows name, so there is nothing
16093+
* to report — and that is how the elevated seed and replay inserts of a
16094+
* walled boot arrive (a system insert with no organization is refused above
16095+
* this driver, ADR-0131 D9). `insertRows` is those rows, passed by the two
16096+
* insert doors only; one row without the column keeps the line. ⛔ Never for
16097+
* update, delete or upsert: there the column is a value being written, not
16098+
* the statement's scope (the line says so), and an upsert's merge leg can
16099+
* reach a row of another organization. Diagnostics only: no write changes.
1608816100
*/
1608916101
protected auditMissingTenant(
1609016102
object: string,
1609116103
op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert',
1609216104
options?: DriverOptions,
16105+
insertRows?: readonly unknown[],
1609316106
): void {
1609416107
if (process.env.OS_TENANT_AUDIT === '0') return;
1609516108
if (options?.bypassTenantAudit === true) return;
@@ -16109,6 +16122,13 @@ export class SqlDriver implements IDataDriver {
1610916122
if (!this.isMultiTenantMode()) return;
1611016123
const field = this.resolveTenantField(object);
1611116124
if (!field) return;
16125+
// Empty is spelled as `injectTenantOnInsert` spells it: a row it would
16126+
// stamp is a row that does not carry its organization.
16127+
const carriesTenant = (row: unknown): boolean => {
16128+
const value = row && typeof row === 'object' ? (row as Record<string, unknown>)[field] : undefined;
16129+
return value !== undefined && value !== null && value !== '';
16130+
};
16131+
if (insertRows !== undefined && insertRows.length > 0 && insertRows.every(carriesTenant)) return;
1611216132
const key = `${object}:${op}`;
1611316133
if (this.tenantAuditWarned.has(key)) return;
1611416134
this.tenantAuditWarned.add(key);

‎packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,12 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => {
304304
try {
305305
await driver.initObjects(objects);
306306

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

0 commit comments

Comments
 (0)