From b96313952afb8abf868c0356af485dba4a88fef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:23:23 +0000 Subject: [PATCH 1/7] fix: producers on tenant-column objects name the organization they can derive (ADR-0131 D9) The self-registration grant and the auth mails about a user read one derivable organization (the Default Organization under single); the activity writer falls back to it for a record with no organization; the settings secret and setting-audit rows carry their setting's owner; the verify RLS personas' grants carry the persona's membership; the business unit dogfood fixture stops swallowing its organization read. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- .../plugins/plugin-audit/src/audit-plugin.ts | 15 +++++- .../plugins/plugin-audit/src/audit-writers.ts | 25 ++++++++- .../plugins/plugin-auth/src/auth-manager.ts | 53 +++++++++++++++---- ...wcase-bu-hierarchy-sharing.dogfood.test.ts | 14 +++-- .../src/settings-service-plugin.ts | 24 ++++++++- .../service-settings/src/settings-service.ts | 8 +++ .../src/settings-service.types.ts | 12 +++++ .../src/settings-user-reference.pin.test.ts | 15 +++--- packages/verify/src/rls.ts | 22 +++++++- 9 files changed, 160 insertions(+), 28 deletions(-) diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 9a3eb803ddc..9b4bcd1857d 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -386,7 +386,20 @@ export class AuditPlugin implements Plugin { const { locale } = await resolveLocalizationContext({ ql: engine, settings, tenantId, userId }); return locale; }; - installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale }); + // [ADR-0131 D9] The organization an activity row about an org-less record + // derives: the `tenancy` service's Default Organization under `single`, + // none under a wall. Resolved at write time — AuthPlugin registers the + // service, and the answer is memoized there. + const getDefaultOrganizationId = async (): Promise => { + let tenancy: { defaultOrgId?: () => Promise } | undefined; + try { + tenancy = ctx.getService<{ defaultOrgId?: () => Promise }>('tenancy'); + } catch { + return undefined; + } + return typeof tenancy?.defaultOrgId === 'function' ? tenancy.defaultOrgId() : undefined; + }; + installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale, getDefaultOrganizationId }); ctx.logger.info('AuditPlugin: audit + activity writers installed'); // [#8992] Record-view auditing — the `read` half of the ledger. Installed diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index f8bbd5f1b14..b517196b358 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -149,6 +149,15 @@ export interface AuditWriterOptions { * comes first. Absent → summaries stay English (status quo). */ getLocale?(tenantId?: string, userId?: string): Promise; + /** + * [ADR-0131 D9] The organization an activity row carries when neither its + * record nor the acting session names one: the Default Organization under + * `single` (the only organization there is), `null` under a wall — the + * `tenancy` service's `defaultOrgId()`. Asked only for such a row, never + * for one whose organization is already known. Absent, or an answer that + * throws → the row names none, as before. + */ + getDefaultOrganizationId?(): Promise; } /** @@ -1445,6 +1454,14 @@ export function installAuditWriters( // `resolveRecordOrganizationField` there for the precedence and for why the // answer comes from the schema rather than a literal. const recordOrgResolver = createRecordOrganizationResolver(engine); + const derivableActivityOrganization = async (): Promise => { + if (!opts.getDefaultOrganizationId) return undefined; + try { + return (await opts.getDefaultOrganizationId()) ?? undefined; + } catch { + return undefined; + } + }; // Display label for an object under a given translate fn: translated label // → authored def label → API name. Shared by activity summaries and the @@ -2204,8 +2221,14 @@ export function installAuditWriters( // Same rationale as auditRow: stamp the tenant column so RLS matches the // recipient's organization on read — but only when the (auto-injected) // column actually exists, so single-tenant activity writes don't fail. + // + // [ADR-0131 D9] A record with no organization of its own (a `sys_user`, a + // platform setting), written with no session organization (a sign-up), + // takes the derivable one: the Default Organization under `single`. Only + // the activity row: the ledger's `tenant_id` above is an attribution of + // what the row is ABOUT (D7) and stays the record's. if (objectHasField('sys_activity', 'organization_id')) { - activityRow.organization_id = tenantId ?? null; + activityRow.organization_id = tenantId ?? (await derivableActivityOrganization()) ?? null; } const activityRowToWrite = mirrorsActivity ? activityRow : undefined; diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 698973c2e6c..d296da6f331 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1916,6 +1916,7 @@ export class AuthManager { // the user's mail carries only the deployment default. Best-effort; // `undefined` leaves the #14319 ladder exactly as it was. const storedLocale = await this.storedRecipientLocale({ id: user.id }); + const mailOrganization = await this.authMailOrganizationArg(); const result = await email.sendTemplate(invitation ? { // Exactly the variables `auth.account_invitation` declares — @@ -1935,6 +1936,7 @@ export class AuthManager { }, relatedObject: 'sys_user', relatedId: user.id, + ...mailOrganization, } : { template: 'auth.password_reset', @@ -1949,6 +1951,7 @@ export class AuthManager { }, relatedObject: 'sys_user', relatedId: user.id, + ...mailOrganization, }); if (result?.status === 'failed') { throw new Error( @@ -2011,6 +2014,9 @@ export class AuthManager { }, relatedObject: 'sys_user', relatedId: user.id, + // [ADR-0131 D9] The mail is about a user, who belongs to no + // organization yet: the derivable owner, as for the grant. + ...(await this.authMailOrganizationArg()), }); if (result?.status === 'failed') { throw new Error( @@ -4012,6 +4018,7 @@ export class AuthManager { expiresInMinutes: 10, appName: this.getAppName(), }, + ...(await this.authMailOrganizationArg()), }); } catch (err: any) { console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`); @@ -5516,12 +5523,41 @@ export class AuthManager { } } + /** + * [ADR-0131 D9] The organization a write this manager makes on a + * tenant-column object carries when nothing on the write names one: the + * Default Organization under `single` — the only organization there is — + * and none under a wall, where `tenancy.defaultOrgId()` answers `null`. One + * reading for every such producer here (the self-registration grant and the + * auth mails that are about a user rather than an organization), so they + * cannot disagree about it. `undefined` when no tenancy service is wired or + * it cannot answer: the producer then names nothing, as before. + */ + private async derivableWriteOrganizationId(): Promise { + try { + const tenancy = this.config.getTenancy?.(); + const organizationId = tenancy ? await tenancy.defaultOrgId() : null; + return organizationId ?? undefined; + } catch { + return undefined; + } + } + + /** The `organizationId` argument of an auth mail that is about a user, not an organization. */ + private async authMailOrganizationArg(): Promise<{ organizationId?: string }> { + const organizationId = await this.derivableWriteOrganizationId(); + return organizationId ? { organizationId } : {}; + } + /** * Land the staged self-registration grant for a freshly created user — the * `user.create.after` half of an admitted self-registration. Idempotent - * (existing `(user, set)` pair wins), org-scoped to the same target org the - * membership reconciler resolves (falls back to an unscoped row only when - * no org is resolvable — multi-org self-registration binds no org either). + * (existing `(user, set)` pair wins), owned by the organization + * {@link derivableWriteOrganizationId} derives — the same target org the + * membership reconciler resolves. Under a wall none is derivable when the + * registrant is created (multi-org self-registration binds no org either), + * so the row is written without one and its fate is the engine's (ADR-0131 + * D9). * * A failure here is reported at the DURABILITY level (`error` when the sink * carries it): the system keeps looking normal from the outside while a @@ -5545,13 +5581,7 @@ export class AuthManager { return; } const sys = withSystemReadContext(engine) as any; - let organizationId: string | null = null; - try { - const tenancy = this.config.getTenancy?.(); - organizationId = tenancy ? await tenancy.defaultOrgId() : null; - } catch { - organizationId = null; - } + const organizationId = await this.derivableWriteOrganizationId(); // [ADR-0131 D3/D4] The grant names the declared set BY NAME — the name // admission asked the security catalog and the activation ledger about — // so no `sys_permission_set` row is read to write it, and none needs to @@ -5573,7 +5603,7 @@ export class AuthManager { this.config.logger?.info?.('[audience] granted the declared self-registration permission set', { userId, permissionSet: staged.setName, - organizationId: organizationId ?? undefined, + organizationId, }); } catch (error) { this.reportUngrantedSelfRegistrant( @@ -5692,6 +5722,7 @@ export class AuthManager { }, relatedObject: 'sys_user', ...(from.id ? { relatedId: from.id } : {}), + ...(await this.authMailOrganizationArg()), }); } catch (err) { // Best-effort by design — see the ⛔ above. Logged so an operator can see diff --git a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts index 798bfabfffa..1d63d4ae37f 100644 --- a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts @@ -63,15 +63,19 @@ describe('showcase: business-unit hierarchy sharing rule (ADR-0057 D6 / #2077)', const mgrId = await uid('bu-mgr@verify.test'); const contribId = await uid('bu-contrib@verify.test'); - let org = await ql.findOne('sys_organization', { where: {}, context: SYS }).catch(() => null); - const orgId = org?.id ?? 'org_bu'; - if (!org) await sys('sys_organization', { id: orgId, name: 'BU Org', slug: 'bu_org' }).catch(() => {}); + // [ADR-0131 D3/D9] The showcase boots `single`, so the Default Organization + // exists before the first sign-up and owns every row this fixture seeds. A + // refused read is an answer, never "no organization": it is not swallowed, + // and no stand-in organization is minted beside the real one. + const org = await ql.findOne('sys_organization', { where: {}, context: SYS }); + if (!org?.id) throw new Error('fixture: the showcase boot has no Default Organization to own the business units'); + const orgId = String(org.id); // parent ⊃ child await sys('sys_business_unit', { id: 'bu_h_parent', name: 'Region', kind: 'division', organization_id: orgId, active: true }); await sys('sys_business_unit', { id: 'bu_h_child', name: 'Team', kind: 'department', parent_business_unit_id: 'bu_h_parent', organization_id: orgId, active: true }); - await sys('sys_business_unit_member', { id: 'bm_mgr', business_unit_id: 'bu_h_parent', user_id: mgrId }); - await sys('sys_business_unit_member', { id: 'bm_contrib', business_unit_id: 'bu_h_child', user_id: contribId }); + await sys('sys_business_unit_member', { id: 'bm_mgr', business_unit_id: 'bu_h_parent', user_id: mgrId, organization_id: orgId }); + await sys('sys_business_unit_member', { id: 'bm_contrib', business_unit_id: 'bu_h_child', user_id: contribId, organization_id: orgId }); // owner creates a PRIVATE note — only the owner can see it by default. const c = await stack.apiAs(ownerTok, 'POST', OBJ, { title: 'BU-shared note' }); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 1d3595a6982..409a2619a56 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -568,9 +568,12 @@ export class SettingsServicePlugin implements Plugin { */ export function buildSettingAuditWriter( engine: IDataEngine, - logger?: { warn?: (message: string) => void }, + logger?: { error?: (message: string) => void }, ): SettingsAuditWriter { const eng: any = engine; + // The consequence and the fix ride the first failure only (AGENTS.md, + // degradation log levels); every later lost row is still named, briefly. + let reportedDurabilityLoss = false; return { write: async (entry) => { try { @@ -599,6 +602,9 @@ export function buildSettingAuditWriter( request_id: entry.requestId ?? null, reason: entry.reason ?? null, created_at: new Date().toISOString(), + // [ADR-0131 D3/D9] The organization whose value changed — the + // settings row's own owner. A global value names none. + ...(entry.organizationId ? { organization_id: entry.organizationId } : {}), // The explicit system opt-in, as the settings row write carries: // `sys_setting_audit` is the platform's own ledger, written after // the settings door already authorized the change, and not a @@ -606,7 +612,21 @@ export function buildSettingAuditWriter( // middleware's principal-less hand-off (ADR-0096 D5). }, { bypassTenantAudit: true, context: { isSystem: true } }); } catch (err: any) { - logger?.warn?.('SettingsServicePlugin: setting-audit write failed: ' + (err?.message ?? err)); + // A durability loss, not a functional one: the settings write already + // landed and everything keeps looking normal, while the per-key + // history row it claims is gone — so `error`, never `warn`. + const cause = 'SettingsServicePlugin: setting-audit write failed: ' + (err?.message ?? err); + if (reportedDurabilityLoss) { + logger?.error?.(cause); + } else { + reportedDurabilityLoss = true; + logger?.error?.( + cause + + ' — settings writes keep succeeding, but their sys_setting_audit history rows are NOT persisted ' + + 'and nothing retries them. Fix the cause named here (the actor must be a sys_user; the row must ' + + 'carry the organization its setting belongs to).', + ); + } } }, }; diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 2983dd21274..e3a1e5290c8 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2117,6 +2117,11 @@ export class SettingsService { alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, + // [ADR-0131 D3/D9] The ciphertext of a tenant or user value is + // that organization's, as its `sys_setting` row is. A global + // value's is deployment-level and names none (see + // `SettingsSecretStore.insert`). + ...(organizationId ? { organization_id: organizationId } : {}), }); storedEnc = handle.id; digest = await this.secretAuditDigest(namespace, key, plain); @@ -2202,6 +2207,9 @@ export class SettingsService { action: isNull ? 'reset' : 'set', source: 'api', actorId: ctx.userId, + // [ADR-0131 D3/D9] The organization whose value changed — the same + // owner the settings row carries; none for a global value. + ...(organizationId ? { organizationId } : {}), oldHash: null, newHash: isNull ? null : digest, encrypted: isEncrypted, diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 8d57b6cc52a..92ed48d1c68 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -242,6 +242,13 @@ export interface SettingsSecretStore { alg: string; version: number; ciphertext: string; + /** + * [ADR-0131 D3/D9] The organization that owns the secret — the owning + * `sys_setting` row's, for a tenant or user value. Absent for a global + * value: a deployment-level secret, whose fate in this tenant-column + * store is not settled yet. + */ + organization_id?: string; }): Promise<{ id: string }>; /** Look up the latest ciphertext for a handle id; null when missing. */ get(id: string): Promise<{ @@ -302,6 +309,11 @@ export interface SettingsAuditWriter { encrypted: boolean; requestId?: string; reason?: string; + /** + * [ADR-0131 D3/D9] The organization whose value changed — the settings + * row's own. Absent for a global value. + */ + organizationId?: string; }): Promise | void; } diff --git a/packages/services/service-settings/src/settings-user-reference.pin.test.ts b/packages/services/service-settings/src/settings-user-reference.pin.test.ts index 22e338735fb..593bfd4332c 100644 --- a/packages/services/service-settings/src/settings-user-reference.pin.test.ts +++ b/packages/services/service-settings/src/settings-user-reference.pin.test.ts @@ -139,18 +139,21 @@ describe('[#21913] the setting-audit writer keeps the dangling actor_id refusal' }).then(() => null, (e) => e); expect(before?.code).toBe('VALIDATION_FAILED'); - const warned: string[] = []; - await buildSettingAuditWriter(engine as any, { warn: (m) => warned.push(m) }).write(entry(GHOST) as any); - expect(warned).toEqual([`SettingsServicePlugin: setting-audit write failed: ${before.message}`]); + // A lost history row is a durability loss, reported at `error` (AGENTS.md, + // degradation log levels), naming the engine's own refusal. + const reported: string[] = []; + await buildSettingAuditWriter(engine as any, { error: (m) => reported.push(m) }).write(entry(GHOST) as any); + expect(reported).toHaveLength(1); + expect(reported[0].startsWith(`SettingsServicePlugin: setting-audit write failed: ${before.message}`)).toBe(true); expect(rowsOf('sys_setting_audit').size).toBe(0); }); it('a known actor is written, and an entry naming no actor is unchanged', async () => { - const warned: string[] = []; - const writer = buildSettingAuditWriter(engine as any, { warn: (m) => warned.push(m) }); + const reported: string[] = []; + const writer = buildSettingAuditWriter(engine as any, { error: (m) => reported.push(m) }); await writer.write(entry(userId) as any); await writer.write(entry() as any); - expect(warned).toEqual([]); + expect(reported).toEqual([]); expect([...rowsOf('sys_setting_audit').values()].map((r) => r.actor_id ?? null).sort()).toEqual([null, userId].sort()); }); }); diff --git a/packages/verify/src/rls.ts b/packages/verify/src/rls.ts index 3b404c85561..3b319f85138 100644 --- a/packages/verify/src/rls.ts +++ b/packages/verify/src/rls.ts @@ -419,6 +419,24 @@ export function rlsProbeSecurity(config: any): SecurityPlugin { * Callers must NOT swallow that into a weaker persona silently: run degraded and * say so (`RlsProbeDescriptor.degraded`), or fail. */ +/** + * [ADR-0131 D3/D9] The organization a persona's assignment row belongs to: the + * one the persona is a member of. Under `single` the membership reconciler + * makes every sign-up a member of the Default Organization, so this is it — + * the owner the engine would derive. A persona with no membership (a walled + * boot, where a sign-up joins nothing) or several names none: the row is + * written without one, and its fate is the engine's. + */ +async function personaOrganization( + ql: any, + userId: string, + sysCtx: { context: { isSystem: true } }, +): Promise<{ organization_id?: string }> { + const memberships = rowsOf(await ql.find('sys_member', { where: { user_id: userId }, limit: 2 }, sysCtx)); + const organizationId = memberships.length === 1 ? memberships[0]?.organization_id : undefined; + return typeof organizationId === 'string' && organizationId !== '' ? { organization_id: organizationId } : {}; +} + export async function provisionRlsProbePersona( stack: VerifyStack, config: any, @@ -469,7 +487,7 @@ export async function provisionRlsProbePersona( user_id: userId, // [ADR-0131 D4] The grant names its set; no id is written (D10). permission_set: RLS_PROBE_PERMISSION_SET, - organization_id: null, + ...(await personaOrganization(ql, userId, sysCtx)), granted_by: null, }, sysCtx, @@ -561,7 +579,7 @@ export async function provisionRlsPositionPersona( user_id: userId, position, business_unit_id: null, - organization_id: null, + ...(await personaOrganization(ql, userId, sysCtx)), granted_by: null, reason: `Ephemeral persona minted by \`objectstack verify --rls\`: holds '${position}' and nothing else, so the app's own position-gated RLS narrowing is exercised.`, }, From 66630882506ecbf2f49b1b608bcadc6ebe7b2833 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:34:44 +0000 Subject: [PATCH 2/7] test: pin each producer's organization under single and under a wall Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- .../plugin-audit/src/audit-writers.test.ts | 58 +++++++ .../src/producers-name-organization.test.ts | 159 ++++++++++++++++++ .../src/settings-secret-organization.test.ts | 103 ++++++++++++ packages/verify/src/rls-persona-grant.test.ts | 26 ++- packages/verify/src/rls.ts | 2 +- 5 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/producers-name-organization.test.ts create mode 100644 packages/services/service-settings/src/settings-secret-organization.test.ts diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 577849155e8..34765644bff 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -2564,3 +2564,61 @@ describe('[#21207] stored metadata copies carry no content hash', () => { expect(JSON.parse(audit!.row.new_value).checksum).toBe(HASH); }); }); + +/** + * [ADR-0131 D9] An activity row about a record with no organization of its own + * (a `sys_user`, a platform setting), written with no session organization (a + * sign-up), takes the one the host derives — the `tenancy` service's Default + * Organization under `single`, `null` under a wall. The ledger row's + * `tenant_id` is an attribution of what the row is ABOUT (D7) and is not + * touched; a known organization is never overridden. + */ +describe('audit writers — the activity row of an org-less record names the derivable organization (ADR-0131 D9)', () => { + const ORG_LESS = { ...MULTI_TENANT, sys_user: ['id', 'name', 'email'] }; + const signUp = { + object: 'sys_user', + input: { id: 'usr-1' }, + result: { id: 'usr-1', name: 'Ada', email: 'ada@example.com' }, + session: {}, + }; + + it('stamps the Default Organization under single, and leaves the ledger attribution alone', async () => { + const { engine, fire, created } = makeEngine(ORG_LESS); + installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId: async () => 'org-default' }); + await fire('afterInsert', signUp); + + expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBe('org-default'); + expect(created.find((c) => c.object === 'sys_audit_log')?.row.tenant_id).toBeNull(); + }); + + it('names none under a wall, and none when the host derives nothing or cannot answer', async () => { + for (const getDefaultOrganizationId of [async () => null, undefined, async () => { throw new Error('no tenancy'); }]) { + const { engine, fire, created } = makeEngine(ORG_LESS); + installAuditWriters(engine as any, 'test.audit', getDefaultOrganizationId ? { getDefaultOrganizationId } : {}); + await fire('afterInsert', signUp); + expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBeNull(); + } + }); + + it('never overrides a known organization — the record\'s, then the session\'s — nor asks for one', async () => { + let asked = 0; + const getDefaultOrganizationId = async () => { asked += 1; return 'org-default'; }; + const { engine, fire, created } = makeEngine({ ...MULTI_TENANT, crm_lead: ['id', 'name', 'organization_id'] }); + installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId }); + await fire('afterInsert', { + object: 'crm_lead', + input: { id: 'lead-1' }, + result: { id: 'lead-1', name: 'Acme', organization_id: 'org-A' }, + session: { organizationId: 'org-B', userId: 'user-1' }, + }); + await fire('afterInsert', { + object: 'crm_lead', + input: { id: 'lead-2' }, + result: { id: 'lead-2', name: 'Beta', organization_id: null }, + session: { organizationId: 'org-B', userId: 'user-1' }, + }); + const activities = created.filter((c) => c.object === 'sys_activity').map((c) => c.row.organization_id); + expect(activities).toEqual(['org-A', 'org-B']); + expect(asked).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-auth/src/producers-name-organization.test.ts b/packages/plugins/plugin-auth/src/producers-name-organization.test.ts new file mode 100644 index 00000000000..26d2ae238fe --- /dev/null +++ b/packages/plugins/plugin-auth/src/producers-name-organization.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D9] The writes AuthManager makes on tenant-column objects for a + * USER — the self-registration grant (`sys_user_permission_set`) and the auth + * mails about that user (`sys_email`, through `IEmailService.sendTemplate`) — + * name the one organization they can derive: the Default Organization under + * `single`, which `tenancy.defaultOrgId()` answers, and none under a wall, + * where it answers `null` (the registrant belongs to no organization yet). + * + * Under `single` this is the owner the engine derives for an org-less system + * insert, so the stamp moves no reader; under a wall the row stays org-less + * and its fate is the engine's. Both halves are pinned per writer. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AuthManager } from './auth-manager'; + +vi.mock('better-auth', () => ({ + betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), +})); +vi.mock('better-auth/plugins/organization', () => ({ + organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })), +})); +vi.mock('better-auth/plugins/magic-link', () => ({ + magicLink: vi.fn((opts: any) => ({ id: 'magic-link', _opts: opts })), +})); +vi.mock('better-auth/plugins/two-factor', () => ({ + twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })), +})); +vi.mock('better-auth/plugins/custom-session', () => ({ + customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })), +})); +vi.mock('better-auth/plugins/haveibeenpwned', () => ({ + haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })), +})); + +const USER = { id: 'u1', email: 'ada@example.com', name: 'Ada' }; +const DEFAULT_ORG = 'org_default'; + +type Tenancy = { defaultOrgId(): Promise } | undefined; + +/** `single`: the Default Organization. A wall: `null`. */ +const SINGLE: Tenancy = { defaultOrgId: async () => DEFAULT_ORG }; +const WALLED: Tenancy = { defaultOrgId: async () => null }; + +/** Boot an AuthManager with a recording email service; returns the better-auth config and the sends. */ +async function bootMail(tenancy: Tenancy) { + const { betterAuth } = await import('better-auth'); + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + emailAndPassword: { enabled: true }, + emailVerification: { sendOnSignUp: true }, + ...(tenancy ? { getTenancy: () => tenancy } : {}), + } as never); + const sent: any[] = []; + manager.setEmailService({ + async send() { return { id: 'e', status: 'sent' }; }, + async sendTemplate(input: any) { sent.push(input); return { id: 'e1', status: 'sent' }; }, + } as never); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + return { config: capturedConfig, sent }; +} + +async function sendVerification(tenancy: Tenancy) { + const { config, sent } = await bootMail(tenancy); + await config.emailVerification.sendVerificationEmail({ user: USER, url: 'http://x/verify', token: 't' }); + return sent; +} + +async function sendReset(tenancy: Tenancy) { + const { config, sent } = await bootMail(tenancy); + await config.emailAndPassword.sendResetPassword( + { user: USER, url: 'http://x/reset', token: 't' }, + new Request('http://x/api/v1/auth/request-password-reset'), + ); + return sent; +} + +describe('[ADR-0131 D9] the auth mails about a user name the derivable organization', () => { + it('the verification mail carries the Default Organization under single', async () => { + const sent = await sendVerification(SINGLE); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ template: 'auth.verify_email', relatedObject: 'sys_user', organizationId: DEFAULT_ORG }); + }); + + it('the verification mail names no organization under a wall, nor with no tenancy service', async () => { + for (const tenancy of [WALLED, undefined]) { + const sent = await sendVerification(tenancy); + expect(sent).toHaveLength(1); + expect(sent[0].template).toBe('auth.verify_email'); + expect('organizationId' in sent[0]).toBe(false); + } + }); + + it('the password-reset mail carries the Default Organization under single and none under a wall', async () => { + const single = await sendReset(SINGLE); + expect(single[0]).toMatchObject({ template: 'auth.password_reset', organizationId: DEFAULT_ORG }); + const walled = await sendReset(WALLED); + expect(walled[0].template).toBe('auth.password_reset'); + expect('organizationId' in walled[0]).toBe(false); + }); +}); + +/** An engine double recording the grant insert; `find` answers no existing grant. */ +function grantEngine() { + const inserts: Array<[string, Record]> = []; + return { + inserts, + async find() { return []; }, + async findOne() { return null; }, + async count() { return 0; }, + async insert(object: string, data: Record) { + inserts.push([object, { ...data }]); + return { ...data }; + }, + }; +} + +async function settleGrant(tenancy: Tenancy) { + const engine = grantEngine(); + const errors: string[] = []; + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + dataEngine: engine as never, + getTenancy: () => tenancy, + logger: { error: (m: string) => errors.push(String(m)), warn: () => {}, info: () => {} }, + } as never); + (manager as never as { stageSelfRegistrationGrant: Function }).stageSelfRegistrationGrant(USER.email, 'portal_user'); + await (manager as never as { settleSelfRegistrationGrant: Function }).settleSelfRegistrationGrant(USER); + return { inserts: engine.inserts, errors }; +} + +describe('[ADR-0131 D9] the self-registration grant names the derivable organization', () => { + it('is owned by the Default Organization under single', async () => { + const { inserts, errors } = await settleGrant(SINGLE); + expect(errors).toEqual([]); + expect(inserts).toHaveLength(1); + expect(inserts[0][0]).toBe('sys_user_permission_set'); + expect(inserts[0][1]).toMatchObject({ user_id: USER.id, permission_set: 'portal_user', organization_id: DEFAULT_ORG }); + }); + + it('names none under a wall, where the registrant belongs to no organization yet', async () => { + const { inserts, errors } = await settleGrant(WALLED); + expect(errors).toEqual([]); + expect(inserts).toHaveLength(1); + expect(inserts[0][1]).toMatchObject({ user_id: USER.id, permission_set: 'portal_user' }); + expect('organization_id' in inserts[0][1]).toBe(false); + }); +}); diff --git a/packages/services/service-settings/src/settings-secret-organization.test.ts b/packages/services/service-settings/src/settings-secret-organization.test.ts new file mode 100644 index 00000000000..8e1bc1dcde2 --- /dev/null +++ b/packages/services/service-settings/src/settings-secret-organization.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D9] The two rows a settings write lands BESIDE its `sys_setting` + * row — the `sys_secret` ciphertext of an encrypted value and the per-key + * `sys_setting_audit` history row — belong to the organization the setting + * row does: the caller's, for a `tenant` or `user` value. A `global` value is + * deployment-level (its row lives in `sys_platform_setting`, which has no + * organization), so neither row names one; whether such rows may stay in + * these two tenant-column objects is a question this file does not answer. + */ + +import { randomBytes } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { SettingsService } from './settings-service.js'; +import { LocalCryptoProvider } from './local-crypto-provider.js'; +import { mailSettingsManifest } from './manifests/mail.manifest.js'; +import { buildSettingAuditWriter } from './settings-service-plugin.js'; +import type { SettingsSecretStore } from './settings-service.types.js'; + +/** A tenant-scoped namespace carrying one encrypted value. */ +const tenantSecretManifest: SettingsManifest = { + namespace: 'tenant_vault', + version: 1, + label: 'Tenant vault', + scope: 'tenant', + specifiers: [ + { type: 'password', key: 'token', label: 'Token', required: false, encrypted: true }, + ], +}; + +function boot() { + const secrets: any[] = []; + const history: any[] = []; + const secretStore: SettingsSecretStore = { + async insert(row) { secrets.push({ ...row }); return { id: row.id }; }, + async get(id) { return secrets.find((r) => r.id === id) ?? null; }, + async update() {}, + }; + const svc = new SettingsService({ + env: {}, + cryptoProvider: new LocalCryptoProvider({ key: randomBytes(32) }), + secretStore, + auditWriter: { write: (e) => { history.push({ ...e }); } }, + }); + svc.registerManifest(tenantSecretManifest); + svc.registerManifest(mailSettingsManifest); + return { svc, secrets, history }; +} + +describe('[ADR-0131 D3/D9] settings secret and history rows name their setting\'s organization', () => { + it('a tenant value: the ciphertext and the history row carry the caller\'s organization', async () => { + const { svc, secrets, history } = boot(); + await svc.setMany('tenant_vault', { token: 's3cret' }, { tenantId: 'org-1', userId: 'usr-1' }); + + expect(secrets).toHaveLength(1); + expect(secrets[0]).toMatchObject({ namespace: 'tenant_vault', key: 'token', organization_id: 'org-1' }); + expect(history.filter((e) => e.key === 'token')).toEqual([ + expect.objectContaining({ scope: 'tenant', encrypted: true, organizationId: 'org-1' }), + ]); + }); + + it('a global value: deployment-level, so neither row names an organization — even from a caller who has one', async () => { + const { svc, secrets, history } = boot(); + await svc.setMany( + 'mail', + { provider: 'resend', api_key: 're_123', from_email: 'ops@example.com' }, + { tenantId: 'org-1', userId: 'usr-1' }, + ); + + expect(secrets).toHaveLength(1); + expect('organization_id' in secrets[0]).toBe(false); + const rows = history.filter((e) => e.namespace === 'mail'); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) expect('organizationId' in row).toBe(false); + }); +}); + +describe('[ADR-0131 D3/D9] the sys_setting_audit writer stamps the entry\'s organization', () => { + function engine() { + const inserted: Array> = []; + return { + inserted, + async findOne() { return { id: 'usr-1' }; }, + async insert(_object: string, row: Record) { inserted.push({ ...row }); return row; }, + }; + } + const entry = { + namespace: 'tenant_vault', key: 'token', scope: 'tenant' as const, action: 'set' as const, + actorId: 'usr-1', oldHash: null, newHash: 'hmac-sha256:x', encrypted: true, + }; + + it('writes organization_id when the entry names one, and no organization_id when it does not', async () => { + const eng = engine(); + const writer = buildSettingAuditWriter(eng as any, { error: (m) => { throw new Error(m); } }); + await writer.write({ ...entry, organizationId: 'org-1' }); + await writer.write({ ...entry, scope: 'global' }); + + expect(eng.inserted[0]).toMatchObject({ key: 'token', organization_id: 'org-1' }); + expect('organization_id' in eng.inserted[1]).toBe(false); + }); +}); diff --git a/packages/verify/src/rls-persona-grant.test.ts b/packages/verify/src/rls-persona-grant.test.ts index 59310478d8a..52671d2b8d7 100644 --- a/packages/verify/src/rls-persona-grant.test.ts +++ b/packages/verify/src/rls-persona-grant.test.ts @@ -62,11 +62,14 @@ function catalogServing(names: string[]): SecurityCatalogReader { }; } -function stackDouble(seed: { sys_permission_set?: any[]; catalog?: SecurityCatalogReader } = {}) { +function stackDouble( + seed: { sys_permission_set?: any[]; sys_member?: any[]; catalog?: SecurityCatalogReader } = {}, +) { const tables: Record = { sys_user: [{ id: 'usr_probe', email: RLS_PROBE_EMAIL }], sys_permission_set: seed.sys_permission_set ?? [], sys_user_permission_set: [], + sys_member: seed.sys_member ?? [], }; const inserted: string[] = []; const ql = { @@ -106,6 +109,27 @@ describe('[ADR-0131 D3/D4] the verify RLS persona is provisioned through the reg expect(grants[0].permission_set_id).toBeUndefined(); }); + // [ADR-0131 D3/D9] An assignment is an organization row: the persona's grant + // belongs to the organization the persona is a member of — under `single`, + // the Default Organization every sign-up joins. + it('owns the grant by the persona\'s organization, and names none for a persona that belongs to none', async () => { + const member = stackDouble({ + catalog: catalogServing([PROBE_SET]), + sys_member: [{ id: 'mem_1', user_id: 'usr_probe', organization_id: 'org_default' }], + }); + await provisionRlsProbePersona(member.stack, { objects: [] }); + expect(member.tables.sys_user_permission_set[0]).toMatchObject({ user_id: 'usr_probe', organization_id: 'org_default' }); + + for (const sys_member of [[], [ + { id: 'mem_1', user_id: 'usr_probe', organization_id: 'org_a' }, + { id: 'mem_2', user_id: 'usr_probe', organization_id: 'org_b' }, + ]]) { + const orgLess = stackDouble({ catalog: catalogServing([PROBE_SET]), sys_member }); + await provisionRlsProbePersona(orgLess.stack, { objects: [] }); + expect('organization_id' in orgLess.tables.sys_user_permission_set[0]).toBe(false); + } + }); + it('refuses, writing nothing, when the catalog does not serve the set — a row alone is not the probe set', async () => { const { stack, inserted } = stackDouble({ sys_permission_set: [{ ...SEEDED_ROW }], diff --git a/packages/verify/src/rls.ts b/packages/verify/src/rls.ts index 3b319f85138..276d0529463 100644 --- a/packages/verify/src/rls.ts +++ b/packages/verify/src/rls.ts @@ -430,7 +430,7 @@ export function rlsProbeSecurity(config: any): SecurityPlugin { async function personaOrganization( ql: any, userId: string, - sysCtx: { context: { isSystem: true } }, + sysCtx: { context: { isSystem: boolean } }, ): Promise<{ organization_id?: string }> { const memberships = rowsOf(await ql.find('sys_member', { where: { user_id: userId }, limit: 2 }, sysCtx)); const organizationId = memberships.length === 1 ? memberships[0]?.organization_id : undefined; From cf97c5747165fd735e4e9b32d3bda33d98259933 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:46:20 +0000 Subject: [PATCH 3/7] test: a sign-up census on a single boot, and the BU fixture reads the Default Organization Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- ...wcase-bu-hierarchy-sharing.dogfood.test.ts | 14 ++- ...signup-organization-census.dogfood.test.ts | 112 ++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts diff --git a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts index 1d63d4ae37f..948bce3621d 100644 --- a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts @@ -64,12 +64,14 @@ describe('showcase: business-unit hierarchy sharing rule (ADR-0057 D6 / #2077)', const contribId = await uid('bu-contrib@verify.test'); // [ADR-0131 D3/D9] The showcase boots `single`, so the Default Organization - // exists before the first sign-up and owns every row this fixture seeds. A - // refused read is an answer, never "no organization": it is not swallowed, - // and no stand-in organization is minted beside the real one. - const org = await ql.findOne('sys_organization', { where: {}, context: SYS }); - if (!org?.id) throw new Error('fixture: the showcase boot has no Default Organization to own the business units'); - const orgId = String(org.id); + // exists before the first sign-up and owns every row this fixture seeds — + // asked of the `tenancy` service, the one reading of it. (This used to be a + // `findOne` with an empty `where`, which the engine refuses as an arbitrary + // pick; the refusal was swallowed and a stand-in organization minted beside + // the real one.) + const tenancy = await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy'); + const orgId = await tenancy.defaultOrgId(); + if (!orgId) throw new Error('fixture: the showcase boot has no Default Organization to own the business units'); // parent ⊃ child await sys('sys_business_unit', { id: 'bu_h_parent', name: 'Region', kind: 'division', organization_id: orgId, active: true }); diff --git a/packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts b/packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts new file mode 100644 index 00000000000..9b6dd8a54d7 --- /dev/null +++ b/packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D9] The organization census of one self-registration, end to end +// on the real stack: a sign-up through `POST /auth/sign-up/email` lands three +// framework writes on tenant-column objects — the declared self-registration +// grant (`sys_user_permission_set`), the verification mail (`sys_email`) and +// the activity about the records the sign-up creates (`sys_activity`). Each +// producer names the organization it can derive, so on a `single` boot — +// where the Default Organization is the only organization there is — the +// sign-up leaves no row without one on any of the three tables. +// +// Under a wall the same sign-up has no organization to derive (the registrant +// belongs to none yet); those rows' fate is not settled by this file. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { EmailServicePlugin } from '@objectstack/plugin-email'; +import { AuditPlugin } from '@objectstack/plugin-audit'; + +const SYS = { context: { isSystem: true } }; +const CENSUS_TABLES = ['sys_user_permission_set', 'sys_email', 'sys_activity'] as const; +const REGISTRANT = 'census-registrant@dogfood.test'; + +/** A transport that accepts every message, so the verification mail persists as sent. */ +const acceptingTransport = { + async send() { + return { messageId: `census-${Math.random().toString(36).slice(2, 10)}` }; + }, +}; + +describe('[ADR-0131 D9] a self-registration on a single boot leaves no organization-less row', () => { + let stack: VerifyStack; + let ql: any; + let defaultOrgId: string; + /** Row ids each census table held BEFORE the sign-up — the boot's own rows. */ + const beforeSignUp = new Map>(); + const rowsOf = async (table: string): Promise>> => + ql.find(table, { limit: 1000, ...SYS }); + + beforeAll(async () => { + stack = await bootStack(crmStack as never, { + extraPlugins: [new AuditPlugin(), new EmailServicePlugin({ + transport: acceptingTransport as never, + defaultFrom: { address: 'no-reply@example.test', name: 'Census' }, + })], + }); + const adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const tenancy = await stack.kernel.getServiceAsync<{ defaultOrgId(): Promise }>('tenancy'); + defaultOrgId = (await tenancy.defaultOrgId()) ?? ''; + + // An open audience: a stranger self-registers, receives the declared set, + // and is sent the verification mail the open posture requires. + const opened = await stack.raw('/api/settings/auth', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` }, + body: JSON.stringify({ + values: { audience_posture: 'open', audience_self_registration_permission_set: 'member_default' }, + }), + }); + expect(opened.status, `open the audience: ${await opened.clone().text()}`).toBe(200); + + // The census is of what the SIGN-UP writes. The boot's own rows — above all + // the platform admin's promotion grant, whose owner is settled by its own + // stage (ADR-0131 D5) — are set aside here, by id. + for (const table of CENSUS_TABLES) beforeSignUp.set(table, new Set((await rowsOf(table)).map((r) => r.id))); + + const signedUp = await stack.api('/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: REGISTRANT, password: 'Census-Passw0rd!', name: 'Census Registrant' }), + }); + expect(signedUp.status, `sign-up: ${await signedUp.clone().text()}`).toBe(200); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('guard: the boot is single, and the sign-up reached all three writers', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string }>('tenancy'); + expect(tenancy.posture).toBe('single'); + expect(defaultOrgId).not.toBe(''); + + const registrant = await ql.findOne('sys_user', { where: { email: REGISTRANT }, ...SYS }); + expect(registrant?.id, 'the registrant exists').toBeTruthy(); + const grants = await ql.find('sys_user_permission_set', { where: { user_id: registrant.id }, ...SYS }); + expect(grants.map((g: any) => g.permission_set)).toEqual(['member_default']); + const mails = await ql.find('sys_email', { where: { related_object: 'sys_user', related_id: registrant.id }, ...SYS }); + expect(mails.length, 'the verification mail persisted').toBeGreaterThan(0); + const activity = await ql.find('sys_activity', { where: { object_name: 'sys_user', record_id: registrant.id }, ...SYS }); + expect(activity.length, 'the registrant\'s creation is on the activity stream').toBeGreaterThan(0); + }); + + it('every row the sign-up wrote on the three tables names the Default Organization', async () => { + const census: Record = {}; + for (const table of CENSUS_TABLES) { + const written = (await rowsOf(table)).filter((r) => !beforeSignUp.get(table)!.has(r.id)); + census[table] = { + written: written.length, + orgLess: written.filter((r) => r.organization_id == null || r.organization_id === '').map((r) => r.id), + foreign: written.filter((r) => r.organization_id != null && r.organization_id !== defaultOrgId).map((r) => r.id), + }; + } + for (const table of CENSUS_TABLES) { + expect(census[table].written, `${table}: the sign-up wrote rows here`).toBeGreaterThan(0); + expect(census[table].orgLess, `${table}: rows without an organization`).toEqual([]); + expect(census[table].foreign, `${table}: rows owned by another organization`).toEqual([]); + } + }); +}); From 8af1ac9398859199376572d9300b9b599580f5f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:51:45 +0000 Subject: [PATCH 4/7] chore: changeset for the producers naming their organization Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- .changeset/24343-producers-name-organization.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/24343-producers-name-organization.md diff --git a/.changeset/24343-producers-name-organization.md b/.changeset/24343-producers-name-organization.md new file mode 100644 index 00000000000..6ab3f051661 --- /dev/null +++ b/.changeset/24343-producers-name-organization.md @@ -0,0 +1,17 @@ +--- +"@objectstack/plugin-audit": minor +"@objectstack/service-settings": minor +"@objectstack/plugin-auth": patch +"@objectstack/verify": patch +--- + +fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9) + +Clause-②: yes (widening) + +A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before. + +- **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. +- **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. +- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s logger argument now reads `error`. +- **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. From 5c3cbdd9037431d04e8fadf6b646e4c46ab4b291 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:15:11 +0000 Subject: [PATCH 5/7] test: the grant double carries only the engine members the writer calls Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- .../plugins/plugin-auth/src/producers-name-organization.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/producers-name-organization.test.ts b/packages/plugins/plugin-auth/src/producers-name-organization.test.ts index 26d2ae238fe..d51c6fe17fc 100644 --- a/packages/plugins/plugin-auth/src/producers-name-organization.test.ts +++ b/packages/plugins/plugin-auth/src/producers-name-organization.test.ts @@ -116,8 +116,6 @@ function grantEngine() { return { inserts, async find() { return []; }, - async findOne() { return null; }, - async count() { return 0; }, async insert(object: string, data: Record) { inserts.push([object, { ...data }]); return { ...data }; From bc7e2b791614910e115b8d96919a1cfc6575860e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:35:52 +0000 Subject: [PATCH 6/7] fix(service-settings): the setting-audit sink declares warn as its fallback for a reduced logger Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- .../24343-producers-name-organization.md | 2 +- .../src/settings-secret-organization.test.ts | 18 +++++++++++++++++- .../src/settings-service-plugin.ts | 12 +++++++++--- .../src/settings-user-reference.pin.test.ts | 4 ++-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.changeset/24343-producers-name-organization.md b/.changeset/24343-producers-name-organization.md index 6ab3f051661..5acaa1490f1 100644 --- a/.changeset/24343-producers-name-organization.md +++ b/.changeset/24343-producers-name-organization.md @@ -13,5 +13,5 @@ A write on a tenant-column object with no organization is derived under `single` - **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. - **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. -- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s logger argument now reads `error`. +- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s logger argument reports through `error` when the sink carries it and through its declared `warn` otherwise (the argument stays optional; a sink passed to it declares `warn`, as `PluginContext.logger` does). - **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. diff --git a/packages/services/service-settings/src/settings-secret-organization.test.ts b/packages/services/service-settings/src/settings-secret-organization.test.ts index 8e1bc1dcde2..f91eb56b786 100644 --- a/packages/services/service-settings/src/settings-secret-organization.test.ts +++ b/packages/services/service-settings/src/settings-secret-organization.test.ts @@ -93,11 +93,27 @@ describe('[ADR-0131 D3/D9] the sys_setting_audit writer stamps the entry\'s orga it('writes organization_id when the entry names one, and no organization_id when it does not', async () => { const eng = engine(); - const writer = buildSettingAuditWriter(eng as any, { error: (m) => { throw new Error(m); } }); + const writer = buildSettingAuditWriter(eng as any, { error: (m) => { throw new Error(m); }, warn: (m) => { throw new Error(m); } }); await writer.write({ ...entry, organizationId: 'org-1' }); await writer.write({ ...entry, scope: 'global' }); expect(eng.inserted[0]).toMatchObject({ key: 'token', organization_id: 'org-1' }); expect('organization_id' in eng.inserted[1]).toBe(false); }); + + it('reports a lost history row at error once with the consequence, and on a reduced sink through warn', async () => { + const refusing = { async findOne() { return { id: 'usr-1' }; }, async insert() { throw new Error('refused'); } }; + const errors: string[] = []; + const full = buildSettingAuditWriter(refusing as any, { error: (m) => errors.push(m), warn: () => { throw new Error('warn used'); } }); + await full.write(entry); + await full.write(entry); + expect(errors).toHaveLength(2); + expect(errors[0]).toContain('NOT persisted'); + expect(errors[1]).not.toContain('NOT persisted'); + + const warned: string[] = []; + await buildSettingAuditWriter(refusing as any, { warn: (m) => warned.push(m) }).write(entry); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain('refused'); + }); }); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 409a2619a56..190ee3d9622 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -568,9 +568,15 @@ export class SettingsServicePlugin implements Plugin { */ export function buildSettingAuditWriter( engine: IDataEngine, - logger?: { error?: (message: string) => void }, + logger?: { error?: (message: string) => void; warn: (message: string) => void }, ): SettingsAuditWriter { const eng: any = engine; + // `error` when the host's sink carries it; `warn` is the declared fallback + // channel for a reduced sink, so a lost row is never reported to nobody. + const report = (message: string): void => { + if (logger?.error) logger.error(message); + else logger?.warn(message); + }; // The consequence and the fix ride the first failure only (AGENTS.md, // degradation log levels); every later lost row is still named, briefly. let reportedDurabilityLoss = false; @@ -617,10 +623,10 @@ export function buildSettingAuditWriter( // history row it claims is gone — so `error`, never `warn`. const cause = 'SettingsServicePlugin: setting-audit write failed: ' + (err?.message ?? err); if (reportedDurabilityLoss) { - logger?.error?.(cause); + report(cause); } else { reportedDurabilityLoss = true; - logger?.error?.( + report( cause + ' — settings writes keep succeeding, but their sys_setting_audit history rows are NOT persisted ' + 'and nothing retries them. Fix the cause named here (the actor must be a sys_user; the row must ' + diff --git a/packages/services/service-settings/src/settings-user-reference.pin.test.ts b/packages/services/service-settings/src/settings-user-reference.pin.test.ts index 593bfd4332c..af14f030d0e 100644 --- a/packages/services/service-settings/src/settings-user-reference.pin.test.ts +++ b/packages/services/service-settings/src/settings-user-reference.pin.test.ts @@ -142,7 +142,7 @@ describe('[#21913] the setting-audit writer keeps the dangling actor_id refusal' // A lost history row is a durability loss, reported at `error` (AGENTS.md, // degradation log levels), naming the engine's own refusal. const reported: string[] = []; - await buildSettingAuditWriter(engine as any, { error: (m) => reported.push(m) }).write(entry(GHOST) as any); + await buildSettingAuditWriter(engine as any, { error: (m) => reported.push(m), warn: (m) => reported.push(`warn: ${m}`) }).write(entry(GHOST) as any); expect(reported).toHaveLength(1); expect(reported[0].startsWith(`SettingsServicePlugin: setting-audit write failed: ${before.message}`)).toBe(true); expect(rowsOf('sys_setting_audit').size).toBe(0); @@ -150,7 +150,7 @@ describe('[#21913] the setting-audit writer keeps the dangling actor_id refusal' it('a known actor is written, and an entry naming no actor is unchanged', async () => { const reported: string[] = []; - const writer = buildSettingAuditWriter(engine as any, { error: (m) => reported.push(m) }); + const writer = buildSettingAuditWriter(engine as any, { error: (m) => reported.push(m), warn: (m) => reported.push(`warn: ${m}`) }); await writer.write(entry(userId) as any); await writer.write(entry() as any); expect(reported).toEqual([]); From 02778102c241db46c2a17dfc6deb0cca927d32b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:01:09 +0000 Subject: [PATCH 7/7] fix(service-settings): the setting-audit logger keeps main's accept set and takes an error-carrying sink beside it Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- .changeset/24343-producers-name-organization.md | 2 +- .../src/settings-secret-organization.test.ts | 10 ++++++++++ .../service-settings/src/settings-service-plugin.ts | 12 +++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.changeset/24343-producers-name-organization.md b/.changeset/24343-producers-name-organization.md index 5acaa1490f1..8965d236cbc 100644 --- a/.changeset/24343-producers-name-organization.md +++ b/.changeset/24343-producers-name-organization.md @@ -13,5 +13,5 @@ A write on a tenant-column object with no organization is derived under `single` - **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. - **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. -- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s logger argument reports through `error` when the sink carries it and through its declared `warn` otherwise (the argument stays optional; a sink passed to it declares `warn`, as `PluginContext.logger` does). +- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`. - **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. diff --git a/packages/services/service-settings/src/settings-secret-organization.test.ts b/packages/services/service-settings/src/settings-secret-organization.test.ts index f91eb56b786..19e43841ebe 100644 --- a/packages/services/service-settings/src/settings-secret-organization.test.ts +++ b/packages/services/service-settings/src/settings-secret-organization.test.ts @@ -116,4 +116,14 @@ describe('[ADR-0131 D3/D9] the sys_setting_audit writer stamps the entry\'s orga expect(warned).toHaveLength(1); expect(warned[0]).toContain('refused'); }); + + it('keeps the accept set it had: no sink, an empty sink, and an error-only sink are all taken', async () => { + const refusing = { async findOne() { return { id: 'usr-1' }; }, async insert() { throw new Error('refused'); } }; + await expect(buildSettingAuditWriter(refusing as any).write(entry)).resolves.toBeUndefined(); + await expect(buildSettingAuditWriter(refusing as any, {}).write(entry)).resolves.toBeUndefined(); + const errors: string[] = []; + await buildSettingAuditWriter(refusing as any, { error: (m) => errors.push(m) }).write(entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('NOT persisted'); + }); }); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 190ee3d9622..54cfab51a24 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -568,14 +568,16 @@ export class SettingsServicePlugin implements Plugin { */ export function buildSettingAuditWriter( engine: IDataEngine, - logger?: { error?: (message: string) => void; warn: (message: string) => void }, + // The accept set `main` published (`{ warn? }`), plus a sink that carries + // `error` — declared REQUIRED on that member, so no shape here makes `error` + // optional with no channel beside it. + logger?: { warn?: (message: string) => void } | { error: (message: string) => void; warn?: (message: string) => void }, ): SettingsAuditWriter { const eng: any = engine; - // `error` when the host's sink carries it; `warn` is the declared fallback - // channel for a reduced sink, so a lost row is never reported to nobody. + // `error` when the host's sink carries it (a durability loss), else `warn`. const report = (message: string): void => { - if (logger?.error) logger.error(message); - else logger?.warn(message); + if (logger && 'error' in logger && typeof logger.error === 'function') logger.error(message); + else logger?.warn?.(message); }; // The consequence and the fix ride the first failure only (AGENTS.md, // degradation log levels); every later lost row is still named, briefly.