Skip to content

Commit a10b69e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24751-remind-notified-per-person
2 parents 5a31503 + 7c2eab9 commit a10b69e

22 files changed

Lines changed: 514 additions & 241 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
feat(driver-sql)!: a tenant-scoped read no longer answers a row with no organization; the driver's `OR organization_id IS NULL` arm is retired
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A read-scope narrowing in the SQL driver's tenant chokepoint and the lifecycle sweep's global pass: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is added, removed or renamed. The data it stops reaching is retired by the 18.x upgrade ceremony, which certifies zero organization-less rows before a database boots. -->
11+
12+
**BREAKING** read-scope narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D1, D14). Step 7 of the 18.x organization-ownership line: it lands after the upgrade ceremony opens.
13+
14+
- **Before.** `SqlDriver.applyTenantScope` compiled a tenant scope as `(organization_id = :tenant OR organization_id IS NULL)`, and the `group` posture's union as `(organization_id IN (…) OR organization_id IS NULL)`. That was the v17 reading, in which a row with no organization stood for platform state, and every tenant's scoped read, count, update and delete reached it. The Turso remote face compiles the same scope, so it answered the same rows.
15+
- **Now.** The scope is `organization_id = :tenant`, or `organization_id IN (…)` under `group`. NULL is not an owner: a row with no organization is no tenant's, and only an unscoped read (no tenant on the call) reaches it. The lifecycle sweep's global retention and archive pass over an `organization_id` partition is a bare `$nin` of the overriding tenants: it no longer selects organization-less rows.
16+
- **Unchanged.** A call with no tenant context and an object with no tenant column are unscoped, as before. The `single` posture's driver reads carry no tenant scope at all (ADR-0131 D8). The `sys_audit_log` attribution partition (`tenant_id`, ADR-0131 D7) keeps its NULL arm: a deployment-level ledger row is about no organization by design, and the global pass still reaps it. Insert stamping (`injectTenantOnInsert`) is unchanged.
17+
18+
**Who is affected.** Only a database that still holds organization-less rows in a tenant-scoped table, and only under a walled posture (`group` / `isolated`) or a caller that passes `tenantId` to the driver itself. A certified 18.x database holds none: a v17 database is refused at boot until `os migrate organization-ownership` certifies it, and a fresh install never writes one.
19+
20+
**The remedy.** If a scoped read misses a row you expect, the row has no organization: run the upgrade ceremony (`os migrate organization-ownership` to review its plan, then the same command with `--apply`), which stamps each such row with its owner, or stamp it yourself (`organization_id = <the owning organization>`).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): the out-of-office delegation form's Delegator picker offers only the signed-in user, the one delegator the server accepts
6+
7+
Clause-②: no
8+
9+
No accept set moves. The field gains a picker filter, which nothing on the write path reads, and the delegation write guard and `required` are unchanged.
10+
11+
- **Before.** `sys_approval_delegation.delegator_id` was a free `sys_user` lookup, so Approvals → Delegations → New offered every user. The delegation write guard accepts one delegator on a non-system write, the acting user, so picking anyone else was refused with 403 `FORBIDDEN`.
12+
- **Now.** The field declares `lookupFilters: [{ field: 'id', operator: 'eq', value: '{current_user_id}' }]`. The engine resolves the placeholder against the caller on every read, so the picker's candidate query offers the signed-in user and nobody else.
13+
- **Unchanged.** What a write may store. A create or an update that names another delegator is still refused, and there is no admin exemption. An admin covering for an absent approver reassigns, recalls or rejects that approver's pending approvals through the approval override instead.

‎packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,22 @@ function suite(face: Face) {
195195
]);
196196
});
197197

198+
it('a batch with no tenant on the call answers every row it stored, the organization-less one included', async () => {
199+
// [ADR-0131 D1] The tenant scope carries no `IS NULL` arm, so the rows'
200+
// own union (`IN ('org_b')`) would miss the unstamped row this call just
201+
// wrote. The read is scoped by that union only when every row carries a
202+
// tenant; here the call's own options (none) decide, and the ids bound it.
203+
const batch = await driver.bulkCreate(TABLE, [
204+
{ id: 't-m1', title: 'named', organization_id: 'org_b' },
205+
{ id: 't-m2', title: 'unstamped' },
206+
]);
207+
expect(batch.map((r) => [r.id, r.organization_id ?? null])).toEqual([
208+
['t-m1', 'org_b'],
209+
['t-m2', null],
210+
]);
211+
expect(batch[1]).toEqual(await stored('t-m2'));
212+
});
213+
198214
it('inside a caller transaction, create and bulkCreate answer the uncommitted rows and the rollback keeps', async () => {
199215
const trx = await driver.beginTransaction();
200216
let single!: Record<string, unknown>;

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,14 @@
5858
* answering `[]` to an unscoped admin read is a different defect, not a fix. So
5959
* each exposure case is stated beside the read it must not become.
6060
*
61-
* The NULL-org row is here for the same reason. `applyTenantScope` emits
62-
* `(field = ? OR field IS NULL)` on purpose (#2734: strict equality hid every
63-
* platform-seeded RBAC row from every tenant admin). A door "fixed" with a bare
64-
* equality would pass a naive cross-tenant assertion and silently lose p1, so
65-
* p1 is what distinguishes routing through the chokepoint from reimplementing
66-
* a worse copy of it.
61+
* The NULL-org row is here for the same reason. The measurement above is the
62+
* v17 chokepoint, `(field = ? OR field IS NULL)` (#2734). [ADR-0131 D1, D14]
63+
* 18.x retired that arm after the upgrade ceremony: NULL is not an owner, so
64+
* `applyTenantScope` emits `field = ?` alone and no scoped read reaches p1. p1
65+
* now holds every door to the SAME rule from both sides: absent under a
66+
* tenant, present with none. A door that kept its own copy of the old arm, or
67+
* that dropped the no-tenant early-out, answers p1 where the chokepoint does
68+
* not.
6769
*/
6870

6971
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -136,8 +138,8 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
136138
await driver.create(TABLE, { id: 'a2', organization_id: 'org_a', name: 'A2', balance: 20 } as any);
137139
await driver.create(TABLE, { id: 'b1', organization_id: 'org_b', name: 'B1', balance: 30 } as any);
138140
await driver.create(TABLE, { id: 'b2', organization_id: 'org_b', name: 'B2', balance: 40 } as any);
139-
// Platform row: belongs to no tenant, so it belongs to no OTHER tenant
140-
// either and must stay visible (#2734).
141+
// An organization-less row: no tenant's, so no scoped read reaches it
142+
// (ADR-0131 D1); only an unscoped one does.
141143
await driver.create(TABLE, { id: 'p1', name: 'P1', balance: 50 } as any);
142144

143145
await driver.create(UNSCOPED_TABLE, { id: 'l1', name: 'L1', balance: 1 } as any);
@@ -155,16 +157,16 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
155157
const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
156158
tenantId: 'org_a',
157159
} as any);
158-
expect(ids(rows)).toEqual(['a1', 'a2', 'p1']);
160+
expect(ids(rows)).toEqual(['a1', 'a2']);
159161
expect(ids(rows)).not.toContain('b1');
160162
expect(ids(rows)).not.toContain('b2');
161163
});
162164

163165
it('gives each tenant its own rows, and only its own', async () => {
164166
const a = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
165167
const b = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_b' } as any);
166-
expect(ids(a)).toEqual(['a1', 'a2', 'p1']);
167-
expect(ids(b)).toEqual(['b1', 'b2', 'p1']);
168+
expect(ids(a)).toEqual(['a1', 'a2']);
169+
expect(ids(b)).toEqual(['b1', 'b2']);
168170
});
169171

170172
it('agrees with `find()` on the same read — one driver, one answer', async () => {
@@ -173,23 +175,23 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
173175
expect(ids(viaWindow)).toEqual(ids(viaFind));
174176
});
175177

176-
it('keeps the NULL-org platform row visible — the chokepoint, not a bare equality (#2734)', async () => {
178+
it('does not reach the organization-less row under a tenant — NULL is not an owner (ADR-0131 D1)', async () => {
177179
const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
178-
expect(ids(rows)).toContain('p1');
180+
expect(ids(rows)).not.toContain('p1');
179181
});
180182

181183
it('honours the ADR-0105 D2 union posture the same way the chokepoint does (#3623)', async () => {
182184
const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
183185
tenantId: 'org_a',
184186
tenantIds: ['org_a', 'org_b'],
185187
} as any);
186-
expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2', 'p1']);
188+
expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2']);
187189

188190
const narrow = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
189191
tenantId: 'org_a',
190192
tenantIds: ['org_a'],
191193
} as any);
192-
expect(ids(narrow)).toEqual(['a1', 'a2', 'p1']);
194+
expect(ids(narrow)).toEqual(['a1', 'a2']);
193195
});
194196

195197
it('still applies the caller\'s own `where` beside the tenant predicate', async () => {
@@ -198,10 +200,9 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
198200
{ ...WINDOW, where: { balance: { $gte: 20 } } } as any,
199201
{ tenantId: 'org_a' } as any,
200202
);
201-
// `p1` (balance 50) is the NULL-org platform row and is legitimately
202-
// visible to org_a — it passes the filter AND the chokepoint. b1/b2
203-
// (30/40) pass the filter and must not survive the tenant predicate.
204-
expect(ids(rows)).toEqual(['a2', 'p1']);
203+
// `p1` (balance 50) and b1/b2 (30/40) pass the filter, and none of them
204+
// survives the tenant predicate: p1 is no tenant's (ADR-0131 D1).
205+
expect(ids(rows)).toEqual(['a2']);
205206
});
206207

207208
it('still computes the window function itself', async () => {
@@ -245,7 +246,7 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
245246
describe('distinct — the third read door, found by the gate rather than by the card', () => {
246247
it('does not return another tenant\'s values', async () => {
247248
const values = await driver.distinct(TABLE, 'name', undefined, { tenantId: 'org_a' } as any);
248-
expect(values.sort()).toEqual(['A1', 'A2', 'P1']);
249+
expect(values.sort()).toEqual(['A1', 'A2']);
249250
expect(values).not.toContain('B1');
250251
expect(values).not.toContain('B2');
251252
});
@@ -260,9 +261,9 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
260261
const values = await driver.distinct(TABLE, 'name', { balance: { $gte: 20 } } as any, {
261262
tenantId: 'org_a',
262263
} as any);
263-
// `P1` is the NULL-org platform row (balance 50) — see the window-door
264-
// case above. `B1`/`B2` also pass the filter and must not survive.
265-
expect(values.sort()).toEqual(['A2', 'P1']);
264+
// `P1` is the organization-less row (balance 50) — see the window-door
265+
// case above. It and `B1`/`B2` pass the filter and must not survive.
266+
expect(values.sort()).toEqual(['A2']);
266267
});
267268

268269
it('is UNSCOPED with no tenantId — the admin / seed path stays open', async () => {

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

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,30 +133,43 @@ describe('SqlDriver tenant scope (organization_id)', () => {
133133
});
134134
});
135135

136-
describe('NULL tenant column = GLOBAL/platform row (#2734)', () => {
136+
// [ADR-0131 D1, D14] NULL is not an owner. The v17 reading (#2734) answered
137+
// an organization-less row to EVERY tenant's scoped read, as platform state;
138+
// 18.x retired that arm with the upgrade ceremony, after which no such row
139+
// exists on a certified database. A row written with no organization is
140+
// therefore no tenant's: only an unscoped read reaches it.
141+
describe('NULL tenant column = no tenant\'s row (ADR-0131 D1)', () => {
137142
beforeEach(async () => {
138-
// A platform-seeded row with no organization (bootstrap positions,
139-
// permission sets, business units, pre-org first-boot seeds).
140-
await driver.create('account', { id: 'g1', name: 'Global', tier: 'global' });
143+
await driver.create('account', { id: 'g1', name: 'Orphan', tier: 'none' });
141144
});
142145

143-
it('a scoped read still sees the org-less row (any tenant)', async () => {
146+
it('a scoped read does not see the organization-less row, under any tenant', async () => {
144147
const rowsA = await driver.find('account', {}, { tenantId: 'org_a' });
145148
const rowsB = await driver.find('account', {}, { tenantId: 'org_b' });
146-
expect(rowsA.map((r: any) => r.id)).toContain('g1');
147-
expect(rowsB.map((r: any) => r.id)).toContain('g1');
148-
// …while cross-tenant rows stay hidden exactly as before.
149-
expect(rowsA.map((r: any) => r.id).sort()).toEqual(['a1', 'a2', 'g1']);
149+
expect(rowsA.map((r: any) => r.id).sort()).toEqual(['a1', 'a2']);
150+
expect(rowsB.map((r: any) => r.id).sort()).toEqual(['b1', 'b2']);
150151
});
151152

152-
it('a scoped by-id read resolves the global row', async () => {
153+
it('a scoped by-id read does not resolve it', async () => {
153154
const row = await driver.findOne('account', { where: { id: 'g1' } }, { tenantId: 'org_a' });
154-
expect(row?.id).toBe('g1');
155+
expect(row).toBeNull();
155156
});
156157

157-
it('a scoped count includes the global row', async () => {
158+
it('a scoped count excludes it', async () => {
158159
const a = await driver.count!('account', {}, { tenantId: 'org_a' });
159-
expect(a).toBe(3); // a1, a2, g1 — never org_b's rows
160+
expect(a).toBe(2); // a1, a2
161+
});
162+
163+
it('a scoped update or delete does not reach it', async () => {
164+
await driver.update('account', 'g1', { name: 'Touched' }, { tenantId: 'org_a' }).catch(() => undefined);
165+
await driver.delete('account', 'g1', { tenantId: 'org_a' }).catch(() => undefined);
166+
const row = await driver.findOne('account', { where: { id: 'g1' } });
167+
expect(row?.name).toBe('Orphan');
168+
});
169+
170+
it('an unscoped read still sees it — no tenant context is no scope', async () => {
171+
const rows = await driver.find('account', {});
172+
expect(rows.map((r: any) => r.id)).toContain('g1');
160173
});
161174
});
162175

@@ -464,14 +477,14 @@ describe('SqlDriver tenant scope (organization_id)', () => {
464477
expect(rows.map(r => r.id)).not.toContain('c1');
465478
});
466479

467-
it('keeps the NULL-tenant global-row carve-out (#2734) on the union path', async () => {
468-
await driver.create('account', { id: 'g1', name: 'GLOBAL' });
480+
it('the union path does not reach an organization-less row either (ADR-0131 D1)', async () => {
481+
await driver.create('account', { id: 'g1', name: 'ORPHAN' });
469482
const rows = await driver.find(
470483
'account',
471484
{},
472-
{ tenantId: 'org_a', tenantIds: ['org_a'] } as any,
485+
{ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any,
473486
);
474-
expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'g1']);
487+
expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'b1', 'b2']);
475488
});
476489

477490
it('an empty or malformed tenantIds falls back to tenantId equality (fail toward isolation)', async () => {

0 commit comments

Comments
 (0)