diff --git a/.changeset/15212-8b-single-platform-insert-stamped.md b/.changeset/15212-8b-single-platform-insert-stamped.md new file mode 100644 index 00000000000..482d7ecf440 --- /dev/null +++ b/.changeset/15212-8b-single-platform-insert-stamped.md @@ -0,0 +1,17 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql)!: under the `single` posture, a system insert into a platform object that names no organization takes the Default Organization, or is refused; it no longer lands with `organization_id` NULL + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11). + +- **Before.** A system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule entirely, under every posture. The row landed with `organization_id` NULL. Thirty-five platform objects in this repository were in that population, among them `sys_business_unit`, `sys_business_unit_member`, `sys_member`, `sys_team`, `sys_user_permission_set`, `sys_user_position`, `sys_sharing_rule`, `sys_share_link`, `sys_comment`, `sys_attachment`, `sys_activity`, `sys_email`, `sys_inbox_message`, `sys_secret`, `sys_setting`, `sys_setting_audit`, `sys_webhook` and `sys_flow_credential`. +- **Now, under `single`.** That insert goes through the same decision every application object already gets. With exactly one organization (the Default Organization), the row is stamped with it. With none, or with several, the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), and the message names the missing organization. Nothing is written. +- **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change ships in a later 18.x release. A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged. + +**The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. On a fresh `single` deployment the auth plugin creates the Default Organization at boot, so a refusal reading `no-organization` means the write ran before it, or in a composition that registers `sys_organization` but mounts no auth plugin to create the Default Organization. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index ccafb7d2e06..344f1ba51a8 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -1090,8 +1090,8 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( const ql = makeQl({ sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [{ user_id: 'u1', role: 'admin', organization_id: 'o1' }], - sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'ehr_all', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'ehr_all', organization_id: 'o1' }], sys_permission_set: [{ id: 'psA', name: 'ehr_all', system_permissions: ['cap_ehr'] }], }); const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 7f8171017e0..b768ac2d550 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7173,16 +7173,20 @@ export class ObjectQL implements IObjectQLEngine { // system write that threaded one. Nothing to resolve; this is the shape the // ruling asks every system write to reach. if (carriesOrganization(execCtx?.tenantId)) return undefined; - // [#13491] Platform-namespace objects are excluded PER OBJECT, not - // wholesale. The 2026-08-31 ruling withdrew the blanket - // `isPlatformNamespaceObject(object)` exemption that used to stand here: - // commit ff08691e6's "an org-less row is defensible for `sys_permission_set`" - // inherits per object, and the namespace also holds objects whose org-less - // rows are a defect — five instances of that class, all found by hand. - // `unclassified` keeps the old exclusion so an unadjudicated object's - // behaviour does not move; see `platform-object-tenancy.ts` for the - // inventory and why the classification cannot be read off the schema. - if (isPlatformObjectOutOfTenantAuditScope(object)) return undefined; + // [ADR-0131 D9, C8 stage 8b] Under `single` a platform object has no + // exemption: with a tenant column it is in scope like any other object, so + // its organization-less system insert is stamped with the Default + // Organization or refused below — never left NULL. + // + // ⚠️ WALLED POSTURES ONLY: #13491's per-object exemption still stands under + // `group` / `isolated` (epic ruling record 6259899486 on #15194, item 4). + // 18.0 refuses a walled posture at the operator's boot path, and the walled + // half of the lift lands in 18.x, after the stages that give the walled + // writers an organization of their own. The cut is a POSTURE condition on + // purpose — ⛔ never re-narrow it with a per-table list. + if (isPlatformObjectOutOfTenantAuditScope(object) && this.resolveEnginePosture() !== 'single') { + return undefined; + } const objectSchema = this._registry.getObject(object) as any; // A federated object's schema is the REMOTE's (ADR-0015); the platform's // injected column says nothing about it, which is the same reason diff --git a/packages/objectql/src/system-write-organization.test.ts b/packages/objectql/src/system-write-organization.test.ts index 5faf70bad3f..15992a8ed29 100644 --- a/packages/objectql/src/system-write-organization.test.ts +++ b/packages/objectql/src/system-write-organization.test.ts @@ -124,12 +124,11 @@ const DISPATCH_ORDER = { /** * A platform-namespace object the #13491 inventory did NOT adjudicate. * - * ⚠️ Its exclusion below is `unclassified`, not `global`. Until the 2026-08-31 - * ruling this file read it as "platform namespace ⇒ deliberately org-less - * (…)"; that wholesale reading was withdrawn, and what these cases now pin - * is that an UNADJUDICATED object's behaviour did not move — which is what - * bounds the reclassification's blast radius to the admitted list. The admitted - * and `global` sides are pinned in `tenancy-by-object-classification.test.ts`. + * ⚠️ Its exclusion below is `unclassified`, not `global`, and since ADR-0131 + * C8 stage 8b it holds under a WALLED posture only (epic ruling record + * 6259899486 on #15194, item 4). Under `single` the object is judged like any + * other — derived or refused. The admitted and `global` sides are pinned in + * `tenancy-by-object-classification.test.ts`. */ const SYS_LEDGER = { name: 'sys_audit_entry', @@ -389,20 +388,28 @@ describe('#8844 multi-organization — the write is REFUSED, never defaulted', ( }); describe('#8844 the exclusions — populations the refusal must not touch', () => { - it.each(['isolated', 'single'] as const)( - 'a platform-namespace object stays org-less on the %s posture', + it.each(['isolated', 'group'] as const)( + 'a platform-namespace object stays org-less on the %s posture — the walled exemption stands in 18.0', async (posture) => { - // ⚠️ [#13491] Reads as an UNCLASSIFIED verdict now, not a namespace one. - // The card commit ff08691e6 cites measured this primitive on `sys_permission_set` and filed it as an - // observation because an org-less row is defensible there. The #8844 - // ruling confirms that reasoning holds for platform objects and does NOT - // generalize to application objects — which is exactly the boundary here. + // ⚠️ [#13491] Reads as an UNCLASSIFIED verdict, not a namespace one, and + // [C8 stage 8b] holds under a wall only: 18.0 refuses a walled posture at + // the operator's boot path, and the walled half of the lift is 18.x's + // (epic ruling record 6259899486 on #15194, item 4). const { engine, observed } = await makeEngine({ posture }); await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined(); }, ); + it('[C8 stage 8b] single posture: a platform-namespace object is no longer excluded — it takes the sole organization', async () => { + // ADR-0131 D9 / D11: `single` is never a reason for a row to be org-less. + // The walled case above is the control: same object, same write, only the + // posture differs. + const { engine, observed } = await makeEngine({ posture: 'single', organizations: [ORG_ID] }); + await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBe(ORG_ID); + }); + it.each(['isolated', 'single'] as const)( 'an object declaring `tenancy.enabled: false` stays org-less on the %s posture', async (posture) => { @@ -418,8 +425,10 @@ describe('#8844 the exclusions — populations the refusal must not touch', () = it('never probes `sys_organization` for a write it does not judge', async () => { // The cost half of the exclusions: an ordinary write must pay two property // reads and a regexp, not a query. + // [C8 stage 8b] A platform object is judged under `single` now, so it is + // not in this population: only the walled exemption skips its probe, and + // a walled refusal never probes anyway. const { engine, observed } = await makeEngine({ organizations: [ORG_ID] }); - await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any); await engine.insert('dispatch_order', { subject: 'session' }, { context: SESSION_CTX } as any); expect(observed.filter((c) => c.object === 'sys_organization')).toEqual([]); diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts index 90c610a20d7..9703830ec12 100644 --- a/packages/objectql/src/tenancy-by-object-classification.test.ts +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -196,17 +196,48 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref expect(lastWrite(observed, 'sys_file')?.options?.tenantId).toBe(ORG_ID); }); - it('single posture: a GLOBAL platform object still stays org-less — the control', async () => { - const { engine, observed } = await makeEngine({ posture: 'single' }); - await engine.insert('sys_permission_set', { label: 'Admin' }, { context: SYSTEM_CTX } as any); - expect(lastWrite(observed, 'sys_permission_set')?.options?.tenantId).toBeUndefined(); - }); + // [ADR-0131 C8 stage 8b] Under `single` the classification no longer decides + // the write: every platform object with a tenant column is judged, admitted + // or not. The walled cases below are the control — there the exemption + // stands in 18.0 (epic ruling record 6259899486 on #15194, item 4). + it.each([ + ['sys_permission_set', { label: 'Admin' }], + ['sys_audit_entry', { subject: 'e1' }], + ] as const)( + 'single posture, one organization: the un-admitted platform object %s now DERIVES it', + async (object, row) => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert(object, { ...row }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, object)?.options?.tenantId).toBe(ORG_ID); + }, + ); - it('single posture: an UNCLASSIFIED platform object stays org-less — behaviour did not move', async () => { - const { engine, observed } = await makeEngine({ posture: 'single' }); - await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); - expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined(); - }); + it.each([ + ['no organization', [] as string[], 0], + ['two organizations', [ORG_ID, 'org_second'], 2], + ] as const)( + 'single posture, %s: an org-less system insert on an un-admitted platform object is REFUSED, loudly, and never written', + async (_label, organizations, count) => { + const { engine, observed } = await makeEngine({ posture: 'single', organizations: [...organizations] }); + await expect( + engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any), + ).rejects.toMatchObject({ + code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', + status: 500, + organizationCount: count, + }); + expect(lastWrite(observed, 'sys_audit_entry')).toBeUndefined(); + }, + ); + + it.each(['isolated', 'group'] as const)( + '%s posture: an UNCLASSIFIED platform object still stays org-less — the walled exemption stands in 18.0', + async (posture) => { + const { engine, observed } = await makeEngine({ posture }); + await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined(); + }, + ); it.each(['isolated', 'group'] as const)( '%s posture: an org-less system write on an admitted object is REFUSED, loudly', diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts index 3965872d8f6..cc76ce2a757 100644 --- a/packages/objectql/src/tenancy/platform-object-tenancy.ts +++ b/packages/objectql/src/tenancy/platform-object-tenancy.ts @@ -3,6 +3,26 @@ /** * [#13491] Per-object tenancy classification for the platform namespaces. * + * ## ⚠️ What still reads this ledger (ADR-0131 C8 stage 8b) + * + * Under the `single` posture the ledger no longer decides any write: ADR-0131 + * D9 made the system-insert rule structural there — a platform object with a + * tenant column is in scope like any other, so its organization-less system + * insert takes the Default Organization or is refused + * (`Engine.resolveSystemInsertOrganization`). Two readers remain, and they + * are why the ledger has not retired yet (D13): + * + * 1. the system-insert exemption under a WALLED posture (`group` / + * `isolated`), which stands in 18.0 by epic ruling record 6259899486 on + * #15194, item 4 — the walled half of the lift lands in 18.x; + * 2. the engine's `bypassTenantAudit` isSystem mute + * (`Engine.buildDriverOptions`), a diagnostics-only reading in every + * posture that changes nothing a write touches. + * + * ⛔ Do not add an entry to make a `single` write behave: under `single` an + * entry changes nothing but the mute. The ledger retires with its walled + * reader. + * * ## The ruling this implements * * Maintainer, 2026-08-31 (总监席第 5 场, 联案 #13491 + #13497, verbatim 「同意」): @@ -78,10 +98,11 @@ * ## What admission actually changes, per object * * A `tenant-scoped` classification lets the object reach - * `resolveSystemInsertOrganization` (#8844): on a `single` posture with exactly - * one organization the write DERIVES it, and on a walled posture - * (`group` / `isolated`) an org-less write is REFUSED loudly - * ({@link SystemWriteOrganizationRequiredError}). It also stops the engine + * `resolveSystemInsertOrganization` (#8844) on a walled posture + * (`group` / `isolated`), where an org-less write is REFUSED loudly + * ({@link SystemWriteOrganizationRequiredError}). Under `single` every + * platform object with a tenant column reaches it whatever its entry says + * (stage 8b, above). It also stops the engine * auto-muting the driver's tenant-audit warning for elevated writes on it. * Both directions are the ruled one — a refusal or a warning, never a silent * rewrite of what the write touches (execution point 3). diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index 8a1c6000ac0..7b0ebb0dcde 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -71,8 +71,11 @@ * org-less rows is `tenancy: { enabled: false }` — a metadata declaration, * loud and checkable — never a per-write bypass flag, which is exactly the * lenient-consumer accommodation Prime Directive #12 forbids. - * - **Platform-namespace objects the inventory did not admit** ({@link - * isPlatformObjectOutOfTenantAuditScope}, `platform-object-tenancy.ts`). + * - **Under a WALLED posture only, platform-namespace objects the inventory + * did not admit** ({@link isPlatformObjectOutOfTenantAuditScope}, + * `platform-object-tenancy.ts`). Under `single` this exclusion is gone + * (ADR-0131 C8 stage 8b): a platform object with a tenant column takes the + * Default Organization or is refused, like every other object. * * ⚠️ This exclusion used to be the whole `sys_` / `cloud_` / `ai_` * NAMESPACE, on the reasoning commit ff08691e6 recorded, that "an org-less row is defensible for @@ -84,8 +87,9 @@ * none by this control. So the exclusion is now a per-object verdict, and an * object nobody has adjudicated stays excluded and stays ON THE LIST. * - * ⚠️ This per-object exclusion STAYS until ADR-0131 C8, which retires it - * together with the ledger (D13); C1 withdrew only the seed loader's + * ⚠️ C8 stage 8b lifted this exclusion under `single` only. Under a wall it + * stands in 18.0 (epic ruling record 6259899486 on #15194, item 4) and + * retires with the ledger in 18.x (D13). C1 withdrew the seed loader's * namespace exemption. #8686's backfill (`seed-tenancy-backfill.ts`) still * carries the NAMESPACE form; its residue attribution is C7's. Read the * three together before changing any of them. @@ -156,7 +160,8 @@ export const DEFAULT_TENANT_FIELD = 'organization_id'; * #13491's re-ruling (2026-08-31, 联案 #13497) withdrew the wholesale namespace * reading — but its scope was the RUNTIME control only. So today: * - * - **the runtime write path** cuts PER OBJECT, by the hand-adjudicated + * - **the runtime write path** no longer cuts under `single` (C8 stage 8b). + * Under a walled posture it cuts PER OBJECT, by the hand-adjudicated * classification in `platform-object-tenancy.ts` — the * `PLATFORM_OBJECT_TENANCY` ledger, read through * {@link classifyPlatformObjectTenancy} and diff --git a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts index 70d65c16188..76f82cfa899 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts @@ -327,6 +327,10 @@ describe('an approval decision cascades as the deciding user (#3783)', () => { { name: 'sys_organization', label: 'Organization', + // [C8 stage 8b] better-auth owns the table, as `sys-organization.object.ts` + // declares: no injected organization column, so the Default Organization's + // own insert below is not judged as an organization-less platform write. + managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' }, slug: { type: 'text', label: 'Slug' } }, } as any, '@objectstack/plugin-auth', diff --git a/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts index 4ac58e197c5..87568ee8a8b 100644 --- a/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts +++ b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts @@ -236,7 +236,11 @@ describe('Setup identity objects declare enable.feeds: false — a comment creat const { engine, storeFor } = await boot(); await expect( - engine.insert('sys_comment', { thread_id: 'crm_account:acc-1', body: 'hello' } as any), + // [C8 stage 8b] The comment carries its organization: under `single` an + // organization-less system insert into a platform object is refused when + // no organization exists, and this harness registers the organization + // object with none of its rows. + engine.insert('sys_comment', { thread_id: 'crm_account:acc-1', body: 'hello', organization_id: 'org_fixture' } as any), ).resolves.toBeTruthy(); expect(Array.from(storeFor('sys_comment').values()).map((r) => r.thread_id)).toEqual(['crm_account:acc-1']); diff --git a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts index 2da6b0c4017..29b15161ae6 100644 --- a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts @@ -41,7 +41,15 @@ export async function seedLegacyPlatformBucket( continue; } const id = legacyBucketRowId(ps.name); - await engine.insert('sys_permission_set', { + // [C8 stage 8b] Written beneath the engine when it has a driver to reach: under + // `single` the engine stamps an organization-less platform insert with the + // Default Organization (ADR-0131 D9), and these rows ARE the organization-less + // residue an earlier release left. An engine double without a driver keeps + // the engine write, where nothing stamps it. + const driver = (engine as { getDriver?: (object: string) => any }).getDriver?.('sys_permission_set'); + const write = (row: Record) => + driver ? driver.create('sys_permission_set', row) : engine.insert('sys_permission_set', row, { context: SYS }); + await write({ id, name: ps.name, label: ps.label ?? ps.name, @@ -54,7 +62,7 @@ export async function seedLegacyPlatformBucket( admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, active: true, managed_by: 'platform', - }, { context: SYS }); + }); ids[ps.name] = id; } return ids; diff --git a/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts b/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts index 49d3ac4d542..1d22d573dc4 100644 --- a/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts +++ b/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts @@ -232,8 +232,15 @@ describe('[#8192/#3653] sys_scim_connection_credential.token_digest is a keyed o const manager = makeManager(engine); const send = (request: Request) => manager.handleRequest(request); + // [C8 stage 8b] It carries an organization: under `single` an organization-less + + // system insert is refused while the install holds none, and this rig holds none. + + // The verifier reads the digest alone, so the organization changes nothing it answers. + const { token } = await mintScimConnectionCredential(engine as never, SECRET, { connectionId: 'okta-probe', + organizationId: 'org_scim_8b', }); const ok = await send( @@ -267,8 +274,12 @@ describe('[#8192/#3653] sys_scim_connection_credential.token_digest is a keyed o const send = (request: Request) => manager.handleRequest(request); // Revoked: minted, then switched inactive — refused. + // [C8 stage 8b] It carries an organization: under `single` an organization-less + // system insert is refused while the install holds none, and this rig holds none. + // The verifier reads the digest alone, so the organization changes nothing it answers. const revoked = await mintScimConnectionCredential(engine as never, SECRET, { connectionId: 'okta-revoked', + organizationId: 'org_scim_8b', }); await (engine as unknown as { update(o: string, data: Record): Promise; @@ -283,8 +294,12 @@ describe('[#8192/#3653] sys_scim_connection_credential.token_digest is a keyed o expect(deniedRevoked.status).toBe(401); // Expired: minted with a past expiry — refused. + // [C8 stage 8b] It carries an organization: under `single` an organization-less + // system insert is refused while the install holds none, and this rig holds none. + // The verifier reads the digest alone, so the organization changes nothing it answers. const expired = await mintScimConnectionCredential(engine as never, SECRET, { connectionId: 'okta-expired', + organizationId: 'org_scim_8b', expiresAt: new Date(Date.now() - 60_000), }); const deniedExpired = await send( diff --git a/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts b/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts index 53118354f17..5c8d48ce2b5 100644 --- a/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts +++ b/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts @@ -257,10 +257,12 @@ for (const cell of CELLS) { // below will meet in the DATABASE — not in any check of ours. await engine.insert('sys_user', { id: 'usr_holder', email: 'holder@corp.example', name: S }, SYSTEM); await driver.getKnex().raw(`create unique index sys_user_name_forced_unique on sys_user (${cell.nameKey})`); - await engine.insert( + // [C8 stage 8b] A legacy organization-less catalogue row, landed beneath the + // engine: under `single` the engine refuses an organization-less platform + // insert while the install holds no organization (ADR-0131 D9). + await (engine as any).getDriver('sys_permission_set').create( 'sys_permission_set', { id: 'ps_member_default', name: 'member_default', label: 'member_default', active: true }, - SYSTEM, ); for (const email of ['first@corp.example', 'second@corp.example']) { await engine.insert( diff --git a/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts b/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts index 034f8adb2ee..399526c8a71 100644 --- a/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts +++ b/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts @@ -182,7 +182,9 @@ describe('SCIM bearer verification — the credential probe carries the system o const { engine, seen } = await bootEngine(); await engine.insert( SCIM_CREDENTIAL_OBJECT, - { connection_id: 'conn-pin', token_digest: digestScimBearerToken(SECRET, TOKEN), active: true }, + // [C8 stage 8b] The row carries an organization: under `single` an organization-less + // system insert is refused while the install holds none, and this rig holds none. + { connection_id: 'conn-pin', token_digest: digestScimBearerToken(SECRET, TOKEN), active: true, organization_id: 'org_scim_8b' }, SYSTEM, ); seen.length = 0; diff --git a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts index 0fbcf875db0..2c163359999 100644 --- a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts +++ b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts @@ -210,8 +210,15 @@ async function boot(): Promise { plugins: { scim: true, organization: true }, } as never); + // [C8 stage 8b] It carries an organization: under `single` an organization-less + + // system insert is refused while the install holds none, and this rig holds none. + + // The verifier reads the digest alone, so the organization changes nothing it answers. + const { token } = await mintScimConnectionCredential(engine as never, SECRET, { connectionId: 'okta-14360', + organizationId: 'org_scim_8b', }); return { engine, manager, token, send: (request) => manager.handleRequest(request) }; @@ -400,13 +407,18 @@ async function makePlatformAdmin(h: Harness, userId: string): Promise { { where: { id: PS_ADMIN }, fields: ['id'] }, SYSTEM, ); + // [C8 stage 8b] The legacy organization-less catalogue row and the unscoped + // platform-admin grant go in beneath the engine: under `single` the engine + // refuses an organization-less platform insert while the install holds no + // organization (ADR-0131 D9), and the engine pool keeps none. + const residue = (object: string, row: Record) => + (h.engine as any).getDriver(object).create(object, row); if (!existing) { - await h.engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }, SYSTEM); + await residue('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); } - await h.engine.insert( + await residue( 'sys_user_permission_set', { id: `ups_${userId}`, user_id: userId, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS }, - SYSTEM, ); } diff --git a/packages/plugins/plugin-auth/src/scim-transaction-scope.test.ts b/packages/plugins/plugin-auth/src/scim-transaction-scope.test.ts index ee68073bd91..6587c608fc9 100644 --- a/packages/plugins/plugin-auth/src/scim-transaction-scope.test.ts +++ b/packages/plugins/plugin-auth/src/scim-transaction-scope.test.ts @@ -129,8 +129,15 @@ async function boot(): Promise { plugins: { scim: true, organization: true }, } as never); + // [C8 stage 8b] It carries an organization: under `single` an organization-less + + // system insert is refused while the install holds none, and this rig holds none. + + // The verifier reads the digest alone, so the organization changes nothing it answers. + const { token } = await mintScimConnectionCredential(engine as never, SECRET, { connectionId: 'okta-14522', + organizationId: 'org_scim_8b', }); return { engine, driver, manager, token, send: (request) => manager.handleRequest(request) }; diff --git a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts index c71a42d90f7..514040ce296 100644 --- a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts @@ -171,10 +171,12 @@ async function seedPopulation(engine: ObjectQL): Promise { ] as const) { await engine.insert('sys_user', { id, email, name: id }, SYSTEM); } - await engine.insert( + // [C8 stage 8b] A legacy organization-less catalogue row, landed beneath the engine: + // under `single` the engine refuses an organization-less platform insert while the + // install holds no organization (ADR-0131 D9), and this rig holds none. + await (engine as any).getDriver('sys_permission_set').create( 'sys_permission_set', { id: 'ps_member_default', name: 'member_default', label: 'member_default', active: true }, - SYSTEM, ); } diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts index 9c39d82ef65..46b4de6031e 100644 --- a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -251,22 +251,19 @@ async function createOrg( /** Grant platform admin the ADR-0068 D2 way: an ORG-LESS `admin_full_access` link. */ async function grantPlatformAdmin(engine: ObjectQL, userId: string): Promise { - await engine.insert( - 'sys_permission_set', - { id: 'ps_admin_full_access', name: ADMIN_FULL_ACCESS }, - SYSTEM as never, - ); - await engine.insert( - 'sys_user_permission_set', - { - id: 'ups_platform_admin', - user_id: userId, - permission_set_id: 'ps_admin_full_access', - permission_set: 'admin_full_access', - organization_id: null, - }, - SYSTEM as never, - ); + // [C8 stage 8b] The legacy organization-less catalogue row and grant, landed + // beneath the engine: under `single` the engine refuses an organization-less + // platform insert while the install holds no organization (ADR-0131 D9). + const residue = (object: string, row: Record) => + (engine as any).getDriver(object).create(object, row); + await residue('sys_permission_set', { id: 'ps_admin_full_access', name: ADMIN_FULL_ACCESS }); + await residue('sys_user_permission_set', { + id: 'ups_platform_admin', + user_id: userId, + permission_set_id: 'ps_admin_full_access', + permission_set: 'admin_full_access', + organization_id: null, + }); } async function userIdOf(engine: ObjectQL, email: string): Promise { diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts index 8e37c51a1ca..3f7b5da4f06 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts @@ -226,7 +226,7 @@ describe('position-bound permission sets reach /auth/me/permissions (#6334)', () const app = mount({ tables: baseTables({ sys_user_permission_set: [ - { id: 'ups1', user_id: USER, permission_set_id: 'ps_direct', permission_set: 'showcase_direct', organization_id: null }, + { id: 'ups1', user_id: USER, permission_set_id: 'ps_direct', permission_set: 'showcase_direct', organization_id: ACTIVE_ORG }, ], }), }); diff --git a/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts index f73aed75f95..bf1fe8cf5ec 100644 --- a/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts +++ b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts @@ -38,7 +38,15 @@ export async function seedLegacyPlatformBucket( continue; } const id = legacyBucketRowId(ps.name); - await engine.insert('sys_permission_set', { + // [C8 stage 8b] Written beneath the engine when it has a driver to reach: under + // `single` the engine stamps an organization-less platform insert with the + // Default Organization (ADR-0131 D9), and these rows ARE the organization-less + // residue an earlier release left. An engine double without a driver keeps + // the engine write, where nothing stamps it. + const driver = (engine as { getDriver?: (object: string) => any }).getDriver?.('sys_permission_set'); + const write = (row: Record) => + driver ? driver.create('sys_permission_set', row) : engine.insert('sys_permission_set', row, { context: SYS }); + await write({ id, name: ps.name, label: ps.label ?? ps.name, @@ -51,7 +59,7 @@ export async function seedLegacyPlatformBucket( admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, active: true, managed_by: 'platform', - }, { context: SYS }); + }); ids[ps.name] = id; } return ids; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index 3f2a5a42558..cbf1a3b10b9 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -148,11 +148,14 @@ type NaturalOrder = (typeof NATURAL_ORDERS)[number]; * query is forwarded verbatim and the RESULT is returned untouched. The facade * never sorts, so everything the fix relies on is done by the real SQL engine. */ +/** [C8 stage 8b] The real engine behind a {@link withNaturalOrder} facade, for landing residue. */ +const ENGINE_OF = new WeakMap(); + function withNaturalOrder(engine: ObjectQL, order: NaturalOrder): any { const insertionRank = new Map(); let nextRank = 0; const rankKey = (object: string, id: unknown) => `${object}:${String(id)}`; - return { + const facade = { async find(object: string, query: any, options: any) { const rows = await (engine as any).find(object, query, options); if (!Array.isArray(rows)) return rows; @@ -178,6 +181,8 @@ function withNaturalOrder(engine: ObjectQL, order: NaturalOrder): any { return (engine as any).update(object, data, options); }, }; + ENGINE_OF.set(facade, engine); + return facade; } // ─────────────────────────────────────────────────────────────────────────── @@ -236,16 +241,18 @@ async function seedTenant( // The founder — this deployment's platform admin, and the row the guard has // to find. Inserted FIRST so the insertion-order family puts it in row 1. await seedUser(ql, 'usr_founder', 'founder@tenant.example', '2026-01-01T00:00:00.000Z', true); - await ql.insert( - 'sys_user_permission_set', - { - id: 'ups_zzz_founder', - user_id: 'usr_founder', - permission_set: 'admin_full_access', - organization_id: unscopedOrganizationId, - }, - { context: SYSTEM_CTX }, - ); + // [C8 stage 8b] The unscoped grant is legacy residue, landed straight through + // the driver: under `single` the engine stamps an organization-less platform + // insert with the Default Organization (ADR-0131 D9), which is where 8b-0a + // already puts a new promotion's grant. An unranked row sorts first in the + // insertion-order family, as it did when it was inserted first. + const real: any = ENGINE_OF.get(ql) ?? ql; + await real.getDriver('sys_user_permission_set').create('sys_user_permission_set', { + id: 'ups_zzz_founder', + user_id: 'usr_founder', + permission_set: 'admin_full_access', + organization_id: unscopedOrganizationId, + }); for (let i = 1; i <= orgGrants; i++) { const n = String(i).padStart(3, '0'); diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 4ec9e9c6937..52b7909c936 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -344,7 +344,13 @@ async function boot(scenario: Scenario): Promise { const organizationId = scenario.posture === 'isolated' ? ORG : undefined; const context = organizationId ? { isSystem: true, tenantId: organizationId } : SYS; for (const row of LEGACY_ROWS) { - await insert('sys_position', { id: `pos_${row.name}`, ...row, active: true, is_default: false }, { context } as any); + const stored = { id: `pos_${row.name}`, ...row, active: true, is_default: false }; + // [C8 stage 8b] The organization-less rows go in beneath the engine: under + // `single` the engine stamps such a platform insert with the Default + // Organization (ADR-0131 D9), and these rows ARE the organization-less + // residue an earlier release's seeder left. + if (organizationId) await insert('sys_position', stored, { context } as any); + else await (engine as any).getDriver('sys_position').create('sys_position', { ...stored, organization_id: null }); } } @@ -523,8 +529,12 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { expect(held.some((h) => h.startsWith('usr_orgadmin -> organization_admin'))).toBe(true); } // Positive control: the census sees a row — one insert reads back as one. + // [C8 stage 8b] Under `single` the organization-less system insert takes + // the sole organization (ADR-0131 D9); under the wall it stays unstamped, + // the exemption 18.0 keeps there. await engine.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, { context: SYS } as any); - expect(await permissionSetCensus(engine)).toEqual(['zz_census_control | -']); + const stamped = scenario.posture === 'single' ? (scenario.organizationAtBoot ? ORG : 'org_default') : '-'; + expect(await permissionSetCensus(engine)).toEqual([`zz_census_control | ${stamped}`]); await engine.delete('sys_permission_set', { where: { id: 'ps_census_control' }, context: SYS } as any); expect(await permissionSetCensus(engine)).toEqual([]); }); diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index 008f5abb8a0..c2c619a3fef 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -199,7 +199,11 @@ const orgCtx = (organizationId: string | null) => async function insertRows(b: Booted, object: string, rows: Array>): Promise { for (const row of rows) { const organizationId = (row.organization_id as string | undefined) ?? null; - await b.engine.insert(object, row, { context: orgCtx(organizationId) } as any); + // [C8 stage 8b] An organization-less row is legacy residue, landed straight + // through the driver: under `single` the engine stamps or refuses such a + // platform insert (ADR-0131 D9), and this rig holds two organizations. + if (organizationId === null) await (b.engine as any).getDriver(object).create(object, row); + else await b.engine.insert(object, row, { context: orgCtx(organizationId) } as any); } } diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts index d9fc5917528..0db87c56aab 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts @@ -232,10 +232,17 @@ beforeAll(async () => { ], SYS); await engine.insert('sys_organization', [{ id: ORG, name: 'CBC', slug: 'cbc' }], SYS); await engine.insert('sys_member', [{ id: 'mem_delegator', user_id: DELEGATOR_MEMBER, organization_id: ORG, role: 'member' }], SYS); - await engine.insert('sys_user_position', [ + // [C8 stage 8b] Organization-less position rows are landed straight through the + // driver, as the residue an older release left: under `single` the engine now + // stamps such a system insert with the one organization (ADR-0131 D9), which + // would tie DELEGATOR_UNBOUND's position to an organization it is not a member of. + const positions = (engine as any).getDriver('sys_user_position'); + for (const row of [ { id: 'up_unbound', user_id: DELEGATOR_UNBOUND, position: MANAGER_SET.name }, { id: 'up_member', user_id: DELEGATOR_MEMBER, position: MANAGER_SET.name }, - ], SYS); + ]) { + await positions.create('sys_user_position', row); + } let security: unknown; let sharing: SharingService | undefined; diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index a165eb05ce6..5d2ce28d622 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -169,22 +169,27 @@ beforeAll(async () => { for (const id of [MEMBER, ADMIN.userId, BLIND_ADMIN.userId, DELEGATE.userId, REFUSED_ADMIN.userId, REFUSED_DELEGATE.userId, EDIT_ONLY_USER]) { await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); } + // [C8 stage 8b] The grant-side rows below carry no organization: under `single` + // the engine stamps or refuses such a platform insert (ADR-0131 D9), and this rig + // holds no organization, so they are landed straight through the driver, as the + // residue an older release left. The explainer reads them as before. + const residue = (o: string, row: Record) => (engine as any).getDriver(o).create(o, row); // The explained member's grant, as the explainer resolves it: a grant row // pointing at a catalogue row; what the set confers is declared in metadata. - await e.insert('sys_permission_set', { + await residue('sys_permission_set', { id: 'ps_ag_member', name: MEMBER_SET.name, label: MEMBER_SET.name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), - }, SYS); - await e.insert('sys_user_permission_set', { + }); + await residue('sys_user_permission_set', { id: 'ups_ag_member', user_id: MEMBER, permission_set: MEMBER_SET.name, - }, SYS); - await e.insert('sys_permission_set', { + }); + await residue('sys_permission_set', { id: 'ps_ag_edit_only', name: EDIT_ONLY_SET.name, label: EDIT_ONLY_SET.name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), - }, SYS); - await e.insert('sys_user_permission_set', { + }); + await residue('sys_user_permission_set', { id: 'ups_ag_edit_only', user_id: EDIT_ONLY_USER, permission_set: EDIT_ONLY_SET.name, - }, SYS); + }); await e.insert(BOARD, [ { id: HIDDEN, name: 'hidden', owner_id: 'usr_ag_owner' }, { id: OPEN, name: 'open', owner_id: 'usr_ag_owner' }, diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 4c03c0c9ff0..92c13a3a1f5 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -437,11 +437,19 @@ async function bootPrincipal(source: PostureSource) { await e.insert('sys_member', { id: `mem_admin_${n}`, user_id: USER_ADMIN, organization_id: ALPHA, role: 'member' }, SYS); await e.insert('sys_member', { id: `mem_member_${n}`, user_id: USER_MEMBER, organization_id: ALPHA, role: 'member' }, SYS); await e.insert('sys_member', { id: `mem_removed_${n}`, user_id: USER_REMOVED, organization_id: BETA, role: 'member' }, SYS); - const catalogue = (id: string, name: string, organization: string | null, objects: Record) => - e.insert('sys_permission_set', { + // [C8 stage 8b] A catalogue row with no organization is landed straight through + // the driver, as the residue an older release left: under `single` the engine + // stamps or refuses an organization-less platform insert (ADR-0131 D9), and this + // rig's engine holds two organizations. + const catalogue = (id: string, name: string, organization: string | null, objects: Record) => { + const row = { id, name, label: name, organization_id: organization, managed_by: 'admin', active: true, object_permissions: JSON.stringify(objects), system_permissions: JSON.stringify([]), - }, SYS); + }; + return organization === null + ? (engine as any).getDriver('sys_permission_set').create('sys_permission_set', row) + : e.insert('sys_permission_set', row, SYS); + }; await catalogue(`ps_user_admin_${n}`, 'qa_user_admin', null, {}); await catalogue(`ps_reader_${n}`, READER_SET, null, {}); await catalogue(`ps_alpha_notes_${n}`, ALPHA_ONLY_SET, ALPHA, { [NOTES]: { allowRead: true } }); diff --git a/packages/plugins/plugin-security/src/explain-principal-person.test.ts b/packages/plugins/plugin-security/src/explain-principal-person.test.ts index 2e1d348e0a4..05d0f396ca3 100644 --- a/packages/plugins/plugin-security/src/explain-principal-person.test.ts +++ b/packages/plugins/plugin-security/src/explain-principal-person.test.ts @@ -129,18 +129,23 @@ beforeAll(async () => { ], SYS); // Grace holds the directory too, so an agent acting for her keeps it under // the D10 intersection; the plain user holds nothing beyond the baseline. - await e.insert('sys_permission_set', { + // [C8 stage 8b] The three grant-side rows below carry no organization: under + // `single` the engine stamps or refuses such a platform insert (ADR-0131 D9), + // and this rig holds no organization, so they are landed straight through the + // driver, as the residue an older release left. The explainer reads them as before. + const residue = (o: string, row: Record) => (engine as any).getDriver(o).create(o, row); + await residue('sys_permission_set', { id: 'ps_pp_directory', name: DIRECTORY_SET.name, label: DIRECTORY_SET.name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), - }, SYS); - await e.insert('sys_user_permission_set', { + }); + await residue('sys_user_permission_set', { id: 'ups_pp_grace', user_id: GRACE, permission_set: DIRECTORY_SET.name, - }, SYS); + }); // A position Lin holds because Grace delegated it, until a date far ahead. - await e.insert('sys_user_position', { + await residue('sys_user_position', { id: 'up_pp_lin_delegated', user_id: LIN, position: DELEGATED_POSITION, delegated_from: GRACE, valid_until: DELEGATED_UNTIL, - }, SYS); + }); await e.insert(BOARD, [{ id: 'b_pp_1', name: 'one' }], SYS); const sets = [MEMBER_DEFAULT, DIRECTORY_SET, MASKED_DIRECTORY_SET, USERS_ONLY_SET]; diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index f88cd400b81..7a62538a884 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -146,11 +146,15 @@ async function boot(makeDriver: () => Driver, posture: TenancyPosture) { await e.insert('sys_member', { id: `mem_removed_${n}`, user_id: USER_REMOVED, organization_id: BETA, role: 'member' }, SYS); // The catalogue rows the grant rows point at (the resolver maps id → name); // what each set CONFERS is declared in metadata below. + // [C8 stage 8b] They carry no organization, so they are landed straight + // through the driver, as the residue an older release left: under `single` + // the engine stamps or refuses such a platform insert (ADR-0131 D9), and this + // rig's engine holds two organizations. const catalogue = async (id: string, name: string) => - e.insert('sys_permission_set', { + (engine as any).getDriver('sys_permission_set').create('sys_permission_set', { id, name, label: name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), - }, SYS); + }); await catalogue(`ps_user_admin_${n}`, 'qa_user_admin'); await catalogue(`ps_probe_${n}`, PROBE_SET); // Grant rows, each scoped to org_alpha. Removing a member deletes their diff --git a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts index fb4faaa6c8c..a55e9bb688a 100644 --- a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts +++ b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts @@ -127,8 +127,10 @@ async function boot(opts: { security: boolean } = { security: true }): Promise ({ id, name: id })), SYS); await engine.insert('sys_business_unit', [ { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index ac067559b48..b2eb89e8a98 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -95,8 +95,21 @@ afterEach(async () => { } }); +/** + * [C8 stage 8b] An organization-less GLOBAL row is legacy residue: under + * `single` the engine stamps or refuses an organization-less platform insert + * (ADR-0131 D9), so a row with `organization_id` NULL is landed straight + * through the driver, as an older release left it. The readers this file pins + * still read it until C8's NULL-reader stages (18.x). + */ +function write(e: any, object: string, row: Record) { + return row.organization_id === null + ? e.getDriver(object).create(object, row) + : e.insert(object, row, SYS); +} + async function insertSet(e: any, id: string, name: string, organizationId: string | null, caps: string[]) { - await e.insert('sys_permission_set', { + await write(e, 'sys_permission_set', { id, name, label: `${organizationId ?? 'global'} ${name}`, @@ -105,7 +118,7 @@ async function insertSet(e: any, id: string, name: string, organizationId: strin active: true, object_permissions: JSON.stringify({ probe_ledger: FULL }), system_permissions: JSON.stringify(caps), - }, SYS); + }); } async function boot(): Promise { @@ -145,9 +158,9 @@ async function boot(): Promise { // USER_GLOBAL's GLOBAL grants — what an organization-less principal really holds: // (1) a global position assignment, folded onto a global same-named set; - await e.insert('sys_user_position', { + await write(e, 'sys_user_position', { id: 'up_global_ops_lead', user_id: USER_GLOBAL, position: 'ops_lead', organization_id: null, - }, SYS); + }); await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.global_ops_lead']); // (2) a global user grant to a global set, beside ORG_OTHER's same-named // copy. The copy's id SORTS FIRST (`ps_0_…` before `ps_1_…`) and the @@ -157,10 +170,10 @@ async function boot(): Promise { // load-bearing. await insertSet(e, 'ps_0_other_platform_ops', 'platform_ops', ORG_OTHER, ['probe.other_platform_ops']); await insertSet(e, 'ps_1_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']); - await e.insert('sys_user_permission_set', { + await write(e, 'sys_user_permission_set', { id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set: 'platform_ops', organization_id: null, - }, SYS); + }); // [ADR-0131 D3/D4] The two GLOBAL sets are declared in the catalog, which is // what makes a grant of them confer anything; the sets ORG_OTHER authored are // rows only, with no definition (#15196 Q3 A). diff --git a/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts b/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts index 99443c8ba74..a8ff3270d4f 100644 --- a/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts @@ -113,18 +113,27 @@ async function boot(opts: { posture: Posture; sets?: PermissionSet[]; ledger?: R type: 'plugin', scope: 'system', objects: [ - { name: 'sys_organization', label: 'Organization', fields: { name: { name: 'name', type: 'text' } } }, + // [C8 stage 8b] better-auth owns the table (`sys-organization.object.ts`): no + // injected organization column, so an organization's own insert is not judged + // as an organization-less platform write. + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { name: 'name', type: 'text' } } }, opts.ledger ?? LEDGER_NOW, ], } as never); await engine.syncSchemas(); await engine.insert('sys_organization', [{ id: 'org_a', name: 'A' }, ...(single ? [] : [{ id: 'org_b', name: 'B' }])], SYS); const before = opts.ledger === LEDGER_BEFORE; - await engine.insert(LEDGER, [ + const ledgerRows = [ { id: 'a1', action: 'update', tenant_id: 'org_a', ...(before ? { organization_id: 'org_a' } : {}) }, ...(single ? [] : [{ id: 'b1', action: 'update', tenant_id: 'org_b', ...(before ? { organization_id: 'org_b' } : {}) }]), { id: 'd1', action: 'platform_admin_standing_change', tenant_id: null }, - ], SYS); + ]; + // [C8 stage 8b] The pre-change ledger carried an organization column, and its + // deployment-level row none: that row is residue, landed beneath the engine, + // which now stamps or refuses an organization-less platform insert under + // `single` (ADR-0131 D9). The current ledger has no such column. + if (before) for (const row of ledgerRows) await (engine as any).getDriver(LEDGER).create(LEDGER, row); + else await engine.insert(LEDGER, ledgerRows, SYS); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts index b460b041219..cbd14916bac 100644 --- a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts @@ -115,8 +115,11 @@ describe('[#21388] an org peer is withheld the activity rows of a colleague’s } const activitySet = await catalogPermissionSet(ql, activityReadSet.name); expect(activitySet, 'fixture permission set served by the security catalog').toBeTruthy(); + // [C8 stage 8b] The grant carries the fixture organization the sessions are + // active in: this `single` install now holds two organizations, so an + // organization-less system insert is refused as ambiguous (ADR-0131 D9). for (const who of ['member', 'admin']) { - await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name }, { context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name, organization_id: orgId }, { context: SYS }); } // The colleague signs in twice through the auth door (two sign-in stamps), diff --git a/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts index dc54f9843ba..2065220aeef 100644 --- a/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts @@ -52,6 +52,15 @@ interface Engine { update(object: string, data: Record, options?: unknown): Promise; } +/** + * [C8 stage 8b] The organization a `single` session is active in: the Default + * Organization, which an organization-less system insert of a grant or a + * position is now stamped with (ADR-0131 D9). Resolving with no organization + * would read the global arm only, which no fresh `single` install writes any more. + */ +const sessionOrgOf = async (s: VerifyStack): Promise => + (await (await s.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()) ?? undefined; + describe('the catalog activation door, on a booted showcase', () => { let stack: VerifyStack; let token: string; @@ -86,7 +95,8 @@ describe('the catalog activation door, on a booted showcase', () => { it('the resolver over the booted engine stops granting through a switched-off position, and grants again once it is back on', async () => { // Its own holder, so the leg depends on nothing a boot seeds later: a - // signed-up member, assigned the `exec` position unscoped. + // signed-up member, assigned the `exec` position by a system write, which + // under `single` takes the Default Organization (C8 stage 8b). await stack.signUp(HOLDER_EMAIL); const [user] = await ql.find('sys_user', { where: { email: HOLDER_EMAIL }, limit: 1, context: SYSTEM_CTX }); expect(user?.id, `no sys_user row for ${HOLDER_EMAIL}`).toBeTruthy(); @@ -99,20 +109,20 @@ describe('the catalog activation door, on a booted showcase', () => { // CONTROL: before the flip the position grants its set — without it the // two assertions below would pass on a holder that was never granted. - const before = await resolveUserAuthzGrants(ql as never, holder, {}); + const before = await resolveUserAuthzGrants(ql as never, holder, { tenantId: await sessionOrgOf(stack) }); expect(before.positions).toContain(HELD_POSITION); expect(before.permissions).toContain(HELD_POSITION_SET); const offRes = await flip('position', HELD_POSITION, { enabled: false }); expect(offRes.status, await offRes.clone().text()).toBe(200); - const off = await resolveUserAuthzGrants(ql as never, holder, {}); + const off = await resolveUserAuthzGrants(ql as never, holder, { tenantId: await sessionOrgOf(stack) }); expect(off.positions).not.toContain(HELD_POSITION); expect(off.permissions).not.toContain(HELD_POSITION_SET); const onRes = await flip('position', HELD_POSITION, { enabled: true }); expect(onRes.status, await onRes.clone().text()).toBe(200); expect(await rowsOf('position', HELD_POSITION)).toHaveLength(1); - const on = await resolveUserAuthzGrants(ql as never, holder, {}); + const on = await resolveUserAuthzGrants(ql as never, holder, { tenantId: await sessionOrgOf(stack) }); expect(on.positions).toContain(HELD_POSITION); expect(on.permissions).toContain(HELD_POSITION_SET); }); @@ -211,7 +221,7 @@ describe('the catalog activation door, on a booted showcase', () => { ); // CONTROL: before any flip the position grants its set. - const before = await resolveUserAuthzGrants(ql as never, holder, {}); + const before = await resolveUserAuthzGrants(ql as never, holder, { tenantId: await sessionOrgOf(stack) }); expect(before.positions).toContain(ENV_POSITION); expect(before.permissions).toContain(ENV_SET); @@ -221,7 +231,7 @@ describe('the catalog activation door, on a booted showcase', () => { expect(positionRows).toHaveLength(1); expect(positionRows[0].active === false || positionRows[0].active === 0).toBe(true); expect(positionRows[0].package_id ?? null).toBeNull(); - const positionSwitchedOff = await resolveUserAuthzGrants(ql as never, holder, {}); + const positionSwitchedOff = await resolveUserAuthzGrants(ql as never, holder, { tenantId: await sessionOrgOf(stack) }); expect(positionSwitchedOff.positions).not.toContain(ENV_POSITION); expect(positionSwitchedOff.permissions).not.toContain(ENV_SET); @@ -234,13 +244,13 @@ describe('the catalog activation door, on a booted showcase', () => { const setRows = await rowsOf('permission', ENV_SET); expect(setRows).toHaveLength(1); expect(setRows[0].package_id ?? null).toBeNull(); - const setSwitchedOff = await resolveUserAuthzGrants(ql as never, holder, {}); + const setSwitchedOff = await resolveUserAuthzGrants(ql as never, holder, { tenantId: await sessionOrgOf(stack) }); expect(setSwitchedOff.positions).toContain(ENV_POSITION); expect(setSwitchedOff.permissions).not.toContain(ENV_SET); const setOn = await flip('permission', ENV_SET, { enabled: true }); expect(setOn.status, await setOn.clone().text()).toBe(200); - const after = await resolveUserAuthzGrants(ql as never, holder, {}); + const after = await resolveUserAuthzGrants(ql as never, holder, { tenantId: await sessionOrgOf(stack) }); expect(after.positions).toContain(ENV_POSITION); expect(after.permissions).toContain(ENV_SET); }); diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index d436351cdb0..ac4ce79fe7e 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -152,8 +152,11 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a const activitySet = await catalogPermissionSet(ql, activityReadSet.name); expect(activitySet, 'fixture permission set served by the security catalog').toBeTruthy(); + // [C8 stage 8b] The grant carries the fixture organization the sessions are + // active in: this `single` install now holds two organizations, so an + // organization-less system insert is refused as ambiguous (ADR-0131 D9). for (const who of ['member', 'owner', 'admin']) { - await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name }, { context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name, organization_id: orgId }, { context: SYS }); } // The colleague's group values, written as the system in two steps so the diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index 979c594a2f7..d900b833f2e 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -134,7 +134,14 @@ async function boot(inside: boolean, email: string): Promise { for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); + // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no + // active organization) must still hold: under `single` the engine now stamps an + // organization-less system insert with the Default Organization (ADR-0131 D9), + // so the global row is landed beneath the engine, as the residue an older + // release left. The readers still apply it until C8's NULL-reader stages. + await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, + }); } if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index e4c241c17fd..bd31f07da3c 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -42,6 +42,15 @@ interface Engine { getObject?(object: string): unknown; } +/** + * [C8 stage 8b] The organization a `single` session is active in: the Default + * Organization, which an organization-less system insert of a grant or a + * position is now stamped with (ADR-0131 D9). Resolving with no organization + * would read the global arm only, which no fresh `single` install writes any more. + */ +const sessionOrgOf = async (s: VerifyStack): Promise => + (await (await s.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()) ?? undefined; + describe('the permission-set clone door, on a booted showcase', () => { let stack: VerifyStack; let token: string; @@ -117,7 +126,7 @@ describe('the permission-set clone door, on a booted showcase', () => { { id: 'usp_clone_door_holder', user_id: String(user.id), position: COPY_POSITION }, { context: SYSTEM_CTX }, ); - const grants = await resolveUserAuthzGrants(ql as never, String(user.id), {}); + const grants = await resolveUserAuthzGrants(ql as never, String(user.id), { tenantId: await sessionOrgOf(stack) }); expect(grants.positions).toContain(COPY_POSITION); expect(grants.permissions).toContain(DEFAULT_COPY); }); diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 45004861493..1c30c648be9 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -157,7 +157,14 @@ async function boot(inside: boolean, email: string): Promise { for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); + // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no + // active organization) must still hold: under `single` the engine now stamps an + // organization-less system insert with the Default Organization (ADR-0131 D9), + // so the global row is landed beneath the engine, as the residue an older + // release left. The readers still apply it until C8's NULL-reader stages. + await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, + }); } if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, userId, token }; diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index e353973f854..18fc74ea469 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -161,9 +161,13 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // with no active organization only global grants apply, so this is the one // spelling under which it still carries `manage_sharing` and still reaches // `adminOrgScope`. - await ql.insert('sys_user_permission_set', { - user_id: orgLessUserId, permission_set: 'sharing_admin_8158', organization_id: null, - }, { context: SYS }); + // [C8 stage 8b] A global grant is legacy residue in 18.0: the engine now stamps + // or refuses an organization-less system insert under `single` (ADR-0131 D9), + // so it is landed beneath the engine. The readers still apply it until C8's + // NULL-reader stages. + const globalGrant = (row: Record) => + ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { ...row, organization_id: null }); + await globalGrant({ id: 'ups_8158_org_less', user_id: orgLessUserId, permission_set: 'sharing_admin_8158' }); // The #8158 grant as filed — scoped to tenant A — for the control (which // has tenant A active) and for the org-scoped persona (which has none). for (const userId of [orgBoundUserId, orgScopedOrgLessUserId]) { @@ -190,9 +194,7 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r await stack.signUp(OPERATOR_EMAIL, PASSWORD); const adminSet = await catalogPermissionSet(ql, 'admin_full_access'); expect(adminSet, 'admin_full_access is served by the security catalog').toBeTruthy(); - await ql.insert('sys_user_permission_set', { - user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, organization_id: null, - }, { context: SYS }); + await globalGrant({ id: 'ups_8158_operator', user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name }); // Sign in AFTER the grants, so every session is minted by the same path // with the membership state above already in place. The org-less personas diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index 09b305c5da3..63cbebdfb87 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -125,9 +125,14 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('explain() reports the vama_bypass layer with the auditor set as contributor', async () => { const security: any = stack.kernel.getService('security'); expect(security?.explain, 'security service exposes explain()').toBeTruthy(); + // [C8 stage 8b] Asked in the organization a `single` session carries: the + // auditor's grant is an organization-less system insert, which now takes the + // Default Organization (ADR-0131 D9), and explain resolves the principal in + // the caller's organization, with none reading only the global arm. + const tenancy = await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy'); const decision = await security.explain( { object: 'showcase_private_note', operation: 'read', userId: auditorId }, - { isSystem: true }, + { isSystem: true, tenantId: (await tenancy.defaultOrgId()) ?? undefined }, ); expect(decision?.allowed).toBe(true); const vama = (decision?.layers ?? []).find((l: any) => l.layer === 'vama_bypass'); diff --git a/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts index 52d006c86d3..eb598db0259 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts @@ -107,9 +107,13 @@ async function bootFallbackWorld(withResolver = true): Promise { const id = {} as Record; for (const who of WHO) id[who] = await uid(who); - let org = await ql.findOne('sys_organization', { where: {}, context: { isSystem: true } }).catch(() => null); - let orgId = org?.id; - if (!orgId) { orgId = 'org_fb'; await sys('sys_organization', { id: orgId, name: 'FB Org', slug: 'fb' }).catch(() => {}); } + // [ADR-0131 D3/D9, C8 stage 8b] The showcase boots `single`, so the Default + // Organization exists before the first sign-up and owns every row this fixture + // seeds — asked of the `tenancy` service. (An empty-`where` `findOne` is refused + // as an arbitrary pick; the refusal was swallowed and a second organization + // minted, which under `single` makes every organization-less insert ambiguous.) + const orgId = (await (await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()); + if (!orgId) throw new Error('fixture: the showcase boot has no Default Organization to own the business units'); await sys('sys_business_unit', { id: 'bu_parent_fb', name: 'Parent', kind: 'division', organization_id: orgId, active: true }); await sys('sys_business_unit', { id: 'bu_child_fb', name: 'Child', kind: 'department', parent_business_unit_id: 'bu_parent_fb', organization_id: orgId, active: true }); diff --git a/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts index 768df95c32c..c61188aa281 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts @@ -99,8 +99,13 @@ async function bootWriteWorld(tag: string, opts: { writeScope?: 'unit'; withReso expect(id[who], `${who} resolved`).toBeTruthy(); } - let orgId = (await ql.findOne('sys_organization', { where: {}, context: SYS }).catch(() => null))?.id; - if (!orgId) { orgId = `org_w_${tag}`; await sys('sys_organization', { id: orgId, name: 'WScope Org', slug: `wscope_${tag}` }).catch(() => {}); } + // [ADR-0131 D3/D9, C8 stage 8b] The showcase boots `single`, so the Default + // Organization exists before the first sign-up and owns every row this fixture + // seeds — asked of the `tenancy` service. (An empty-`where` `findOne` is refused + // as an arbitrary pick; the refusal was swallowed and a second organization + // minted, which under `single` makes every organization-less insert ambiguous.) + const orgId = (await (await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()); + if (!orgId) throw new Error('fixture: the showcase boot has no Default Organization to own the business units'); await sys('sys_business_unit', { id: `bu_wparent_${tag}`, name: 'Parent', kind: 'division', organization_id: orgId, active: true }); await sys('sys_business_unit', { id: `bu_wchild_${tag}`, name: 'Child', kind: 'department', parent_business_unit_id: `bu_wparent_${tag}`, organization_id: orgId, active: true }); diff --git a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts index 86fc566e7cb..e3b1d48b539 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts @@ -126,10 +126,13 @@ async function bootScopeWorld(scope: 'unit' | 'unit_and_below' | 'own_and_report const id = {} as Record; for (const who of WHO) id[who] = await uid(who); - // org (BU.organization_id is required) — reuse any existing, else create one. - let org = await ql.findOne('sys_organization', { where: {}, context: { isSystem: true } }).catch(() => null); - let orgId = org?.id; - if (!orgId) { orgId = 'org_scope'; await sys('sys_organization', { id: orgId, name: 'Scope Org', slug: `scope_${scope}` }).catch(() => {}); } + // [ADR-0131 D3/D9, C8 stage 8b] The showcase boots `single`, so the Default + // Organization exists before the first sign-up and owns every row this fixture + // seeds — asked of the `tenancy` service. (An empty-`where` `findOne` is refused + // as an arbitrary pick; the refusal was swallowed and a second organization + // minted, which under `single` makes every organization-less insert ambiguous.) + const orgId = (await (await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()); + if (!orgId) throw new Error('fixture: the showcase boot has no Default Organization to own the business units'); const p = `${scope}`; await sys('sys_business_unit', { id: `bu_parent_${p}`, name: 'Parent', kind: 'division', organization_id: orgId, active: true }); diff --git a/packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts b/packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts new file mode 100644 index 00000000000..a603510bcc3 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D9 / D11, C8 stage 8b] Under the `single` posture no platform +// object is exempt from the system-insert organization rule. +// +// Until this stage, `Engine.resolveSystemInsertOrganization` returned before it +// read the posture for every platform object (`sys_` / `cloud_` / `ai_`) with a +// tenant column that the #13491 inventory had not admitted, so a system insert +// that named no organization landed with `organization_id` NULL — under every +// posture. `sys_business_unit_member` was the measured instance (#14570): a +// bare `isSystem` membership insert stored NULL, and a strict organization +// screen then resolved that member to nobody. +// +// This file pins the lift on the real showcase composition, the one the +// operator boots: +// 1. a fresh `single` boot, the seeded admin's sign-in and an invitation +// leave NO organization-less row in any platform object with a tenant +// column — so no boot-time producer writes before the Default +// Organization exists, or writes around it; +// 2. a bare `isSystem` insert into un-admitted platform objects takes the +// Default Organization; +// 3. with a second organization present the same insert is REFUSED, loudly, +// and nothing is written — the refusal half of D9, never a guess. +// +// The walled half stays as it was in 18.0 (epic ruling record 6259899486 on +// #15194, item 4): the unit pins in `@objectstack/objectql` hold that control. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { resolveTenantFieldName } from '@objectstack/objectql'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { isSystem: true } as const; +const PLATFORM_PREFIX = /^(sys_|cloud_|ai_)/; + +describe('showcase (single): a platform object system insert takes the Default Organization or is refused (C8 stage 8b)', () => { + let stack: VerifyStack; + let ql: any; + let orgId: string; + let userId: string; + + beforeAll(async () => { + stack = await bootShowcase(); + const adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const tenancy = await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy'); + const resolved = await tenancy.defaultOrgId(); + if (!resolved) throw new Error('fixture: the showcase boot has no Default Organization'); + orgId = resolved; + // An identity-mail producer and the invitation row, through the public door. + const invite = await stack.apiAs(adminToken, 'POST', '/auth/organization/invite-member', { + email: 'c8-8b-invitee@verify.test', + role: 'member', + }); + expect(invite.status, 'the admin invites a member').toBeLessThan(300); + userId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; + if (!userId) throw new Error('fixture: the seeded admin has no sys_user row'); + }); + + afterAll(async () => { await stack?.stop(); }); + + it('boot, sign-in and an invitation leave no organization-less row in any platform object with a tenant column', async () => { + const offenders: string[] = []; + let inScope = 0; + for (const def of ql.registry.getAllObjects() as Array<{ name: string }>) { + if (!PLATFORM_PREFIX.test(def.name)) continue; + const tenantField = resolveTenantFieldName(ql.registry.getObject(def.name)); + if (!tenantField) continue; + inScope++; + const rows = await ql.find(def.name, { + where: { [tenantField]: null }, + fields: ['id'], + limit: 3, + context: SYS, + }); + if (rows.length > 0) offenders.push(`${def.name} (${rows.length}+)`); + } + // The non-vacuity half: the walk really visited the in-reach population. + expect(inScope).toBeGreaterThan(30); + expect(offenders).toEqual([]); + }); + + it('a bare isSystem insert into an un-admitted platform object takes the Default Organization (#14570)', async () => { + await ql.insert('sys_business_unit', { id: 'bu_c8_8b', name: 'C8 8b', kind: 'department', active: true }, { context: SYS }); + await ql.insert('sys_business_unit_member', { id: 'bm_c8_8b', business_unit_id: 'bu_c8_8b', user_id: userId }, { context: SYS }); + const unit = await ql.findOne('sys_business_unit', { where: { id: 'bu_c8_8b' }, context: SYS }); + const member = await ql.findOne('sys_business_unit_member', { where: { id: 'bm_c8_8b' }, context: SYS }); + expect(unit?.organization_id).toBe(orgId); + expect(member?.organization_id).toBe(orgId); + }); + + it('with a second organization the same insert is REFUSED, loudly, and nothing is written', async () => { + // A unit the user is not yet a member of, so the one-membership-per-unit + // constraint cannot answer before the organization rule does. + await ql.insert('sys_business_unit', { id: 'bu_c8_8b_carried', name: 'C8 8b carried', kind: 'department', active: true, organization_id: orgId }, { context: SYS }); + await ql.insert('sys_organization', { id: 'org_c8_8b_second', name: 'Second', slug: 'c8-8b-second' }, { context: SYS }); + await expect( + ql.insert('sys_business_unit_member', { id: 'bm_c8_8b_refused', business_unit_id: 'bu_c8_8b_carried', user_id: userId }, { context: SYS }), + ).rejects.toMatchObject({ + code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', + status: 500, + reason: 'ambiguous-organization', + }); + expect(await ql.findOne('sys_business_unit_member', { where: { id: 'bm_c8_8b_refused' }, context: SYS })).toBeFalsy(); + // Carrying the organization is the remedy the refusal names, and it lands. + await ql.insert('sys_business_unit_member', { id: 'bm_c8_8b_carried', business_unit_id: 'bu_c8_8b_carried', user_id: userId, organization_id: orgId }, { context: SYS }); + expect((await ql.findOne('sys_business_unit_member', { where: { id: 'bm_c8_8b_carried' }, context: SYS }))?.organization_id).toBe(orgId); + }); +}); diff --git a/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts b/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts index 431a914bffd..8090144b5f9 100644 --- a/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts +++ b/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts @@ -66,9 +66,14 @@ describe('ADR-0057: org-scoped identity creatable single-tenant', () => { expect(denied.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); // ADR-0057's actual regression target — `organization_id` is OPTIONAL at - // the schema level, so a single-tenant (no org row) write does not die - // with VALIDATION_FAILED. System-context writes (org-structure sync, + // the schema level, so a system write that names no organization does not + // die with VALIDATION_FAILED. System-context writes (org-structure sync, // seeding) are the writers that remain legitimate post-ADR-0092. + // + // [ADR-0131 D9 / D11, C8 stage 8b] Under `single` that write is no longer + // left organization-less: it takes the Default Organization, the one owner + // a `single` install has. It used to land NULL, through the platform-object + // exemption 8b lifted under `single`. const ql = await stack.kernel.getServiceAsync('objectql'); const row = await ql.insert( 'sys_team', @@ -76,6 +81,9 @@ describe('ADR-0057: org-scoped identity creatable single-tenant', () => { { context: { isSystem: true } }, ); expect(row?.id).toBeTruthy(); - expect(row?.organization_id ?? null).toBeNull(); + const tenancy = await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy'); + const defaultOrgId = await tenancy.defaultOrgId(); + expect(defaultOrgId).toBeTruthy(); + expect(row?.organization_id).toBe(defaultOrgId); }); }); diff --git a/packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts b/packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts index d573affe5a6..b8b5fbe3a49 100644 --- a/packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts @@ -159,6 +159,15 @@ async function boot(posture: 'isolated' | 'group' | 'single'): Promise return h; } +/** + * [C8 stage 8b] The organization a `single` session is active in: the Default + * Organization, which an organization-less system grant is now stamped with + * (ADR-0131 D9). A precheck that resolves with no organization would read the + * global arm only, which no fresh `single` install writes any more. + */ +const sessionOrgOf = async (h: Harness): Promise => + (await (await h.stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()) ?? undefined; + const grantsOf = (h: Harness, user: string, org: string | undefined) => resolveUserAuthzGrants(h.ql as never, user, org ? { tenantId: org } : {}); @@ -259,10 +268,10 @@ describe('[#23546] single: the narrowing is not a posture branch, and no door ga }); it('guard the guard: the metadata author holds manage_metadata alone; the setup holder holds setup.access alone', async () => { - const author = await grantsOf(h, h.author, undefined); + const author = await grantsOf(h, h.author, await sessionOrgOf(h)); expect([...author.systemPermissions].sort()).toEqual(['manage_metadata']); expect(author.posture).not.toBe('PLATFORM_ADMIN'); - const setup = await grantsOf(h, h.setup, undefined); + const setup = await grantsOf(h, h.setup, await sessionOrgOf(h)); expect([...setup.systemPermissions].sort()).toEqual(['setup.access']); }); diff --git a/packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts index 14017868c4b..bc88ea803a9 100644 --- a/packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts @@ -144,6 +144,15 @@ async function ok(res: Response, status = 200): Promise { expect(res.status, await res.clone().text()).toBe(status); } +/** + * [C8 stage 8b] The organization a `single` session is active in: the Default + * Organization, which an organization-less system grant is now stamped with + * (ADR-0131 D9). A precheck that resolves with no organization would read the + * global arm only, which no fresh `single` install writes any more. + */ +const sessionOrgOf = async (h: Harness): Promise => + (await (await h.stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()) ?? undefined; + for (const posture of ['isolated', 'group'] as const) { describe(`[ADR-0131 D12 items 7–8] ${posture}: every environment-metadata write is the platform operator's`, () => { let h: Harness; @@ -328,7 +337,7 @@ describe('[ADR-0131 D12 items 7–8 · D11] single (CONTROL): the refusal is ine }); it('a `manage_metadata` holder below the operator writes at the /meta (the stored rewrite included), /packages, install-local and /automation doors as before', async () => { - const a = await resolveUserAuthzGrants(h.ql as never, h.userA, {}); + const a = await resolveUserAuthzGrants(h.ql as never, h.userA, { tenantId: await sessionOrgOf(h) }); expect(a.systemPermissions).toContain('manage_metadata'); expect(a.posture).not.toBe('PLATFORM_ADMIN'); await ok(await h.stack.apiAs(h.tokA, 'PUT', '/meta/object/em_sin_obj', object('em_sin_obj', 'Object by A'))); diff --git a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts index 23c6bf10f93..6f7a7db5cee 100644 --- a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts @@ -140,6 +140,15 @@ async function boot(posture: 'isolated' | 'group' | 'single'): Promise return h; } +/** + * [C8 stage 8b] The organization a `single` session is active in: the Default + * Organization, which an organization-less system grant is now stamped with + * (ADR-0131 D9). A precheck that resolves with no organization would read the + * global arm only, which no fresh `single` install writes any more. + */ +const sessionOrgOf = async (h: Harness): Promise => + (await (await h.stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()) ?? undefined; + const grantsOf = (h: Harness, user: string, org: string | undefined) => resolveUserAuthzGrants(h.ql as never, user, org ? { tenantId: org } : {}); @@ -305,7 +314,7 @@ describe('[ADR-0131 D3 · #15204 W3] single (CONTROL): the refusal is inert', () }); it('a `manage_metadata` holder below the operator runs every one of these doors on the operator\'s package as before', async () => { - const a = await grantsOf(h, h.userA, undefined); + const a = await grantsOf(h, h.userA, await sessionOrgOf(h)); expect(a.systemPermissions).toContain('manage_metadata'); expect(a.posture).not.toBe('PLATFORM_ADMIN'); const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); diff --git a/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts index 4cf22417626..c90db0b6299 100644 --- a/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts @@ -149,6 +149,15 @@ async function boot(posture: 'isolated' | 'group' | 'single'): Promise return h; } +/** + * [C8 stage 8b] The organization a `single` session is active in: the Default + * Organization, which an organization-less system grant is now stamped with + * (ADR-0131 D9). A precheck that resolves with no organization would read the + * global arm only, which no fresh `single` install writes any more. + */ +const sessionOrgOf = async (h: Harness): Promise => + (await (await h.stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy')).defaultOrgId()) ?? undefined; + const grantsOf = (h: Harness, user: string, org: string | undefined) => resolveUserAuthzGrants(h.ql as never, user, org ? { tenantId: org } : {}); @@ -287,7 +296,7 @@ describe('[ADR-0131 D3 · #15204 W2] single (CONTROL): the refusal is inert', () }); it('a `manage_metadata` holder below the operator installs, upgrades and uninstalls catalog-carrying packages, and deletes one at /packages', async () => { - const a = await grantsOf(h, h.userA, undefined); + const a = await grantsOf(h, h.userA, await sessionOrgOf(h)); expect(a.systemPermissions).toContain('manage_metadata'); expect(a.posture).not.toBe('PLATFORM_ADMIN'); const installed = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, @@ -296,7 +305,7 @@ describe('[ADR-0131 D3 · #15204 W2] single (CONTROL): the refusal is inert', () const upgraded = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, packageBody(OP_PKG, '1.1.0', { permissions: [set(OP_SET, ['manage_users'])] })); expect(upgraded.status, await upgraded.clone().text()).toBe(200); - expect((await grantsOf(h, h.userB, undefined)).systemPermissions).toContain('manage_users'); + expect((await grantsOf(h, h.userB, await sessionOrgOf(h))).systemPermissions).toContain('manage_users'); const removed = await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/${OP_PKG}`); expect(removed.status, await removed.clone().text()).toBe(200); const deleted = await h.stack.apiAs(h.tokA, 'DELETE', `/packages/${RT_PKG}`); diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index c109eb5919a..69a8d1d01fe 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -126,7 +126,14 @@ async function boot(inside: boolean, email: string): Promise { for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); + // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no + // active organization) must still hold: under `single` the engine now stamps an + // organization-less system insert with the Default Organization (ADR-0131 D9), + // so the global row is landed beneath the engine, as the residue an older + // release left. The readers still apply it until C8's NULL-reader stages. + await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, + }); } if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; diff --git a/packages/runtime/src/security/request-execution-context.conformance.test.ts b/packages/runtime/src/security/request-execution-context.conformance.test.ts index 9c522cc2250..e709c3788b9 100644 --- a/packages/runtime/src/security/request-execution-context.conformance.test.ts +++ b/packages/runtime/src/security/request-execution-context.conformance.test.ts @@ -153,7 +153,7 @@ function world(opts: { auth: boolean; gateActive?: boolean; sessionApi?: boolean { user_id: 'u_ses', organization_id: ORG, role: 'admin' }, ], sys_user_position: [{ user_id: 'u_ses', position: 'sales_rep', organization_id: ORG }], - sys_user_permission_set: [{ user_id: 'u_key', permission_set: 'contact_reader', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u_key', permission_set: 'contact_reader', organization_id: ORG }], sys_permission_set: [ { id: 'ps_sales', name: 'sales_user', object_permissions: '{}' }, { id: 'ps_contact', name: 'contact_reader', system_permissions: '["export_reports"]', object_permissions: '{}' }, diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 8ed5ab0105f..a5d64d8aa4c 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -415,7 +415,7 @@ describe('resolveExecutionContext — posture plumbing (#2947)', () => { it('carries MEMBER for an ordinary principal (no admin / org-admin capability)', async () => { const ql = makeQlFor( [{ id: 'ps_basic', name: 'sales_rep', system_permissions: '[]', object_permissions: '{}' }], - [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_basic', permission_set: 'sales_rep', organization_id: null }], + [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_basic', permission_set: 'sales_rep', organization_id: 'orgA' }], ); const ctx = await resolveExecutionContext(opts(ql)); expect(ctx.userId).toBe('u1'); diff --git a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts index 62a4d033bdd..0442ee042cd 100644 --- a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts @@ -83,7 +83,11 @@ const CASE_OBJECT = { }, } as any; -const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; +// [C8 stage 8b] Declared as the platform declares it (`sys-organization.object.ts`): +// better-auth owns the table, so the registration injects no organization column. +// A bare schema would gain one, and under `single` the first organization's own +// insert would then be refused for want of an organization. +const ORG_OBJECT = { name: 'sys_organization', managedBy: 'better-auth', fields: { name: { type: 'text' } } } as any; function createMetadata(objects: any[]) { const byName: Record = Object.fromEntries(objects.map((o) => [o.name, o])); @@ -548,8 +552,11 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { await driver.initObjects([sysObject, ORG_OBJECT]); // Land untenanted platform rows, then bring an organization into existence. + // [C8 stage 8b] Straight through the driver, as `landPreC1SeedResidue` does: + // under `single` the engine no longer writes an organization-less platform + // row, so these are the residue an older release left behind. for (let i = 0; i < 3; i++) { - await engine.insert('sys_audit_entry', { subject: `e${i}` }, { context: { isSystem: true } } as any); + await (driver as any).create('sys_audit_entry', { subject: `e${i}` }); } await createOrganization(engine); expect(await readSequences(driver)).toEqual([{ tenant: GLOBAL_TENANT, lastValue: 3 }]); diff --git a/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts index e5fd7ed7133..9b655d4fc51 100644 --- a/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts @@ -68,7 +68,11 @@ const DISPATCH_ORDER = { }, } as any; -const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; +// [C8 stage 8b] Declared as the platform declares it (`sys-organization.object.ts`): +// better-auth owns the table, so the registration injects no organization column. +// A bare schema would gain one, and under `single` the first organization's own +// insert would then be refused for want of an organization. +const ORG_OBJECT = { name: 'sys_organization', managedBy: 'better-auth', fields: { name: { type: 'text' } } } as any; const openDrivers: SqlDriver[] = []; diff --git a/packages/services/service-messaging/src/notification-organization-follows-event.integration.test.ts b/packages/services/service-messaging/src/notification-organization-follows-event.integration.test.ts index d4d19d0b2d3..d69dc53c460 100644 --- a/packages/services/service-messaging/src/notification-organization-follows-event.integration.test.ts +++ b/packages/services/service-messaging/src/notification-organization-follows-event.integration.test.ts @@ -45,8 +45,11 @@ import { InboxMessage, NotificationDelivery, NotificationPreference, Notificatio const SYSTEM = { context: { isSystem: true } as ExecutionContext }; -const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', fields: { name: { type: 'text', label: 'Name' } } }; -const SYS_USER = { name: 'sys_user', label: 'User', fields: { name: { type: 'text', label: 'Name' } } }; +// [C8 stage 8b] Declared as the platform declares them: better-auth owns both tables, +// so the registration injects no organization column. A bare schema would gain one, +// and under `single` the first organization's own insert would then be refused. +const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; +const SYS_USER = { name: 'sys_user', label: 'User', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; const silentLogger: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; silentLogger.child = () => silentLogger;