diff --git a/.changeset/25248-localization-fallback-organization-scope.md b/.changeset/25248-localization-fallback-organization-scope.md new file mode 100644 index 00000000000..b540bad41b0 --- /dev/null +++ b/.changeset/25248-localization-fallback-organization-scope.md @@ -0,0 +1,22 @@ +--- +'@objectstack/core': patch +--- + +fix(core)!: `resolveLocalizationContext`'s direct `sys_setting` fallback reads the caller's organization's rows only, never another organization's row or a row with no organization outside a lean embedding + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** read narrowing (ADR-0131 D8 "no layer has a NULL arm"). It changes which stored row a localization read takes when the settings service cannot answer, not what a metadata author can write, so it ships as `patch` with this banner. + +- FROM: when the settings service was absent or threw (a namespace not yet bound, a failed organization or posture read), `resolveLocalizationContext` read `sys_setting` tenant rows for `timezone` / `locale` / `currency` with no organization filter and took the first row per key. A signed-in user of one organization could be answered with another organization's timezone, locale or currency, or with a row written with no organization, and the success cache could hold that answer. +- TO: the fallback reads with the reach the settings service reads with: + - A caller that names an organization reads that organization's rows, under every posture. + - A caller that names none reads the one organization ADR-0131 D9 derives under `single`. Behind a wall (`group` / `isolated`, the requested posture), at zero or several organizations, or when the organization read fails, it reads nothing and the built-ins `UTC` / `en-US` answer. + - A composition that registers no `sys_organization` (a lean embedding) reads the rows written with no organization, the only rows it can hold. + +**Who is affected.** Only a deployment whose settings service fails a localization read, and only for an organization with no tenant localization row of its own, or a store holding organization-less tenant localization rows. A healthy settings service never reaches this read. + +**The remedy.** Run `os migrate organization-ownership --out PLAN`, review the reported `sys_setting` rows, and save each localization value again from inside the organization it belongs to (Setup → Localization, or `PUT /api/settings/localization` with that organization active). diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index ea843e0cf86..d411331fcde 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -219,9 +219,9 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => { it('reads sys_setting once (all three keys) when no settings service is wired', async () => { const ql = makeCountingQl({ sys_setting: [ - { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }, - { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }, - { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' }, + { namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' }, + { namespace: 'localization', key: 'locale', scope: 'tenant', organization_id: 'o1', value: 'ja-JP' }, + { namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'o1', value: 'JPY' }, ], }); const loc = await resolveLocalizationContext({ ql, tenantId: 'o1' }); @@ -283,7 +283,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => { }; const ql = makeCountingQl({ sys_setting: [ - { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }, + { namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Europe/Paris' }, ], }); const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' }); @@ -505,7 +505,7 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022 // the same staleness property through the invalidation instead of through the // absence of a cache. it('without an engine write-epoch seam, a successful read is never cached: a value change between two calls is visible on the very next call', async () => { - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeCountingQl({ sys_setting: rows }); const first = await resolveLocalizationContext({ ql, tenantId: 'o1' }); expect(first.timezone).toBe('UTC'); @@ -523,11 +523,11 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022 // unconfigured tenant must be visible on the very next call, same as the // value-change case above. (#11966: same seam-absent arm as that case.) it('without a seam, a legitimate empty result is not cached either: the first write for a previously-unconfigured tenant is visible immediately', async () => { - const rows: Array<{ namespace: string; key: string; scope: string; value: string }> = []; + const rows: Array<{ namespace: string; key: string; scope: string; organization_id: string; value: string }> = []; const ql = makeCountingQl({ sys_setting: rows }); const first = await resolveLocalizationContext({ ql, tenantId: 'o1' }); expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined }); - rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }); + rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' }); const second = await resolveLocalizationContext({ ql, tenantId: 'o1' }); expect(second.timezone).toBe('Asia/Tokyo'); expect(ql.counts.sys_setting).toBe(2); @@ -627,7 +627,7 @@ describe('resolveLocalizationContext — only a backend fault populates the memo // has nothing to do with the backend. it('a settings refusal never freezes a SUCCESSFUL direct read: a row change is visible on the very next call', async () => { const settings = makeBindWindowSettings(); // stays unbound → refuses every call - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeCountingQl({ sys_setting: rows }); const first = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' }); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 112b541e8e7..5f29e1c9cf1 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -2386,7 +2386,9 @@ function putLocalizationEntry(ql: object, key: string, entry: LocalizationCacheE * Resolve workspace localization defaults (reference `timezone` / `locale` / * `currency`). Canonical path is the `localization` SettingsManifest (cascade: * platform default → global → tenant); falls back to direct tenant-scoped - * `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws. + * `sys_setting` rows of the caller's organization only (ADR-0131 D8 — see + * {@link localizationFallbackReach}), then the built-ins `UTC` / `en-US`. + * Never throws. * * The DIRECT `sys_setting` read failing outright (backend fault — table * missing, connection refused, etc.) is memoized for @@ -2461,6 +2463,56 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput return value; } +/** + * [ADR-0131 D8/D9] Which `sys_setting` tenant rows the direct localization + * fallback in {@link resolveLocalizationContextUncached} may draw on — the + * reach `SettingsService` reads the same rows with, so the fallback can never + * answer what the service it stands in for would refuse: + * + * - `own` — one organization's rows: the caller's, under every posture; or, + * for a caller that names none where no wall stands, the one organization + * D9's predicate derives ({@link resolveGuestOrganization}). + * - `no-organization-object` — the composition registers no organization + * object (the lean embedding, kept by ADR-0131's D9 note): the rows written + * with no organization, the only rows it can hold. + * - `unowned` — nothing to read: a caller that names no organization behind a + * wall, where D9 derives none (zero organizations, or several), or where + * the derivation could not be asked (a failed organization read). The + * built-in defaults answer. + * + * The posture is the REQUESTED one ({@link resolveTenancyPosture}), as the + * platform-standing anchor in this file reads it: a deployment that asked for + * a wall stays walled here even while running degraded, and the posture costs + * no query. This fallback is reached only when the settings service is absent + * or has just failed, so it takes no inputs that service would supply. + */ +async function localizationFallbackReach( + ql: any, + tenantId: string | undefined, +): Promise< + | { kind: 'own'; organizationId: string } + | { kind: 'no-organization-object' } + | { kind: 'unowned' } +> { + if (typeof tenantId === 'string' && tenantId !== '') return { kind: 'own', organizationId: tenantId }; + try { + const decision = await resolveGuestOrganization(ql, resolveTenancyPosture()); + if (decision.kind === 'derived') return { kind: 'own', organizationId: decision.organizationId }; + if (decision.kind === 'no-organization-object') return { kind: 'no-organization-object' }; + } catch { + // The organization could not be derived (`AuthzStoreUnavailableError`): + // no organization's row is read on a guess. Not the direct read's own + // backend fault, so it does not populate the failure memo (#10221). + } + return { kind: 'unowned' }; +} + +/** A `sys_setting` row's organization, with "no organization" spelled one way. */ +function settingRowOrganizationOf(row: any): string | null { + const organizationId = row?.organization_id; + return typeof organizationId === 'string' && organizationId !== '' ? organizationId : null; +} + async function resolveLocalizationContextUncached( input: ResolveLocalizationInput, ): Promise<{ value: LocalizationResult; backendFailed: boolean }> { @@ -2525,6 +2577,10 @@ async function resolveLocalizationContextUncached( // Settings service unavailable → direct read. Still not a backend fault, // so it does not populate the memo either (#11877). } + // [ADR-0131 D8] The organization this read may draw on — the reach the + // settings service itself reads with (#25226): never another organization's + // row, and a row with no organization only where no organization can exist. + const reach = await localizationFallbackReach(ql, tenantId); // One read for all three keys instead of a query per key (`$in` on `key`). // Inlined (rather than the shared `tryFind`) so a genuine backend fault — // as opposed to a legitimate empty result — is visible to the caller above, @@ -2534,15 +2590,23 @@ async function resolveLocalizationContextUncached( // query-options-erasure guard: that cast is a distinct, counted erasure // site, not implied by an already-`any` receiver). let rows: any[] = []; - if (ql && typeof ql.find === 'function') { + if (reach.kind !== 'unowned' && ql && typeof ql.find === 'function') { + const organization_id = reach.kind === 'own' ? reach.organizationId : null; try { let result = await ql.find('sys_setting', { - where: { namespace: 'localization', key: { $in: ['timezone', 'locale', 'currency'] }, scope: 'tenant' }, + where: { + namespace: 'localization', + key: { $in: ['timezone', 'locale', 'currency'] }, + scope: 'tenant', + organization_id, + }, limit: 10, context: { isSystem: true }, }); if (result && (result as any).value) result = (result as any).value; - rows = Array.isArray(result) ? result : []; + // The organization is compared HERE too, not trusted to the filter — + // the settings service's own rule (`preferredRow`). + rows = (Array.isArray(result) ? result : []).filter((r) => settingRowOrganizationOf(r) === organization_id); } catch { // THE backend fault the failure memo exists for (#10221). backendFailed = true; diff --git a/packages/core/src/security/resolve-localization-cache.test.ts b/packages/core/src/security/resolve-localization-cache.test.ts index dddef90121a..21a42c1cac5 100644 --- a/packages/core/src/security/resolve-localization-cache.test.ts +++ b/packages/core/src/security/resolve-localization-cache.test.ts @@ -150,9 +150,9 @@ function makeSettings(values: Record) { describe('resolveLocalizationContext — success cache identity (#11633 §7 pin 1)', () => { it('a second resolve returns a DEEP-EQUAL answer and issues ZERO reads', async () => { const ql = makeQl([ - { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }, - { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }, - { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' }, + { namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' }, + { namespace: 'localization', key: 'locale', scope: 'tenant', organization_id: 'o1', value: 'ja-JP' }, + { namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'o1', value: 'JPY' }, ]); const first = await resolveLocalizationContext({ ql, tenantId: 'o1' }); const second = await resolveLocalizationContext({ ql, tenantId: 'o1' }); @@ -164,7 +164,7 @@ describe('resolveLocalizationContext — success cache identity (#11633 §7 pin }); it('keys per tenant and per `ql`, so no environment or tenant reads another\'s answer', async () => { - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' }]; const qlA = makeQl(rows); const qlB = makeQl(rows); await resolveLocalizationContext({ ql: qlA, tenantId: 't1' }); @@ -225,7 +225,7 @@ describe('resolveLocalizationContext — a write to a table the answer is read f it('a DIRECT `sys_setting` write, bypassing the settings service entirely, is observed at once', async () => { // The case the backstop exists for: a seeder writes the row through the // engine, so no settings event is ever emitted. - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows); expect((await resolveLocalizationContext({ ql, tenantId: 'o1' })).timezone).toBe('UTC'); await resolveLocalizationContext({ ql, tenantId: 'o1' }); @@ -254,7 +254,7 @@ describe('resolveLocalizationContext — a write to a table the answer is read f it('the retirement lands AFTER the write settles — a read while the write is in flight is dead on arrival', async () => { // The write has entered the chain (its epoch bump already happened) and its // driver step is still running when the resolve reads the old row. - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows); await resolveLocalizationContext({ ql, tenantId: 'other' }); // first sight: wires the seam let land!: () => void; @@ -274,7 +274,7 @@ describe('resolveLocalizationContext — a write to a table the answer is read f it('a peer node\'s hint (`remote`) retires the entry the same way a local write does', async () => { // With the `authz.invalidated` bridge attached, a peer's write arrives as a // local bump — so cross-node convergence rides the same backstop. - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows); await resolveLocalizationContext({ ql, tenantId: 'o1' }); rows[0].value = 'Asia/Tokyo'; @@ -283,7 +283,7 @@ describe('resolveLocalizationContext — a write to a table the answer is read f }); it('a `metadata` bump — no object to judge — retires it as well', async () => { - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows); await resolveLocalizationContext({ ql, tenantId: 'o1' }); rows[0].value = 'Asia/Tokyo'; @@ -301,7 +301,7 @@ describe('resolveLocalizationContext — a write to any OTHER object does not re it.each(['sys_http_delivery', 'sys_notification_delivery', 'showcase_task', 'sys_session'])( 'a `%s` write leaves the entry serving, with zero reads', async (object) => { - const ql = makeQl([{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]); + const ql = makeQl([{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]); const settings = makeSettings({ timezone: 'Asia/Tokyo' }); expect((await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).timezone).toBe('Asia/Tokyo'); await ql.write(object); @@ -348,7 +348,7 @@ describe('resolveLocalizationContext — a write to any OTHER object does not re describe('resolveLocalizationContext — no seam, no success cache', () => { it('a `ql` without a write epoch never caches a success (the pre-#11966 multiset, exactly)', async () => { - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows, { epoch: false }); await resolveLocalizationContext({ ql, tenantId: 'o1' }); rows[0].value = 'Asia/Tokyo'; @@ -358,7 +358,7 @@ describe('resolveLocalizationContext — no seam, no success cache', () => { }); it('a `ql` with the epoch but no middleware seam never caches a success either', async () => { - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows, { middleware: false }); await resolveLocalizationContext({ ql, tenantId: 'o1' }); rows[0].value = 'Asia/Tokyo'; @@ -369,7 +369,7 @@ describe('resolveLocalizationContext — no seam, no success cache', () => { it('a PARTIAL epoch shape is not a seam — `{ current }` alone must not license caching', async () => { // A counter nothing can bump is worse than no counter: it would read as a // live invalidation source and pin the answer for the whole TTL. - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows, { epoch: false }); (ql as Record).writeEpoch = { current: 0 }; await resolveLocalizationContext({ ql, tenantId: 'o1' }); @@ -388,7 +388,7 @@ describe('resolveLocalizationContext — the TTL is the residual bound (#11633 it('`0` means OFF — a real path, and the query multiset returns to the uncached golden', async () => { process.env[TTL_ENV] = '0'; - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows); const registered: unknown[] = []; const register = ql.registerMiddleware as (fn: unknown) => void; @@ -409,7 +409,7 @@ describe('resolveLocalizationContext — the TTL is the residual bound (#11633 // `3OOO` with letter O. Folding it into the 30s default would hand the // operator a LONGER window than the one they were trying to set. process.env[TTL_ENV] = '3OOO'; - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const ql = makeQl(rows); await resolveLocalizationContext({ ql, tenantId: 'o1' }); rows[0].value = 'Asia/Tokyo'; @@ -420,7 +420,7 @@ describe('resolveLocalizationContext — the TTL is the residual bound (#11633 it('an entry expires on the configured bound even when no write ever happens', async () => { vi.useFakeTimers(); try { - const ql = makeQl([{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]); + const ql = makeQl([{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]); await resolveLocalizationContext({ ql, tenantId: 'o1' }); await resolveLocalizationContext({ ql, tenantId: 'o1' }); expect(ql.counts.sys_setting).toBe(1); @@ -511,7 +511,7 @@ describe('resolveLocalizationContext — the failure memo survives what retires // Migration lands; the memo is still standing, so nothing changes yet. broken = false; - rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }); + rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' }); await resolveLocalizationContext({ ql, tenantId: 'o1' }); expect(counts.sys_setting).toBe(1); @@ -541,7 +541,7 @@ describe('resolveLocalizationContext — a write DURING the read must not be swa // write lands after the query was issued and before the answer is stored. // Stamping the entry with the post-read epoch would make that staleness // permanent, invisible, and unbounded by any further event. - const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }]; + const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }]; const counts = { sys_setting: 0 }; const seams = engineSeams(); let raceOnce = true; diff --git a/packages/core/src/security/resolve-localization-fallback-organization.pin.test.ts b/packages/core/src/security/resolve-localization-fallback-organization.pin.test.ts new file mode 100644 index 00000000000..d65a8cf5143 --- /dev/null +++ b/packages/core/src/security/resolve-localization-fallback-organization.pin.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D8/D9] `resolveLocalizationContext`'s DIRECT `sys_setting` + * fallback — the read taken when the settings service is absent or throws — + * draws on the caller's organization's rows only, the reach the settings + * service itself reads with: + * + * - a caller that names organization X reads X's rows, under every posture; + * - a caller that names none reads the organization D9 derives under + * `single`, and nothing behind a wall, at zero or several organizations, or + * when the derivation cannot be asked; + * - a row with no organization answers only a composition that registers no + * `sys_organization` (the lean embedding). + * + * Before this, the fallback read every organization's tenant rows and took the + * first per key: a user of one organization was answered with another's + * timezone / locale / currency whenever the settings service failed. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveLocalizationContext } from './resolve-authz-context.js'; +import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; + +type Row = Record; + +function matches(row: Row, where: Record | undefined): boolean { + if (!where) return true; + return Object.entries(where).every(([field, cond]) => { + if (field.startsWith('$')) throw new Error(`this double does not implement the where combinator ${field}`); + const value = row[field] ?? null; + if (cond !== null && typeof cond === 'object') { + const operators = Object.keys(cond); + if (operators.length !== 1 || operators[0] !== '$in') { + throw new Error(`this double does not implement the where operator(s) ${operators.join(', ')} on ${field}`); + } + return (cond as any).$in.includes(value); + } + return value === (cond ?? null); + }); +} + +/** + * An engine double that applies the `where` it is handed (`null` matching an + * absent column, as SQL `IS NULL`), records every `sys_setting` query, and + * reports `sys_organization` registered unless told otherwise. + */ +function makeQl( + tables: Record, + opts: { organizationObject?: boolean } = {}, +) { + const settingQueries: Array | undefined> = []; + const ql = { + settingQueries, + registry: { + getObject: (name: string) => + name === 'sys_organization' && opts.organizationObject === false ? undefined : { name }, + }, + find: async (object: string, options: any) => { + if (object === 'sys_setting') settingQueries.push(options?.where); + const rows = (tables[object] ?? []).filter((r) => matches(r, options?.where)); + return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; + }, + }; + return ql; +} + +const tz = (organization_id: string | null, value: string): Row => ({ + namespace: 'localization', + key: 'timezone', + scope: 'tenant', + value, + ...(organization_id === null ? {} : { organization_id }), +}); + +/** Two organizations, each holding its own tenant row — the OTHER one first. */ +const TWO_ORGS = { + sys_organization: [{ id: 'org_a' }, { id: 'org_b' }], + sys_setting: [tz('org_b', 'Asia/Tokyo'), tz('org_a', 'Europe/Paris')], +}; + +const throwingSettings = (error: unknown) => ({ + get: async () => { throw error; }, + getMany: async () => { throw error; }, +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('the direct localization fallback reads the caller organization only (ADR-0131 D8)', () => { + it('settings service absent: each organization reads its own row, never the first row of another', async () => { + const ql = makeQl(TWO_ORGS); + expect((await resolveLocalizationContext({ ql, tenantId: 'org_a' })).timezone).toBe('Europe/Paris'); + expect((await resolveLocalizationContext({ ql, tenantId: 'org_b' })).timezone).toBe('Asia/Tokyo'); + // The organization is in the query itself, not only compared afterwards. + expect(ql.settingQueries.map((w) => w?.organization_id)).toEqual(['org_a', 'org_b']); + }); + + it('settings service throwing (the organization source\'s AuthzStoreUnavailableError) reads the same way', async () => { + const settings = throwingSettings(new AuthzStoreUnavailableError('sys_organization', new Error('down'))); + const ql = makeQl(TWO_ORGS); + expect((await resolveLocalizationContext({ ql, settings, tenantId: 'org_a' })).timezone).toBe('Europe/Paris'); + }); + + it('an organization with no row of its own gets the built-in default, not another organization\'s row', async () => { + const ql = makeQl({ sys_organization: [{ id: 'org_a' }, { id: 'org_c' }], sys_setting: [tz('org_a', 'Europe/Paris')] }); + expect((await resolveLocalizationContext({ ql, tenantId: 'org_c' })).timezone).toBe('UTC'); + }); + + it('the organization is compared in memory too — an engine that drops the filter still cannot leak', async () => { + const base = makeQl(TWO_ORGS); + // Drops the organization condition, as an engine that ignored it would. + const ql = { ...base, find: async (object: string, options: any) => { + const { organization_id: _dropped, ...where } = options?.where ?? {}; + return base.find(object, { ...options, where }); + } }; + expect((await resolveLocalizationContext({ ql, tenantId: 'org_a' })).timezone).toBe('Europe/Paris'); + }); + + it('a row with no organization is not an organization\'s fallback', async () => { + const ql = makeQl({ sys_organization: [{ id: 'org_a' }], sys_setting: [tz(null, 'America/New_York')] }); + expect((await resolveLocalizationContext({ ql, tenantId: 'org_a' })).timezone).toBe('UTC'); + }); +}); + +describe('a caller that names no organization (ADR-0131 D9)', () => { + it('under `single` with one organization, reads the organization D9 derives', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const ql = makeQl({ + sys_organization: [{ id: 'org_a' }], + sys_setting: [tz(null, 'America/New_York'), tz('org_a', 'Europe/Paris')], + }); + expect((await resolveLocalizationContext({ ql })).timezone).toBe('Europe/Paris'); + }); + + it('under `single` with several organizations, reads nothing', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const ql = makeQl({ ...TWO_ORGS, sys_setting: [...TWO_ORGS.sys_setting, tz(null, 'America/New_York')] }); + expect((await resolveLocalizationContext({ ql })).timezone).toBe('UTC'); + expect(ql.settingQueries).toEqual([]); + }); + + it('under `single` with no organization yet, reads nothing', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const ql = makeQl({ sys_organization: [], sys_setting: [tz(null, 'America/New_York')] }); + expect((await resolveLocalizationContext({ ql })).timezone).toBe('UTC'); + expect(ql.settingQueries).toEqual([]); + }); + + for (const posture of ['isolated', 'group'] as const) { + it(`behind a wall (\`${posture}\`), reads nothing — not even with one organization`, async () => { + vi.stubEnv('OS_TENANCY_POSTURE', posture); + const ql = makeQl({ sys_organization: [{ id: 'org_a' }], sys_setting: [tz('org_a', 'Europe/Paris'), tz(null, 'America/New_York')] }); + expect((await resolveLocalizationContext({ ql })).timezone).toBe('UTC'); + expect(ql.settingQueries).toEqual([]); + }); + + it(`behind a wall (\`${posture}\`), a caller that names its organization still reads its own row`, async () => { + vi.stubEnv('OS_TENANCY_POSTURE', posture); + const ql = makeQl(TWO_ORGS); + expect((await resolveLocalizationContext({ ql, tenantId: 'org_b' })).timezone).toBe('Asia/Tokyo'); + }); + } + + it('an organization read that fails derives nothing and reads nothing — never a guess', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const base = makeQl({ sys_organization: [{ id: 'org_a' }], sys_setting: [tz('org_a', 'Europe/Paris')] }); + const ql = { ...base, find: async (object: string, options: any) => { + if (object === 'sys_organization') throw new Error('connection reset'); + return base.find(object, options); + } }; + const settings = throwingSettings(new Error('settings down')); + expect((await resolveLocalizationContext({ ql, settings })).timezone).toBe('UTC'); + expect(base.settingQueries).toEqual([]); + }); +}); + +describe('the lean embedding — no `sys_organization` registered (#25133 letter A)', () => { + it('reads the rows written with no organization, and only those', async () => { + const ql = makeQl( + { sys_setting: [tz('org_a', 'Europe/Paris'), tz(null, 'America/New_York')] }, + { organizationObject: false }, + ); + expect((await resolveLocalizationContext({ ql })).timezone).toBe('America/New_York'); + expect(ql.settingQueries.map((w) => w?.organization_id)).toEqual([null]); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts index 0c00b4a615c..949d1bd26e7 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts @@ -132,14 +132,15 @@ function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, setti sys_user: [{ id: USER, email: 'lang@example.com', locale: storedLocale }], // The endpoint reads all three `localization` keys in ONE `$in` query, // so the table carries whichever of them the fixture configured — and - // nothing for the rest. + // nothing for the rest. Each row is the caller's organization's: the + // fallback reads that organization's rows only (ADR-0131 D8). sys_setting: ([ ['locale', settingLocale], ['timezone', settingTimezone], ['currency', settingCurrency], ] as Array<[string, string | undefined]>) .filter(([, value]) => value !== undefined) - .map(([key, value]) => ({ namespace: 'localization', key, value, scope: 'tenant' })), + .map(([key, value]) => ({ namespace: 'localization', key, value, scope: 'tenant', organization_id: ORG })), }; const ql = { find: async (object: string, opts: any) => { diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index ec2e8ce80d0..51f046dca31 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -190,7 +190,10 @@ describe('resolveExecutionContext — API key verify path', () => { */ describe('resolveExecutionContext — localization (timezone + locale)', () => { const RAW = 'osk_tz'; - const apiKeyRows = [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', expires_at: FUTURE }]; + // The key's organization is the caller's: the direct `sys_setting` fallback + // reads that organization's rows only (ADR-0131 D8). + const ORG = 'org_tz'; + const apiKeyRows = [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', active_organization_id: ORG, expires_at: FUTURE }]; /** Fake `settings` service doing the 4-tier `get(namespace, key)` resolution. */ function makeSettings(values: Record) { @@ -259,9 +262,9 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => { it('falls back to a direct tenant-scoped sys_setting read when no settings service', async () => { const ctx = await resolveExecutionContext(makeTzOpts({ settings: [ - { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }, - { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }, - { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' }, + { namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: ORG, value: 'Asia/Tokyo' }, + { namespace: 'localization', key: 'locale', scope: 'tenant', organization_id: ORG, value: 'ja-JP' }, + { namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: ORG, value: 'JPY' }, ], })); expect(ctx.timezone).toBe('Asia/Tokyo'); @@ -272,7 +275,7 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => { it('ignores per-user sys_user_preference rows (organization-level only)', async () => { const ctx = await resolveExecutionContext(makeTzOpts({ prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }], - settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }], + settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: ORG, value: 'Europe/Paris' }], })); expect(ctx.timezone).toBe('Europe/Paris'); // org default, NOT the user pref });