Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/25248-localization-fallback-organization-scope.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) Which stored `sys_setting` row the localization fallback takes changes. No key of any metadata schema is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite and no tombstone is needed. No export, type, error code or route is removed or renamed: `resolveLocalizationContext` keeps its signature. The remedy for the rows this stops reading is the existing organization-ownership ceremony, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this read (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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).
16 changes: 8 additions & 8 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand Down Expand Up @@ -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' });
Expand Down
72 changes: 68 additions & 4 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }> {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Loading
Loading