From 32abc59783a7029af0dd0f8146eed181b812e7bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:46:41 +0000 Subject: [PATCH 1/6] fix(service-settings): carry the organization in settings row identity, reads and the generic read door Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ --- .../src/settings-read-door.ts | 105 +++++ .../src/settings-service-plugin.ts | 14 + .../service-settings/src/settings-service.ts | 359 ++++++++++++++++-- .../src/settings-service.types.ts | 8 + 4 files changed, 446 insertions(+), 40 deletions(-) create mode 100644 packages/services/service-settings/src/settings-read-door.ts diff --git a/packages/services/service-settings/src/settings-read-door.ts b/packages/services/service-settings/src/settings-read-door.ts new file mode 100644 index 00000000000..224e9a95fe5 --- /dev/null +++ b/packages/services/service-settings/src/settings-read-door.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The GENERIC read door of the settings stores — each namespace's declared + * `readPermission`, applied to the data API's read of the rows that hold it. + * + * ## What it closes + * + * The settings door (`GET /api/settings/:namespace`) refuses a caller who lacks + * the manifest's `readPermission` (`SettingsService.assertPermitted`). The same + * values, and their audit trail, are rows of `sys_setting`, + * `sys_setting_audit` and `sys_platform_setting`, and each of those objects + * exposes `get` / `list` on the generic data API as a diagnostic grid. That + * door applied the OBJECT's grants only — never the namespace's — so a + * principal the settings door refused read the namespace's rows there instead. + * + * ## The seam + * + * An engine middleware the settings plugin registers for exactly those three + * objects. It ANDs a `namespace` predicate into the operation's `where` for + * every READ (`find`, `findOne`, `count`, `aggregate`) — a filter, never a + * pass over the result, so a count, an aggregate and a page see exactly the + * rows a list returns, and a by-id read of a withheld row answers "not found". + * The predicate is {@link SettingsService.namespaceReadScope}: the same + * capability table, from the same `requiredCapability`, the settings door + * enforces — one rule at two doors. + * + * What it does not touch: + * + * - a SYSTEM context — the settings service's own plumbing and every other + * platform reader that elevates on purpose; + * - writes — the three objects expose no write on the data API, and the + * settings door owns every write. + * + * A context that is not system and carries no capability holds none, so it + * reads no namespace at all: the deny baseline, not a hand-through. + */ + +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { SettingsService } from './settings-service.js'; + +/** The objects whose rows carry a settings `namespace`. */ +export const SETTINGS_READ_DOOR_OBJECTS: readonly string[] = Object.freeze([ + 'sys_setting', + 'sys_setting_audit', + 'sys_platform_setting', +]); + +/** The engine verbs that read rows — the four that carry a `where` to scope. */ +const READ_OPERATIONS: ReadonlySet = new Set(['find', 'findOne', 'count', 'aggregate']); + +/** The engine middleware signature, as `IObjectQLEngine.registerMiddleware` declares it. */ +type EngineMiddleware = Parameters[0]; + +/** The capabilities a principal holds, read the way the settings door reads them. */ +function capabilitiesOf(context: Record | undefined): string[] { + const out: string[] = []; + for (const field of ['systemPermissions', 'permissions'] as const) { + const held = context?.[field]; + if (Array.isArray(held)) for (const c of held) if (typeof c === 'string') out.push(c); + } + return out; +} + +/** + * The middleware. Exported for the pin that drives it on a real engine; the + * plugin installs it through {@link registerSettingsReadDoor}. + */ +export function settingsReadDoorMiddleware(service: SettingsService): EngineMiddleware { + return async (opCtx: any, next: () => Promise): Promise => { + if (!READ_OPERATIONS.has(opCtx?.operation)) return next(); + const context = opCtx.context as Record | undefined; + if (context?.isSystem === true) return next(); + const ast = opCtx.ast as { where?: unknown } | undefined; + if (!ast || typeof ast !== 'object') { + // Every engine read carries its query on `opCtx.ast`; one that does not + // cannot be scoped, so it is refused rather than served unscoped. + throw new Error( + `[SettingsService] refused a '${String(opCtx?.operation)}' on '${String(opCtx?.object)}': ` + + 'the operation carries no query to scope by namespace, so the namespace read ' + + 'permissions this object\'s rows are governed by could not be applied.', + ); + } + const scope = service.namespaceReadScope(capabilitiesOf(context)); + if (scope) { + ast.where = ast.where ? { $and: [ast.where, scope] } : scope; + } + return next(); + }; +} + +/** + * Install the read door on the engine. Answers `false` when the engine offers + * no middleware seam — the caller reports that, loudly, because the generic + * read door is then ungated by namespace. + */ +export function registerSettingsReadDoor(engine: unknown, service: SettingsService): boolean { + const register = (engine as Partial | null | undefined)?.registerMiddleware; + if (typeof register !== 'function') return false; + const middleware = settingsReadDoorMiddleware(service); + for (const object of SETTINGS_READ_DOOR_OBJECTS) { + register.call(engine, middleware, { object }); + } + return true; +} diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index c15ba9a4583..d5399fd591d 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -24,6 +24,7 @@ import { LocalCryptoProvider } from './local-crypto-provider.js'; import { buildConfigChangeAuditSink } from './config-change-audit.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { registerSettingsRoutes } from './settings-routes.js'; +import { registerSettingsReadDoor } from './settings-read-door.js'; import { settingsObjects, settingsPluginManifestHeader, @@ -233,8 +234,21 @@ export class SettingsServicePlugin implements Plugin { secretStore: this.buildSecretStore(engine), auditWriter: this.buildAuditWriter(ctx, engine), cryptoProvider: this.opts.cryptoProvider ?? new LocalCryptoProvider(), + // The posture IN FORCE, read the way this plugin's HTTP door reads + // it — so the service and the door it sits behind agree on whether + // organizations are walled off. Read live at each use. + tenancyPosture: () => this.resolveAdmissionTenancyPosture(ctx), }, ); + // Each namespace's `readPermission`, applied at the generic data + // API's read of the settings stores too (see `settings-read-door.ts`). + if (!registerSettingsReadDoor(engine, this.service!)) { + ctx.logger?.warn?.( + 'SettingsServicePlugin: the objectql engine offers no middleware seam, so the data API ' + + 'read of sys_setting / sys_setting_audit / sys_platform_setting is NOT gated by each ' + + "settings namespace's readPermission — only by the objects' own grants.", + ); + } } else { // No `objectql` on this kernel — the OPTIONAL dependency this plugin // declares is genuinely absent, so no engine is ever coming and the diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 3b142fbc142..3139815c736 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -38,6 +38,9 @@ import { // one thing about one domain; every other refusal here still carries its own // hand-written sentence, unchanged. import { renderValidationMessage } from '@objectstack/spec/system'; +// The one predicate every layer asks "does this posture wall organizations +// off from each other?" with (`group` / `isolated`), never a re-spelled list. +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { @@ -121,6 +124,70 @@ function callerUserIdOf(ctx: SettingsContext): string | null { return typeof ctx.userId === 'string' && ctx.userId !== '' ? ctx.userId : null; } +/** + * The caller's organization, or `null` when the context names none. + * + * `sys_setting` declares its row identity as `(organization_id, namespace, + * key, scope, user_id)`: a `tenant` row is one row PER ORGANIZATION, and a + * `user` row belongs to one user in one organization. This service reads and + * writes that table under its own system context, which carries no + * organization, so neither the driver's tenant scope nor the security layer's + * organization wall ever reaches those calls. The organization therefore has + * to be carried here, explicitly, in every `where` and every written row — it + * is taken from `SettingsContext.tenantId`, which the HTTP door fills from the + * vetted authorization context. An absent or empty id names no organization. + */ +function callerOrganizationIdOf(ctx: SettingsContext): string | null { + return typeof ctx.tenantId === 'string' && ctx.tenantId !== '' ? ctx.tenantId : null; +} + +/** A row's organization, with "no organization" spelled one way. */ +function organizationOf(row: SettingsRow): string | null { + return typeof row.organization_id === 'string' && row.organization_id !== '' + ? row.organization_id + : null; +} + +/** + * Which `sys_setting` rows a caller's `tenant` and `user` rungs may draw on. + * + * - `own` — the caller names an organization: that organization's rows, plus + * the rows written with no organization. The cascade prefers the + * organization's own row ({@link SettingsService.preferredRow}); an + * organization-less row is only the fallback, the way the global rung is + * for every organization. + * - `organization-less` — no organization, under a posture that walls + * organizations off from each other (`group` / `isolated`): only the rows + * written with no organization. Another organization's row has no claim on + * a caller that is in none of them. + * - `unwalled` — no organization, under `single` (or with no posture source + * wired): every row, as before. A single-organization deployment has no + * boundary to keep, and its process-wide readers (an email brand name, a + * boot-time locale) read without naming the one organization they serve. + */ +type OrganizationReach = + | { kind: 'own'; organizationId: string } + | { kind: 'organization-less' } + | { kind: 'unwalled' }; + +/** + * Where the service learns the tenancy posture IN FORCE — the `tenancy` + * service's answer, which the plugin supplies at bind time. `undefined` means + * the composition reports no posture (no `tenancy` service registered). + */ +export type SettingsTenancyPostureSource = () => + | TenancyPosture + | undefined + | Promise; + +/** + * The capability a namespace's read requires when its manifest declares no + * `readPermission` — and therefore the one a row of a namespace with NO + * registered manifest requires at the generic read door. One constant, read by + * both, so the two defaults cannot drift apart. + */ +const DEFAULT_READ_CAPABILITY = 'setup.access'; + /** * Value-bearing specifier types — drives which entries we expect to * find in the K/V store. Keeps the resolver in sync with the spec @@ -632,6 +699,12 @@ export class SettingsService { * before this flag existed. */ private engineBindPending: boolean; + /** + * The tenancy posture source, bound with the engine. Unset — a service + * constructed directly, or one whose host reports no posture — reads as a + * deployment with no organization wall. See {@link organizationWallStands}. + */ + private tenancyPosture?: SettingsTenancyPostureSource; /** Change subscribers, optionally scoped to a namespace. */ private readonly subscribers = new Set<{ ns?: string; @@ -667,6 +740,12 @@ export class SettingsService { secretStore?: import('./settings-service.types.js').SettingsSecretStore; auditWriter?: import('./settings-service.types.js').SettingsAuditWriter; cryptoProvider?: import('@objectstack/spec/contracts').ICryptoProvider; + /** + * The tenancy posture IN FORCE. Read when a caller names no + * organization: under a walled posture such a caller reads only the + * organization-less rows and may not write a `tenant` key at all. + */ + tenancyPosture?: SettingsTenancyPostureSource; }, ): void { this.engine = engine; @@ -677,6 +756,7 @@ export class SettingsService { if (extras?.secretStore) this.secretStore = extras.secretStore; if (extras?.auditWriter) this.auditWriter = extras.auditWriter; if (extras?.cryptoProvider) this.cryptoProvider = extras.cryptoProvider; + if (extras?.tenancyPosture) this.tenancyPosture = extras.tenancyPosture; // Notify subscribers that the persistent store is now available so // late-binders (e.g. AIServicePlugin's adapter rebuild on saved @@ -1212,8 +1292,8 @@ export class SettingsService { */ private requiredCapability(m: SettingsManifest, op: 'read' | 'write'): string { return op === 'read' - ? (m.readPermission ?? 'setup.access') - : (m.writePermission ?? m.readPermission ?? 'setup.access'); + ? (m.readPermission ?? DEFAULT_READ_CAPABILITY) + : (m.writePermission ?? m.readPermission ?? DEFAULT_READ_CAPABILITY); } /** @@ -1242,6 +1322,41 @@ export class SettingsService { return all.filter((m) => perms.has(this.requiredCapability(m, 'read'))); } + /** + * The `namespace` predicate the generic read door ANDs onto a principal's + * read of a settings store (`sys_setting`, `sys_setting_audit`, + * `sys_platform_setting`), given the capabilities the principal holds — or + * `null` when it may read every namespace there is. + * + * The settings door has always applied a namespace's `readPermission` + * ({@link assertPermitted}); the data API's read of the same rows did not, so + * a principal the settings door refuses could list the namespace's rows, and + * its audit trail, there instead. This is the same rule, from the same + * {@link requiredCapability}, at the other door. + * + * A predicate rather than a filter over the result, so `count`, `aggregate` + * and paging see exactly the rows a `find` returns. Two spellings, both total: + * + * - the principal holds {@link DEFAULT_READ_CAPABILITY}: every namespace + * EXCEPT the registered ones whose capability it lacks. A row whose + * namespace has no registered manifest declares no `readPermission`, so it + * reads at the default — the one a manifest without the property gets. + * - it does not: ONLY the registered namespaces whose capability it holds. + * An empty list is the deny answer (`$in: []` matches no row). + */ + namespaceReadScope(held: Iterable): Record | null { + const capabilities = new Set(held); + const readable: string[] = []; + const withheld: string[] = []; + for (const [namespace, reg] of this.registry) { + (capabilities.has(this.requiredCapability(reg.manifest, 'read')) ? readable : withheld).push(namespace); + } + if (capabilities.has(DEFAULT_READ_CAPABILITY)) { + return withheld.length === 0 ? null : { namespace: { $nin: withheld } }; + } + return { namespace: { $in: readable } }; + } + /** Register a handler for an `action_button` declared in a manifest. */ registerAction(namespace: string, actionId: string, handler: SettingsActionHandler): void { const reg = this.registry.get(namespace); @@ -1298,10 +1413,12 @@ export class SettingsService { const scope = reg.scopes.get(key)!; // For 'user' scope we pre-filter by user_id; for 'tenant' and 'global' // we load everything for the namespace and pick the right row below. - // The user rung's pick compares the owner too (see `resolveKeyFromRows`). + // The user rung's pick compares the owner too (see `resolveKeyFromRows`), + // and both rungs read only the rows the caller's organization reaches. const userId = scope === 'user' ? callerUserIdOf(ctx) : null; - const rows = await this.loadRows(namespace, userId); - return this.resolveKeyFromRows(reg, key, scope, rows, userId); + const reach = await this.organizationReachOf(ctx); + const rows = await this.loadRows(namespace, userId, reach); + return this.resolveKeyFromRows(reg, key, scope, rows, userId, reach); } /** @@ -1401,14 +1518,17 @@ export class SettingsService { ...(userKeys.length > 0 ? [userId] : []), ...(otherKeys.length > 0 ? [null] : []), ]; - const sets = await this.loadRowSets(namespace, groups); + // ONE organization reach for the whole call — every key is resolved for + // the same caller, so the grouping above is by user only. + const reach = await this.organizationReachOf(ctx); + const sets = await this.loadRowSets(namespace, groups, reach); const userRows = userKeys.length > 0 ? sets[0] : []; const otherRows = otherKeys.length > 0 ? sets[sets.length - 1] : []; for (const { key, scope } of userKeys) { - out[key] = await this.resolveKeyFromRows(reg, key, scope, userRows, userId); + out[key] = await this.resolveKeyFromRows(reg, key, scope, userRows, userId, reach); } for (const { key, scope } of otherKeys) { - out[key] = await this.resolveKeyFromRows(reg, key, scope, otherRows, null); + out[key] = await this.resolveKeyFromRows(reg, key, scope, otherRows, null, reach); } } return out; @@ -1424,6 +1544,11 @@ export class SettingsService { * that names no user and for every key not declared `scope: 'user'`. The user * rung answers only a row whose `user_id` equals it; with `null` there is no * user rung at all. + * + * `reach` is the caller's organization reach ({@link organizationReachOf}). + * The tenant and user rungs each take ONE row, the caller organization's own + * when it has one ({@link preferredRow}) — never whichever row a positional + * `find` happens to meet first. */ private async resolveKeyFromRows( reg: RegisteredManifest, @@ -1431,6 +1556,7 @@ export class SettingsService { scope: SpecifierScope, rows: SettingsRow[], userId: string | null, + reach: OrganizationReach, ): Promise> { // 2. cascade walk — OS_* env (handled by callers) > global > tenant > user > default // @@ -1451,7 +1577,10 @@ export class SettingsService { } if (scope === 'tenant' || scope === 'user') { - const tenantRow = rows.find((r) => r.key === key && r.scope === 'tenant'); + const tenantRow = this.preferredRow( + rows.filter((r) => r.key === key && r.scope === 'tenant'), + reach, + ); if (tenantRow) { chain.push({ scope: 'tenant', @@ -1467,7 +1596,10 @@ export class SettingsService { // that names no user gets no user rung: the walk falls through to tenant, // global, then the default, exactly as if no user row existed. if (scope === 'user' && userId !== null) { - const userRow = rows.find((r) => r.key === key && r.scope === 'user' && r.user_id === userId); + const userRow = this.preferredRow( + rows.filter((r) => r.key === key && r.scope === 'user' && r.user_id === userId), + reach, + ); if (userRow) { chain.push({ scope: 'user', @@ -1494,6 +1626,60 @@ export class SettingsService { }; } + /** + * The ONE row a rung takes from its candidates (rows of one key on one rung, + * already narrowed to the caller's owner for the user rung), by the caller's + * organization reach: + * + * - `own` — the caller organization's row; failing that, the row written + * with no organization. The organization is compared HERE, not trusted to + * the load's filter, the same way the user rung compares its owner. + * - `organization-less` — only a row written with no organization. + * - `unwalled` — a row an organization wrote, ahead of one written with + * none: on a single-organization deployment that is the one organization's + * current value, while an organization-less row predates its first write. + * + * Shared by the cascade walk and the lock pre-flight in {@link setMany}, so + * the row that answers a read is the row whose lock refuses a write. + */ + private preferredRow(candidates: SettingsRow[], reach: OrganizationReach): SettingsRow | undefined { + if (reach.kind === 'own') { + return ( + candidates.find((r) => organizationOf(r) === reach.organizationId) ?? + candidates.find((r) => organizationOf(r) === null) + ); + } + if (reach.kind === 'organization-less') { + return candidates.find((r) => organizationOf(r) === null); + } + return candidates.find((r) => organizationOf(r) !== null) ?? candidates[0]; + } + + /** The caller's organization reach — see {@link OrganizationReach}. */ + private async organizationReachOf(ctx: SettingsContext): Promise { + const organizationId = callerOrganizationIdOf(ctx); + if (organizationId !== null) return { kind: 'own', organizationId }; + return (await this.organizationWallStands()) ? { kind: 'organization-less' } : { kind: 'unwalled' }; + } + + /** + * Does the tenancy posture IN FORCE wall organizations off from each other? + * + * Asked only for a caller that names no organization — a caller that names + * one is read and written as that organization under every posture. No + * posture source, or a source that reports none, is a deployment with no + * wall this service can know of: it keeps the reading it always had. A + * source that THROWS is not caught: the posture is an input to what this + * caller may read and write, and a decision taken without it would be a + * guess. + */ + private async organizationWallStands(): Promise { + const source = this.tenancyPosture; + if (!source) return false; + const posture = await source(); + return posture !== undefined && postureEnforcesWall(posture); + } + /** Resolve every value in a namespace + return the manifest. */ async getNamespace( namespace: string, @@ -1726,6 +1912,31 @@ export class SettingsService { }; } + /** + * The per-key entry {@link setMany} refuses a `tenant`-scoped key with when + * the caller names no organization under a walled posture. Same vocabulary + * as {@link ownerlessUserKeyError}, for the same reason one level up: a + * `tenant` row is one row per organization, and under a wall a row naming + * none belongs to no organization's settings — it would only ever be read as + * every organization's fallback, which is not what any caller asked to write. + */ + private organizationlessTenantKeyError(reg: RegisteredManifest, key: string): FieldError { + const spec = ((reg.manifest.specifiers ?? []) as Array>) + .find((s) => s.key === key); + const label = typeof spec?.label === 'string' ? spec.label : key; + return { + field: key, + code: 'invalid_value', + message: + `${label} is a per-organization setting, and this write names no organization to store ` + + 'it for. Make the write from inside the organization it belongs to (the caller\'s active ' + + "organization, `SettingsContext.tenantId`); a value meant for every organization belongs " + + "on a key declared at scope 'global'.", + label, + constraint: { scope: 'tenant' }, + }; + } + /** Persist a single key. Throws SettingsLockedError when env-locked. */ async set( namespace: string, @@ -1762,9 +1973,16 @@ export class SettingsService { // missing — the write path previously trusted a spoofable header identity). this.assertPermitted(reg.manifest, 'write', ctx); - // Pre-flight: reject the whole batch if any key is locked or unknown, or - // is user-scoped while the caller names no user (collected, refused below). + // Pre-flight: reject the whole batch if any key is locked or unknown, is + // user-scoped while the caller names no user, or is tenant-scoped while the + // caller names no organization under a walled posture (both collected, + // refused below). const callerUserId = callerUserIdOf(ctx); + const callerOrganizationId = callerOrganizationIdOf(ctx); + // The reach every load below reads with — resolved once for the batch, and + // only when a key below the global rung asks for it: a `global` key has no + // upper rung to be locked by and no organization to be stored for. + let reach: OrganizationReach | undefined; const ownerless: FieldError[] = []; for (const key of Object.keys(patch)) { if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key); @@ -1784,14 +2002,29 @@ export class SettingsService { // scope as the lock is still permitted (i.e. a platform admin // can edit a globally-locked value; a tenant admin cannot). const scope = reg.scopes.get(key)!; + if (scope === 'global') continue; if (scope === 'user' && callerUserId === null) { ownerless.push(this.ownerlessUserKeyError(reg, key)); continue; } - const rows = await this.loadRows(namespace, scope === 'user' ? callerUserId : null); - const upper = rows.find( - (r) => - r.key === key && + reach ??= await this.organizationReachOf(ctx); + // Under a walled posture an organization-less caller reaches only the + // organization-less rows (see `organizationReachOf`), so `reach` already + // carries the posture answer: no second read of it per key. + if (scope === 'tenant' && callerOrganizationId === null && reach.kind === 'organization-less') { + ownerless.push(this.organizationlessTenantKeyError(reg, key)); + continue; + } + const rows = await this.loadRows(namespace, scope === 'user' ? callerUserId : null, reach); + // The upper rungs THIS caller's cascade reads — the global row and the + // tenant row it prefers — so another organization's lock locks nothing + // here, and the row that answers the read is the row whose lock counts. + const upper = [ + rows.find((r) => r.key === key && r.scope === 'global'), + this.preferredRow(rows.filter((r) => r.key === key && r.scope === 'tenant'), reach), + ].find( + (r): r is SettingsRow => + r !== undefined && r.locked === true && this.scopeRank(r.scope) < this.scopeRank(scope), ); @@ -1833,9 +2066,13 @@ export class SettingsService { // global rows are deployment-wide and land in `sys_platform_setting`, // which has no organization and no user column (ADR-0131 D7, see // `rowIdentity`); user rows pin to the caller's user id, which the - // pre-flight above guarantees is present; tenant rows leave user_id - // null and let the engine's tenant scoping fill in tenant_id from ctx. + // pre-flight above guarantees is present. Tenant and user rows both + // carry the caller's organization, HERE: this write runs under the + // service's system context, which names no organization, so nothing + // downstream would stamp one — the row would land organization-less, in + // the one bucket every organization reads and writes. const userId = scope === 'user' ? callerUserId : null; + const organizationId = scope === 'global' ? null : callerOrganizationId; const isEncrypted = reg.encryptedKeys.has(key); const isNull = rawValue === null || typeof rawValue === 'undefined'; @@ -1897,6 +2134,7 @@ export class SettingsService { key, scope, user_id: userId, + organization_id: organizationId, value: storedValue, value_enc: storedEnc, encrypted: isEncrypted, @@ -2424,8 +2662,12 @@ export class SettingsService { // --------------------------------------------------------------------- /** The namespace's rows every rung of one key's cascade can draw on. */ - private async loadRows(namespace: string, userId: string | null): Promise { - return (await this.loadRowSets(namespace, [userId]))[0]; + private async loadRows( + namespace: string, + userId: string | null, + reach: OrganizationReach, + ): Promise { + return (await this.loadRowSets(namespace, [userId], reach))[0]; } /** @@ -2442,11 +2684,12 @@ export class SettingsService { private async loadRowSets( namespace: string, userIds: ReadonlyArray, + reach: OrganizationReach, ): Promise { if (this.engine) { const [globalRows, ...scopedSets] = await Promise.all([ this.loadGlobalRows(namespace), - ...userIds.map((userId) => this.loadScopedRows(namespace, userId)), + ...userIds.map((userId) => this.loadScopedRows(namespace, userId, reach)), ]); return scopedSets.map((scoped) => [...globalRows, ...scoped]); } @@ -2460,16 +2703,27 @@ export class SettingsService { // scope), so it must not be reported. this.reportPreBindRead(namespace); // The in-memory fallback is ONE store for every rung: rows keep their - // `scope` tag, and there is no table split to mirror. + // `scope` tag, and there is no table split to mirror. The organization + // reach is applied to the tenant and user rungs exactly as the engine + // query applies it; a global row belongs to no organization. return userIds.map((userId) => this.memory.filter( (r) => r.namespace === namespace && - (userId === null || r.user_id === userId || r.scope === 'tenant' || r.scope === 'global'), + (userId === null || r.user_id === userId || r.scope === 'tenant' || r.scope === 'global') && + (r.scope === 'global' || SettingsService.withinReach(r, reach)), ), ); } + /** Whether a `tenant` / `user` row is one the reach may draw on. */ + private static withinReach(row: SettingsRow, reach: OrganizationReach): boolean { + const organizationId = organizationOf(row); + if (reach.kind === 'own') return organizationId === null || organizationId === reach.organizationId; + if (reach.kind === 'organization-less') return organizationId === null; + return true; + } + /** * [ADR-0131 D7] The global rung: the namespace's `sys_platform_setting` rows, * tagged `scope: 'global'` for the cascade walk. The object has no `scope`, @@ -2493,24 +2747,40 @@ export class SettingsService { * `sys_setting` must not answer as a second one — the v18 upgrade ceremony * moves it (ADR-0131 D14), and until then it is simply not a rung. */ - private async loadScopedRows(namespace: string, userId: string | null): Promise { + private async loadScopedRows( + namespace: string, + userId: string | null, + reach: OrganizationReach, + ): Promise { // A user-keyed load must still see the tenant rows (user_id NULL): // resolveKey's user→tenant→global cascade and the Phase-2 upper-scope lock // check both search THIS one result set, so a bare user_id equality starves // them of every upper-scope row on engine-bound deployments while the // in-memory branch includes them (#11228). The global rung is not in this // table any more; `loadRowSets` adds it. + const rungs: Array> = userId !== null + ? [{ user_id: userId }, { scope: 'tenant' }] + : [{ scope: 'tenant' }, { scope: 'user' }]; + // The organization filter, explicit in the query itself. NOTHING else + // scopes this read: it runs under SETTINGS_SYSTEM_CONTEXT, which carries no + // organization, so neither the driver's tenant scope nor the security + // layer's organization wall applies to it — every organization's row came + // back, and the cascade answered with whichever it met first. Each rung is + // crossed with the organizations the caller reaches (`OrganizationReach`): + // its own and the organization-less, or the organization-less alone. + const organizations: Array | null = + reach.kind === 'own' ? [reach.organizationId, null] + : reach.kind === 'organization-less' ? [null] + : null; const where: Record = { namespace, - $or: userId !== null - ? [{ user_id: userId }, { scope: 'tenant' }] - : [{ scope: 'tenant' }, { scope: 'user' }], + $or: organizations === null + ? rungs + : rungs.flatMap((rung) => organizations.map((organization_id) => ({ ...rung, organization_id }))), }; // Bypass the tenant-scoping audit warning so loads work uniformly across - // the tenant and user rungs without log noise. Per-tenant isolation for - // `tenant`-scope rows is still enforced by the engine once an - // ExecutionContext.tenantId is plumbed through (Phase 2+). - // The explicit system opt-in: see SETTINGS_SYSTEM_CONTEXT. + // the tenant and user rungs without log noise; the organization scope is + // the `where` above. The explicit system opt-in: see SETTINGS_SYSTEM_CONTEXT. const rows = await this.engine!.find(this.objectName, { where, bypassTenantAudit: true, @@ -2526,6 +2796,7 @@ export class SettingsService { key: r.key, scope, user_id: scope === 'global' ? null : r.user_id ?? null, + organization_id: scope === 'global' ? null : r.organization_id ?? null, value: r.value ?? null, value_enc: r.value_enc ?? null, encrypted: Boolean(r.encrypted), @@ -2588,12 +2859,18 @@ export class SettingsService { * looking correct — and the answer decides whether a ciphertext is deleted. * * [ADR-0131 D7] The rung picks the store. A `global` row lives in - * `sys_platform_setting`, keyed `(namespace, key)` and written WITHOUT `scope` - * or `user_id` — that object declares neither, and no organization column - * either. A `tenant` / `user` row lives in `sys_setting`, keyed - * `(namespace, key, scope, user_id)` exactly as before. Neither carries a - * tenant-audit bypass: the global rung's object has no tenant field to audit, - * and the tenant/user rungs keep the warning for a write missing its tenant. + * `sys_platform_setting`, keyed `(namespace, key)` and written WITHOUT `scope`, + * `user_id` or `organization_id` — that object declares none of them. A + * `tenant` / `user` row lives in `sys_setting`, keyed by the identity that + * object declares, `(organization_id, namespace, key, scope, user_id)`. + * + * The organization is IN the key, not left to the engine: this write runs + * under SETTINGS_SYSTEM_CONTEXT, which carries none, so the existence probe + * keyed without it found ANOTHER organization's row and the update rewrote + * that row in place — one organization's save replacing another's value. + * Neither carries a tenant-audit bypass: the global rung's object has no + * tenant field to audit, and the tenant/user rungs keep the warning for a + * write missing its tenant. */ private rowIdentity(row: SettingsRow): { object: string; @@ -2601,7 +2878,7 @@ export class SettingsService { data: Record; } { if (row.scope === 'global') { - const { scope: _scope, user_id: _userId, ...data } = row; + const { scope: _scope, user_id: _userId, organization_id: _organizationId, ...data } = row; return { object: PLATFORM_SETTING_OBJECT, where: { namespace: row.namespace, key: row.key }, @@ -2615,8 +2892,9 @@ export class SettingsService { key: row.key, scope: row.scope, user_id: row.user_id ?? null, + organization_id: organizationOf(row), }, - data: { ...row }, + data: { ...row, organization_id: organizationOf(row) }, }; } @@ -2627,7 +2905,8 @@ export class SettingsService { r.namespace === row.namespace && r.key === row.key && r.scope === row.scope && - (r.user_id ?? null) === (row.user_id ?? null), + (r.user_id ?? null) === (row.user_id ?? null) && + (row.scope === 'global' || organizationOf(r) === organizationOf(row)), ); } diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 1739ed1fb8c..030b1de79e0 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -70,6 +70,14 @@ export interface SettingsRow { key: string; scope: SpecifierScope; user_id: string | null; + /** + * The organization a `tenant` or `user` row belongs to — part of the row + * identity `sys_setting` declares, `(organization_id, namespace, key, scope, + * user_id)`. Written from the caller's `SettingsContext.tenantId`; `null` for + * a row written with no organization and for every `global` row (whose store, + * `sys_platform_setting`, has no organization column). + */ + organization_id?: string | null; value: unknown | null; value_enc: string | null; encrypted: boolean; From a38df02e6510e5804b0ef390441a327dfdaf6d6c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:51:38 +0000 Subject: [PATCH 2/6] test(service-settings): pin organization isolation of settings rows and the generic read door Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ --- ...ettings-organization-isolation.pin.test.ts | 342 ++++++++++++++++++ .../src/settings-read-door.pin.test.ts | 218 +++++++++++ 2 files changed, 560 insertions(+) create mode 100644 packages/services/service-settings/src/settings-organization-isolation.pin.test.ts create mode 100644 packages/services/service-settings/src/settings-read-door.pin.test.ts diff --git a/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts b/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts new file mode 100644 index 00000000000..88834a17ee6 --- /dev/null +++ b/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_setting` rows belong to ONE organization — the identity the object + * declares, `(organization_id, namespace, key, scope, user_id)`, carried by the + * service that writes and reads them. + * + * ## Why the service has to carry it + * + * `SettingsService` reads and writes its store under its own system context, + * which names no organization. Nothing downstream scopes such a call: the + * driver's tenant scope needs a `tenantId` on the context, and the security + * layer's organization wall stands aside for a system caller. So the + * organization is in the service's own `where` and in every row it writes, or + * it is nowhere. + * + * ## What is pinned + * + * - identity: a tenant-scope or user-scope row carries the writing caller's + * organization, and a write by one organization neither reads, nor + * replaces, nor resets another's; + * - the cascade: a caller's own organization row is preferred over a row + * written with no organization, which stays every organization's fallback, + * and the global rung is read by every organization; + * - the lock pre-flight reads the same row the cascade does; + * - the refusal: under a walled posture a tenant-scope write that names no + * organization is refused whole, and writes nothing; under `single` it is + * not; + * - `single`: the default organization keeps its answers, including for a + * process-wide reader that names no organization. + * + * Driven over a REAL `ObjectQL` engine through the plugin's own adapter + * (`wrapEngineAsSettingsEngine`); only the driver is a Map. Its matcher is + * deliberately strict — equality and `$or` only, anything else refuses — so a + * query shape it cannot express fails here instead of reading as "no row". + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysUser } from '@objectstack/platform-objects/identity'; +import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; +import type { TenancyPosture } from '@objectstack/spec/security'; +import { SettingsService } from './settings-service.js'; +import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { SettingsLockedError, type SettingsContext } from './settings-service.types.js'; + +const SYS = { context: { isSystem: true } } as const; +const NS = 'org_isolation_fixture'; +const ORG_A = 'org_alpha'; +const ORG_B = 'org_beta'; + +/** A driver over plain Maps. Equality and `$or` only; any other operator refuses. */ +function makeMemoryDriver() { + const store = new Map>>(); + let nextId = 0; + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v !== null && typeof v === 'object') throw new Error(`fake driver: unsupported condition on '${k}'`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const row = { ...data, id: (data.id as string) ?? `row_${nextId}` }; + rowsOf(object).set(row.id as string, row); + return { ...row }; + }, + async update(object: string, id: string, data: Record) { + const s = rowsOf(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return { ...next }; + }, + async updateMany(object: string, ast: any, data: Record) { + const hits = await this.find(object, ast); + const s = rowsOf(object); + for (const r of hits) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id }); + return hits.length; + }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +const MANIFEST = { + namespace: NS, + label: 'Organization isolation fixture', + scope: 'tenant', + specifiers: [ + { key: 'motto', type: 'text', label: 'Motto', scope: 'tenant', default: 'none' }, + { key: 'banner', type: 'text', label: 'Banner', scope: 'global', default: 'none' }, + { key: 'theme', type: 'text', label: 'Theme', scope: 'user', default: 'light' }, + ], +} as any; + +let engine: ObjectQL; +let rowsOf: (object: string) => Map>; +let userId: string; + +beforeEach(async () => { + engine = new ObjectQL(); + const memory = makeMemoryDriver(); + rowsOf = memory.rowsOf; + engine.registerDriver(memory.driver, true); + await engine.init(); + for (const o of [SysUser, SysSetting, SysPlatformSetting]) { + engine.registry.registerObject(o as any, '@objectstack/platform-objects'); + } + const user = await engine.insert('sys_user', { name: 'Ada', email: 'ada@example.test' }, SYS); + userId = String((user as any).id); +}); + +/** A service bound to the real engine, under the given posture. */ +function settings(posture: TenancyPosture | undefined): SettingsService { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(MANIFEST); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, { + tenancyPosture: () => posture, + }); + return svc; +} + +/** A row put straight into the store, as an earlier writer left it. */ +async function seed(row: Record): Promise { + await engine.insert('sys_setting', { + namespace: NS, value_enc: null, encrypted: false, locked: false, locked_reason: null, + user_id: null, ...row, + }, SYS); +} + +const settingRows = () => [...rowsOf('sys_setting').values()]; +const inOrg = (organization: string, extra: SettingsContext = {}): SettingsContext => ({ + tenantId: organization, ...extra, +}); + +describe('a tenant-scope value belongs to the organization that wrote it (walled posture)', () => { + it('a tenant-scope value written by one organization is not read by another', async () => { + const svc = settings('isolated'); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe('Alpha'); + const other = await svc.get(NS, 'motto', inOrg(ORG_B)); + expect(other.value).toBe('none'); + expect(other.source).toBe('default'); + }); + + it('each organization reads its own value, and one organization\'s write leaves the other\'s unchanged', async () => { + const svc = settings('isolated'); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + await svc.set(NS, 'motto', 'Beta', inOrg(ORG_B)); + + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe('Alpha'); + expect((await svc.get(NS, 'motto', inOrg(ORG_B))).value).toBe('Beta'); + // Two rows, each carrying the organization that wrote it — the second write + // did not find, and rewrite, the first organization's row. + expect( + settingRows().map((r) => [r.organization_id, r.value]).sort(), + ).toEqual([[ORG_A, 'Alpha'], [ORG_B, 'Beta']]); + }); + + it('one organization\'s reset leaves the other\'s value unchanged', async () => { + const svc = settings('isolated'); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + await svc.set(NS, 'motto', 'Beta', inOrg(ORG_B)); + + await svc.runAction(NS, 'reset', null, inOrg(ORG_A)); + + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).source).toBe('default'); + const other = await svc.get(NS, 'motto', inOrg(ORG_B)); + expect(other.value).toBe('Beta'); + expect(other.source).toBe('tenant'); + }); + + it('getMany and getNamespace answer per organization exactly as get does', async () => { + const svc = settings('isolated'); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + await svc.set(NS, 'motto', 'Beta', inOrg(ORG_B)); + + expect((await svc.getMany(NS, ['motto'], inOrg(ORG_A))).motto.value).toBe('Alpha'); + expect((await svc.getNamespace(NS, inOrg(ORG_B))).values.motto.value).toBe('Beta'); + }); + + it('a global row is read by both organizations', async () => { + const svc = settings('isolated'); + // A global key names no organization and is not refused under the wall. + await svc.set(NS, 'banner', 'Everyone', {}); + + for (const org of [ORG_A, ORG_B]) { + const got = await svc.get(NS, 'banner', inOrg(org)); + expect(got.value).toBe('Everyone'); + expect(got.source).toBe('global'); + } + }); + + it('a row written with no organization is every organization\'s fallback, and an organization\'s own row is preferred over it', async () => { + await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null }); + const svc = settings('isolated'); + + for (const org of [ORG_A, ORG_B]) { + const got = await svc.get(NS, 'motto', inOrg(org)); + expect(got.value).toBe('Legacy'); + expect(got.source).toBe('tenant'); + } + + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + const own = await svc.get(NS, 'motto', inOrg(ORG_A)); + expect(own.value).toBe('Alpha'); + // ONE tenant entry in the chain — the preferred row, not both. + expect(own.cascadeChain?.filter((e) => e.scope === 'tenant')).toHaveLength(1); + expect((await svc.get(NS, 'motto', inOrg(ORG_B))).value).toBe('Legacy'); + // The organization-less row was not rewritten by the organization's write. + expect(settingRows().find((r) => r.organization_id == null)?.value).toBe('Legacy'); + }); + + it('a user-scope value carries the organization too: the same user reads it only in the organization it was set in', async () => { + const svc = settings('isolated'); + await svc.set(NS, 'theme', 'dark', inOrg(ORG_A, { userId })); + + expect((await svc.get(NS, 'theme', inOrg(ORG_A, { userId }))).value).toBe('dark'); + expect((await svc.get(NS, 'theme', inOrg(ORG_B, { userId }))).value).toBe('light'); + expect(settingRows().map((r) => [r.scope, r.user_id, r.organization_id])).toEqual([['user', userId, ORG_A]]); + }); + + it('a lock on one organization\'s tenant row does not lock another organization\'s write', async () => { + await seed({ key: 'theme', scope: 'tenant', value: 'dark', organization_id: ORG_A, locked: true }); + const svc = settings('isolated'); + + await expect(svc.set(NS, 'theme', 'blue', inOrg(ORG_A, { userId }))).rejects.toBeInstanceOf(SettingsLockedError); + await expect(svc.set(NS, 'theme', 'blue', inOrg(ORG_B, { userId }))).resolves.toBeDefined(); + }); +}); + +describe('a tenant-scope write that names no organization', () => { + for (const posture of ['isolated', 'group'] as const) { + it(`is refused whole under the '${posture}' posture, and nothing is written — while the same write from an organization lands`, async () => { + const svc = settings(posture); + + const refusal = await svc.setMany(NS, { motto: 'Nobody' }, { userId }).then(() => null, (e) => e); + expect(refusal, 'the organization-less write must refuse').not.toBeNull(); + expect(refusal.code).toBe('SETTINGS_VALIDATION'); + expect(refusal.fields).toEqual([ + expect.objectContaining({ field: 'motto', code: 'invalid_value', constraint: { scope: 'tenant' } }), + ]); + expect(settingRows()).toHaveLength(0); + + // A reset is a write too: it is refused the same way. + const reset = await svc.setMany(NS, { motto: null }, { userId }).then(() => null, (e) => e); + expect(reset?.code).toBe('SETTINGS_VALIDATION'); + expect(settingRows()).toHaveLength(0); + + // Positive control — the identical write, from inside an organization. + await svc.setMany(NS, { motto: 'Alpha' }, inOrg(ORG_A, { userId })); + expect(settingRows().map((r) => [r.organization_id, r.value])).toEqual([[ORG_A, 'Alpha']]); + }); + } + + it("is not refused under the 'single' posture, nor where no posture is reported", async () => { + for (const posture of ['single', undefined] as const) { + const svc = settings(posture); + await svc.set(NS, 'motto', `free-${String(posture)}`, {}); + expect((await svc.get(NS, 'motto', {})).value).toBe(`free-${String(posture)}`); + } + expect(settingRows().every((r) => r.organization_id == null)).toBe(true); + }); + + it('under a walled posture, a reader that names no organization reads only the organization-less rows', async () => { + const svc = settings('isolated'); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + expect((await svc.get(NS, 'motto', {})).source).toBe('default'); + + await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null }); + expect((await svc.get(NS, 'motto', {})).value).toBe('Legacy'); + }); +}); + +describe("posture 'single': the default organization keeps its answers", () => { + const DEFAULT_ORG = 'org_default'; + + it('a value stored before rows carried an organization is still read by the default organization', async () => { + await seed({ key: 'motto', scope: 'tenant', value: 'Acme', organization_id: null }); + const svc = settings('single'); + const got = await svc.get(NS, 'motto', inOrg(DEFAULT_ORG)); + expect(got.value).toBe('Acme'); + expect(got.source).toBe('tenant'); + }); + + it('the default organization\'s write is read by itself and by a process-wide reader that names no organization', async () => { + await seed({ key: 'motto', scope: 'tenant', value: 'Acme', organization_id: null }); + const svc = settings('single'); + await svc.set(NS, 'motto', 'Acme Corp', inOrg(DEFAULT_ORG)); + + expect((await svc.get(NS, 'motto', inOrg(DEFAULT_ORG))).value).toBe('Acme Corp'); + expect((await svc.get(NS, 'motto', {})).value).toBe('Acme Corp'); + }); + + it('the default organization\'s reset reads back the default for both readers', async () => { + const svc = settings('single'); + await svc.set(NS, 'motto', 'Acme Corp', inOrg(DEFAULT_ORG)); + await svc.runAction(NS, 'reset', null, inOrg(DEFAULT_ORG)); + + expect((await svc.get(NS, 'motto', inOrg(DEFAULT_ORG))).source).toBe('default'); + expect((await svc.get(NS, 'motto', {})).source).toBe('default'); + }); +}); + +describe('the in-memory store keeps the same identity', () => { + it('two organizations each read their own value with no engine bound', async () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(MANIFEST); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + await svc.set(NS, 'motto', 'Beta', inOrg(ORG_B)); + + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe('Alpha'); + expect((await svc.get(NS, 'motto', inOrg(ORG_B))).value).toBe('Beta'); + }); +}); diff --git a/packages/services/service-settings/src/settings-read-door.pin.test.ts b/packages/services/service-settings/src/settings-read-door.pin.test.ts new file mode 100644 index 00000000000..328c919f91e --- /dev/null +++ b/packages/services/service-settings/src/settings-read-door.pin.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The generic read door of the settings stores applies each namespace's + * `readPermission` — the rule the settings door has always applied, now at the + * data API's read of the same rows (`settings-read-door.ts`). + * + * Driven over a REAL `ObjectQL` engine with the middleware installed the way + * the plugin installs it (`registerSettingsReadDoor`), reading as a principal + * context the way the data API hands one to the engine. Only the driver is a + * Map; its matcher implements exactly the shapes the door composes (`$and`, + * `$or`, `$in`, `$nin`, equality) and refuses everything else. + * + * Every refusal below is paired with its positive control: the same read, by a + * principal holding the namespace's capability, returns the row. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SettingsService } from './settings-service.js'; +import { SETTINGS_READ_DOOR_OBJECTS, registerSettingsReadDoor, settingsReadDoorMiddleware } from './settings-read-door.js'; + +const SYS = { context: { isSystem: true } } as const; + +function makeMemoryDriver() { + const store = new Map>>(); + let nextId = 0; + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + const condition = (value: unknown, cond: unknown): boolean => { + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1) throw new Error(`fake driver: unsupported condition ${JSON.stringify(cond)}`); + const [op] = ops; + const list = (cond as Record)[op]; + if (!Array.isArray(list)) throw new Error(`fake driver: ${op} needs an array`); + if (op === '$in') return list.includes(value); + if (op === '$nin') return !list.includes(value); + throw new Error(`fake driver: unsupported operator ${op}`); + } + return (value ?? null) === (cond ?? null); + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$and') return (v as any[]).every((b) => matches(row, b)); + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${k}`); + return condition(row[k], v); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const row = { ...data, id: (data.id as string) ?? `row_${nextId}` }; + rowsOf(object).set(row.id as string, row); + return { ...row }; + }, + async count(object: string, ast: any) { + return [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)).length; + }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +/** Two namespaces with different read capabilities, and one with no manifest at all. */ +const OPEN = 'door_open_ns'; +const RESTRICTED = 'door_restricted_ns'; +const UNREGISTERED = 'door_unregistered_ns'; + +function manifest(namespace: string, readPermission: string) { + return { + namespace, label: namespace, scope: 'tenant', readPermission, writePermission: readPermission, + specifiers: [{ key: 'k', type: 'text', label: 'K' }], + } as any; +} + +let engine: ObjectQL; +let ids: Record>; + +beforeEach(async () => { + engine = new ObjectQL(); + const memory = makeMemoryDriver(); + engine.registerDriver(memory.driver, true); + await engine.init(); + for (const o of [SysSetting, SysSettingAudit, SysPlatformSetting]) { + engine.registry.registerObject(o as any, '@objectstack/platform-objects'); + } + const svc = new SettingsService({ env: {} }); + svc.registerManifest(manifest(OPEN, 'setup.access')); + svc.registerManifest(manifest(RESTRICTED, 'manage_platform_settings')); + expect(registerSettingsReadDoor(engine, svc)).toBe(true); + + ids = {}; + for (const namespace of [OPEN, RESTRICTED, UNREGISTERED]) { + ids[namespace] = {}; + const setting = await engine.insert('sys_setting', { + namespace, key: 'k', scope: 'tenant', user_id: null, value: 'v', value_enc: null, + encrypted: false, locked: false, locked_reason: null, + }, SYS); + ids[namespace].sys_setting = String((setting as any).id); + const audit = await engine.insert('sys_setting_audit', { + namespace, key: 'k', scope: 'tenant', action: 'set', source: 'api', actor_id: null, + old_hash: null, new_hash: 'h', encrypted: false, request_id: null, reason: null, + created_at: new Date().toISOString(), + }, SYS); + ids[namespace].sys_setting_audit = String((audit as any).id); + const platform = await engine.insert('sys_platform_setting', { + namespace, key: 'k', value: 'v', value_enc: null, encrypted: false, locked: false, locked_reason: null, + }, SYS); + ids[namespace].sys_platform_setting = String((platform as any).id); + } +}); + +/** A principal context as the data API hands one to the engine. */ +function principal(systemPermissions: string[]) { + return { context: { userId: 'usr_reader', tenantId: 'org_a', positions: ['member'], permissions: [], systemPermissions } }; +} + +const SETUP_ONLY = principal(['setup.access']); +const BOTH = principal(['setup.access', 'manage_platform_settings']); +const PLATFORM_ONLY = principal(['manage_platform_settings']); +const NONE = principal([]); + +async function namespacesRead(object: string, as: any): Promise { + const rows = await engine.find(object, {}, as); + return (rows as any[]).map((r) => r.namespace).sort(); +} + +describe('the generic read door applies each namespace\'s readPermission', () => { + for (const object of SETTINGS_READ_DOOR_OBJECTS) { + describe(object, () => { + it('a principal lacking a namespace\'s readPermission does not read its rows; a holder does (positive control)', async () => { + expect(await namespacesRead(object, SETUP_ONLY)).toEqual([OPEN, UNREGISTERED].sort()); + expect(await namespacesRead(object, BOTH)).toEqual([OPEN, RESTRICTED, UNREGISTERED].sort()); + }); + + it('a by-id read of a withheld row answers nothing; the holder reads it', async () => { + const id = ids[RESTRICTED][object]; + expect(await engine.findOne(object, { where: { id } }, SETUP_ONLY)).toBeNull(); + expect(((await engine.findOne(object, { where: { id } }, BOTH)) as any)?.namespace).toBe(RESTRICTED); + }); + + it('a count sees exactly the rows a list returns', async () => { + expect(await engine.count(object, {}, SETUP_ONLY)).toBe(2); + expect(await engine.count(object, {}, BOTH)).toBe(3); + }); + + it('the caller\'s own filter is kept, AND-ed with the door', async () => { + const narrowed = await engine.find(object, { where: { namespace: RESTRICTED } }, SETUP_ONLY); + expect(narrowed).toEqual([]); + const own = await engine.find(object, { where: { namespace: OPEN } }, SETUP_ONLY); + expect((own as any[]).map((r) => r.namespace)).toEqual([OPEN]); + }); + + it('a principal holding no capability reads no namespace; one holding only a namespace\'s own capability reads only it', async () => { + expect(await namespacesRead(object, NONE)).toEqual([]); + expect(await namespacesRead(object, PLATFORM_ONLY)).toEqual([RESTRICTED]); + }); + + it('a system context is not scoped', async () => { + expect(await namespacesRead(object, SYS)).toEqual([OPEN, RESTRICTED, UNREGISTERED].sort()); + }); + }); + } + + it('the door is registered BY NAME for each settings store', () => { + for (const object of SETTINGS_READ_DOOR_OBJECTS) expect(engine.hasObjectMiddleware(object)).toBe(true); + }); +}); + +describe('settingsReadDoorMiddleware — the operations it scopes', () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(manifest(RESTRICTED, 'manage_platform_settings')); + const door = settingsReadDoorMiddleware(svc); + + for (const operation of ['find', 'findOne', 'count', 'aggregate']) { + it(`ANDs the namespace predicate into a '${operation}'`, async () => { + const opCtx: any = { object: 'sys_setting', operation, ast: { where: { key: 'k' } }, context: SETUP_ONLY.context }; + await door(opCtx, async () => {}); + expect(opCtx.ast.where).toEqual({ $and: [{ key: 'k' }, { namespace: { $nin: [RESTRICTED] } }] }); + }); + } + + for (const operation of ['insert', 'update', 'delete']) { + it(`leaves a '${operation}' untouched`, async () => { + const opCtx: any = { object: 'sys_setting', operation, ast: { where: { key: 'k' } }, context: SETUP_ONLY.context }; + await door(opCtx, async () => {}); + expect(opCtx.ast.where).toEqual({ key: 'k' }); + }); + } + + it('refuses a read that carries no query to scope', async () => { + let ran = false; + const opCtx: any = { object: 'sys_setting', operation: 'find', context: SETUP_ONLY.context }; + await expect(door(opCtx, async () => { ran = true; })).rejects.toThrow(/carries no query to scope/); + expect(ran).toBe(false); + }); +}); From e1407dc55f6409aabf0a05e4fec595b41a3e0a82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:57:02 +0000 Subject: [PATCH 3/6] test(service-settings): fixtures spell organization_id as a real driver returns it Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ --- .../services/service-settings/src/settings-getmany.test.ts | 7 +++++-- .../services/service-settings/src/settings-routes.test.ts | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/services/service-settings/src/settings-getmany.test.ts b/packages/services/service-settings/src/settings-getmany.test.ts index a57b053d166..03383ee886f 100644 --- a/packages/services/service-settings/src/settings-getmany.test.ts +++ b/packages/services/service-settings/src/settings-getmany.test.ts @@ -79,8 +79,11 @@ const ROWS: Stores = { { namespace: 'localization', key: 'timezone', value: 'America/New_York' }, ], sys_setting: [ - { namespace: 'localization', key: 'locale', scope: 'user', value: 'zh-CN', user_id: 'u1' }, - { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'USD', user_id: null }, + // `organization_id` spelled as a real driver hands it back: the column is + // injected into every tenant-scoped object, and a row written with no + // organization reads back NULL there, not absent. + { namespace: 'localization', key: 'locale', scope: 'user', value: 'zh-CN', user_id: 'u1', organization_id: null }, + { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'USD', user_id: null, organization_id: null }, ], }; diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index 0fe08fb52f3..70e2327c3eb 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -703,7 +703,9 @@ async function makeRetiredFormatStack() { registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); /** The store's own rows for the four keys, read the way every resolve reads them. */ const storedFormatRows = async () => { - const rows = (await (svc as any).loadRows('localization', null)) as Array<{ key: string; value: unknown }>; + // The admin context names no organization and this service reports no + // posture, so every resolve reads with the `unwalled` reach. + const rows = (await (svc as any).loadRows('localization', null, { kind: 'unwalled' })) as Array<{ key: string; value: unknown }>; return Object.fromEntries(rows.filter((r) => r.key in RETIRED_FORMAT_ROWS).map((r) => [r.key, r.value])); }; return { svc, http, storedFormatRows }; From 672e54e08ed1f2190a1c95ffd235b5f9bb355804 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:04:42 +0000 Subject: [PATCH 4/6] test(dogfood): settings values per organization and the generic read door, over HTTP; changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ --- ...2261-settings-organization-row-identity.md | 22 ++ ...ngs-organization-isolation.dogfood.test.ts | 227 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 .changeset/22261-settings-organization-row-identity.md create mode 100644 packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts diff --git a/.changeset/22261-settings-organization-row-identity.md b/.changeset/22261-settings-organization-row-identity.md new file mode 100644 index 00000000000..300a287f5fb --- /dev/null +++ b/.changeset/22261-settings-organization-row-identity.md @@ -0,0 +1,22 @@ +--- +'@objectstack/service-settings': minor +--- + +fix(service-settings)!: tenant- and user-scope settings rows carry the caller's organization, and the data API read of the settings stores applies each namespace's readPermission + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`sys_setting` declares its row identity as `(organization_id, namespace, key, scope, user_id)`. `SettingsService` now carries the organization in that identity itself, on every read and write of a `tenant` or `user` row, because it reads and writes the store under its own system context, which no driver tenant scope or organization wall reaches. + +- **Writes.** A `tenant` or `user` row is written with the caller's organization (`SettingsContext.tenantId`) in its key and in its stored `organization_id`. A write by one organization updates only that organization's row. +- **Reads.** The tenant and user rungs draw on the caller organization's rows and on rows stored with no organization, and take the caller organization's own row when it has one. A row stored with no organization stays the fallback for every organization until that organization writes its own. A caller that names no organization reads only the rows stored with no organization under a walled posture (`group`, `isolated`), and every row under `single`. The global rung (`sys_platform_setting`) is unchanged and read by every organization. +- **Locks.** The lock check on a write reads the same upper rows the caller's cascade reads, so a lock on one organization's tenant row locks nothing for another organization. +- **Refused now.** Under a walled posture, `set` and `setMany` refuse a key declared `scope: 'tenant'` when the context names no organization, a `null` reset of one included. The refusal is a `SettingsValidationError` (`code: 'SETTINGS_VALIDATION'`, HTTP 400 at the settings routes) with one `fields` entry per such key (`code: 'invalid_value'`, `constraint: { scope: 'tenant' }`). It refuses the whole batch, before anything is written. The posture is the one the `tenancy` service reports; `SettingsServicePlugin` supplies it through `bindEngine`. +- **Generic read door.** `SettingsServicePlugin` registers an engine middleware on `sys_setting`, `sys_setting_audit` and `sys_platform_setting`: every non-system read (`find`, `findOne`, `count`, `aggregate`) is narrowed to the namespaces whose `readPermission` the principal holds, by the same rule `GET /api/settings/:namespace` applies. A namespace with no registered manifest reads at the default capability, `setup.access`. +- **Unchanged.** Under `single`, a caller in the default organization reads every value it read before, and a process-wide reader that names no organization reads the organization's current value. Keys declared at `scope: 'global'` resolve and write the same for every caller. + +What changes for you: write a tenant-scope setting from inside the organization it belongs to (an active organization on the session, or `SettingsContext.tenantId` in process). Rows already stored with no organization are not rewritten. diff --git a/packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts b/packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts new file mode 100644 index 00000000000..1091ba9c71e --- /dev/null +++ b/packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Settings values on a two-organization kernel, measured over HTTP — the + * settings door and the generic data-API read of `sys_setting`. + * + * ## What is asserted + * + * 1. Two organizations each set and read their own tenant-scope value; one + * organization's write, and its reset, leave the other's unchanged. + * 2. A `global` value is read by both. + * 3. A tenant-scope write that names no organization, under the walled + * posture, is refused — `status` and `error.code` asserted — and writes + * nothing. Positive control: the identical write from inside an + * organization lands. + * 4. The generic data-API read of `sys_setting` withholds a namespace's rows + * from a principal that lacks the namespace's `readPermission`, on the + * list and on the by-id read. Positive control: the same principal reads + * the rows of a namespace whose capability it holds, and a holder of the + * withheld capability reads the withheld row. + * + * ## The harness + * + * - boot: `bootStack(crm, { multiTenant: 'posture-only' })` — a real, + * non-degraded `isolated` posture. Its stand-in scopes no row of its own, + * which is what this file needs: the settings service reads and writes + * under its own system context, so no row wall reaches those calls anyway. + * Everything asserted in 1–3 is the service's own `where` and row identity. + * - principals: the platform admin (the seeded first user, in no + * organization) and two organization owners who each create their OWN + * organization, so the two tenants carry different active organizations — + * asserted, not assumed. + * - namespaces: the shipped `branding` manifest (tenant scope, read + * `setup.access`, write `setup.write`) for 1 and 3; two fixture manifests + * registered on the booted service for 2 and 4, because no shipped manifest + * has a global key an organization may read, or a read capability stricter + * than its write capability. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { context: { isSystem: true } }; + +/** A global key every organization may read; only the platform may write it. */ +const GLOBAL_NS = 'dogfood_settings_global_fixture'; +/** A tenant key an organization may write but whose read needs a platform capability. */ +const GATED_NS = 'dogfood_settings_read_gated_fixture'; + +interface ApiError { error?: { code?: string; details?: { fields?: Array<{ field?: string; code?: string }> } } } + +describe('settings values are per organization, and the generic read door applies each namespace\'s readPermission', () => { + let stack: VerifyStack; + let adminToken: string; + let tenantAToken: string; + let tenantBToken: string; + let orgAId: string; + let orgBId: string; + let ql: any; + + const settingsCall = (token: string, method: string, path: string, body?: unknown) => + stack.raw(`/api/settings/${path}`, { + method, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + const readValue = async (token: string, ns: string, key: string) => { + const res = await settingsCall(token, 'GET', ns); + const text = await res.clone().text(); + expect(res.status, `GET /api/settings/${ns}: ${text}`).toBe(200); + const body = (await res.json()) as { data: { values: Record } }; + return body.data.values[key]; + }; + + const storedRows = async (namespace: string) => + (await ql.find('sys_setting', { where: { namespace }, limit: 50 }, SYS)) as Array>; + + beforeAll(async () => { + stack = await bootStack(crmStack as never, { multiTenant: 'posture-only' }); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + const settings = await stack.kernel.getServiceAsync('settings'); + settings.registerManifest({ + namespace: GLOBAL_NS, label: 'Global fixture', scope: 'global', + readPermission: 'setup.access', writePermission: 'manage_platform_settings', + specifiers: [{ type: 'text', key: 'banner', label: 'Banner', required: false }], + }); + settings.registerManifest({ + namespace: GATED_NS, label: 'Read-gated fixture', scope: 'tenant', + readPermission: 'manage_platform_settings', writePermission: 'setup.write', + specifiers: [{ type: 'text', key: 'note', label: 'Note', required: false }], + }); + + tenantAToken = await stack.signUp('settings-org-a-owner@dogfood.test'); + tenantBToken = await stack.signUp('settings-org-b-owner@dogfood.test'); + for (const [token, name, slug] of [ + [tenantAToken, 'Settings Org A', 'settings-org-a'], + [tenantBToken, 'Settings Org B', 'settings-org-b'], + ] as const) { + const created = await stack.apiAs(token, 'POST', '/auth/organization/create', { name, slug }); + expect(created.status, `org create ${slug}: ${await created.clone().text()}`).toBe(200); + const id = ((await created.json()) as { id: string }).id; + if (slug === 'settings-org-a') orgAId = id; else orgBId = id; + const active = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, `set-active ${slug}: ${await active.clone().text()}`).toBe(200); + } + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('guard: a real walled posture, two distinct organizations, and an organization-less platform admin', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.degraded).toBe(false); + expect(orgAId).toBeTruthy(); + expect(orgBId).toBeTruthy(); + expect(orgAId).not.toBe(orgBId); + + const activeOrg = async (token: string) => { + const res = await stack.apiAs(token, 'GET', '/auth/get-session'); + expect(res.status).toBe(200); + return ((await res.json()) as { session: { activeOrganizationId: string | null } }).session.activeOrganizationId; + }; + expect(await activeOrg(tenantAToken)).toBe(orgAId); + expect(await activeOrg(tenantBToken)).toBe(orgBId); + expect(await activeOrg(adminToken)).toBeFalsy(); + }); + + it('two organizations each set and read their own tenant-scope value', async () => { + const putA = await settingsCall(tenantAToken, 'PUT', 'branding', { workspace_name: 'Org A Workspace' }); + expect(putA.status, await putA.clone().text()).toBe(200); + // Organization B has written nothing: it does not read A's value. + expect((await readValue(tenantBToken, 'branding', 'workspace_name')).source).toBe('default'); + + const putB = await settingsCall(tenantBToken, 'PUT', 'branding', { workspace_name: 'Org B Workspace' }); + expect(putB.status, await putB.clone().text()).toBe(200); + + expect((await readValue(tenantAToken, 'branding', 'workspace_name')).value).toBe('Org A Workspace'); + expect((await readValue(tenantBToken, 'branding', 'workspace_name')).value).toBe('Org B Workspace'); + + // Two stored rows, each carrying the organization that wrote it. + const rows = (await storedRows('branding')).filter((r) => r.key === 'workspace_name'); + expect(rows.map((r) => [r.organization_id, r.value]).sort()).toEqual( + [[orgAId, 'Org A Workspace'], [orgBId, 'Org B Workspace']].sort(), + ); + }); + + it('one organization\'s write, and its reset, leave the other\'s value unchanged', async () => { + const putA = await settingsCall(tenantAToken, 'PUT', 'branding', { workspace_name: 'Org A Renamed' }); + expect(putA.status, await putA.clone().text()).toBe(200); + expect((await readValue(tenantBToken, 'branding', 'workspace_name')).value).toBe('Org B Workspace'); + + const reset = await settingsCall(tenantAToken, 'POST', 'branding/reset', {}); + expect(reset.status, await reset.clone().text()).toBe(200); + expect((await readValue(tenantAToken, 'branding', 'workspace_name')).source).toBe('default'); + + const other = await readValue(tenantBToken, 'branding', 'workspace_name'); + expect(other.value).toBe('Org B Workspace'); + expect(other.source).toBe('tenant'); + }); + + it('a global value is read by both organizations', async () => { + const put = await settingsCall(adminToken, 'PUT', GLOBAL_NS, { banner: 'Platform banner' }); + expect(put.status, await put.clone().text()).toBe(200); + + for (const token of [tenantAToken, tenantBToken]) { + const got = await readValue(token, GLOBAL_NS, 'banner'); + expect(got.value).toBe('Platform banner'); + expect(got.source).toBe('global'); + } + }); + + it('a tenant-scope write that names no organization is refused under the walled posture, and writes nothing; the same write from an organization lands', async () => { + const before = (await storedRows('branding')).length; + + const refused = await settingsCall(adminToken, 'PUT', 'branding', { workspace_name: 'No Organization' }); + expect(refused.status).toBe(400); + const body = (await refused.json()) as ApiError; + expect(body.error?.code).toBe('SETTINGS_VALIDATION'); + expect(body.error?.details?.fields?.[0]).toMatchObject({ field: 'workspace_name', code: 'invalid_value' }); + expect((await storedRows('branding')).length).toBe(before); + expect((await storedRows('branding')).some((r) => r.value === 'No Organization')).toBe(false); + + // Positive control: the identical write, from inside an organization. + const landed = await settingsCall(tenantAToken, 'PUT', 'branding', { workspace_name: 'No Organization' }); + expect(landed.status, await landed.clone().text()).toBe(200); + expect((await readValue(tenantAToken, 'branding', 'workspace_name')).value).toBe('No Organization'); + }); + + it('the generic data-API read of sys_setting withholds a namespace\'s rows from a principal lacking its readPermission', async () => { + const put = await settingsCall(tenantAToken, 'PUT', GATED_NS, { note: 'gated' }); + expect(put.status, await put.clone().text()).toBe(200); + const [gatedRow] = await storedRows(GATED_NS); + expect(gatedRow?.organization_id).toBe(orgAId); + + // The settings door refuses this principal the namespace — the rule the + // generic door now applies too. + const door = await settingsCall(tenantAToken, 'GET', GATED_NS); + expect(door.status).toBe(403); + expect(((await door.json()) as ApiError).error?.code).toBe('SETTINGS_FORBIDDEN'); + + const list = await stack.apiAs(tenantAToken, 'GET', `/data/sys_setting?namespace=${GATED_NS}`); + expect(list.status, await list.clone().text()).toBe(200); + const listed = ((await list.json()) as { data?: unknown[]; records?: unknown[] }); + expect((listed.data ?? listed.records ?? []).length).toBe(0); + + const byId = await stack.apiAs(tenantAToken, 'GET', `/data/sys_setting/${String(gatedRow.id)}`); + expect(byId.status).toBe(404); + + // Positive control 1: the same principal reads its own row of a namespace + // whose read capability it holds. + const own = await stack.apiAs(tenantAToken, 'GET', '/data/sys_setting?namespace=branding'); + expect(own.status, await own.clone().text()).toBe(200); + const ownRows = ((await own.json()) as { data?: Array>; records?: Array> }); + expect((ownRows.data ?? ownRows.records ?? []).some((r) => r.organization_id === orgAId)).toBe(true); + + // Positive control 2: a holder of the withheld capability reads the + // withheld row. + const held = await stack.apiAs(adminToken, 'GET', `/data/sys_setting/${String(gatedRow.id)}`); + expect(held.status, await held.clone().text()).toBe(200); + }); +}); From 9978287608d99f0c4dc511134d07f2ee12d5f5f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:20:40 +0000 Subject: [PATCH 5/6] docs(permissions): the settings read door's system-context read on the census page Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ --- content/docs/permissions/system-context.mdx | 25 +++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 94dd19225a2..c446d898fc1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,8 +10,8 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **115 -distinct sites across 19 packages**, and knowing three of those behaviours gives +because the flag is not one concept: it is a single boolean read at **116 +distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, and the gap was observable only by querying the resulting rows. @@ -115,6 +115,7 @@ that silently does not happen. | 16 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `packages/plugins/plugin-audit/src/read-audit.ts#installReadAuditWriter` | | 17 | Approval snapshot payload redaction skipped, and so is the snapshot query guard | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel — and a filter, sort or grouping by it on any read. Lose: field-visibility redaction over approval payloads, and the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware`, `packages/plugins/plugin-approvals/src/payload-predicate-guard.ts#bindSnapshotPredicateGuard` | | 18 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `packages/rest/src/rest-server.ts#enforceAuth` | +| 18b | Settings namespace read scope not applied | service-settings | Get: a read of `sys_setting`, `sys_setting_audit` or `sys_platform_setting` across every namespace, whatever each namespace's `readPermission` names — the settings service's own reads of its stores take this path. Lose: the per-namespace read gate the generic data API applies to every other caller, the same rule the settings routes enforce | `packages/services/service-settings/src/settings-read-door.ts#settingsReadDoorMiddleware` | ### 2. Write pipeline and data integrity @@ -139,7 +140,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 115 sites**. +The largest single consumer — **17 of the 116 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -282,8 +283,8 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 115 read sites - in 19 packages. Splitting it is a breaking contract change across all of them. +- **Shipped semantics.** `isSystem` is a published contract with 116 read sites + in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) - **No business pull.** No app has asked for the combinations a split would @@ -356,16 +357,16 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 27 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 121 | ✅ | +| — parsed as a property **read** | 122 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **115** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 112 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **116** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 113 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | -| Packages containing at least one elevation read | **19** | ✅ | -| Files containing at least one elevation read | 53 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 98 | ✅ | +| Packages containing at least one elevation read | **20** | ✅ | +| Files containing at least one elevation read | 54 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 99 | ✅ | | — of those files, the ones holding more than one read in one symbol | 8 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were @@ -429,7 +430,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **8** of the **53** +cannot say WHICH read inside a function it means, and **8** of the **54** anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must From 61911cf17aba4cb5e2c2a249e159c2d165b4ac94 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:26:32 +0000 Subject: [PATCH 6/6] fix(service-settings): a global-key read does not wait on the posture; pin where the posture is read Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ --- ...ettings-organization-isolation.pin.test.ts | 32 ++++++++++++++++++- .../service-settings/src/settings-service.ts | 18 +++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts b/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts index 88834a17ee6..fcb1d4968c2 100644 --- a/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts +++ b/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts @@ -27,7 +27,10 @@ * organization is refused whole, and writes nothing; under `single` it is * not; * - `single`: the default organization keeps its answers, including for a - * process-wide reader that names no organization. + * process-wide reader that names no organization; + * - the posture is asked only for a caller that names no organization, on a + * key below the global rung — and when it cannot be read, that read or + * write fails rather than guessing. * * Driven over a REAL `ObjectQL` engine through the plugin's own adapter * (`wrapEngineAsSettingsEngine`); only the driver is a Map. Its matcher is @@ -299,6 +302,33 @@ describe('a tenant-scope write that names no organization', () => { }); }); +describe('the posture is read only where it decides something', () => { + function withFailingPosture(): SettingsService { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(MANIFEST); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, { + tenancyPosture: () => { throw new Error('tenancy unreadable'); }, + }); + return svc; + } + + it('a caller that names an organization, and any read of a global key, never ask it', async () => { + await settings('isolated').set(NS, 'banner', 'Everyone', {}); + const svc = withFailingPosture(); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe('Alpha'); + expect((await svc.get(NS, 'banner', {})).value).toBe('Everyone'); + expect((await svc.getMany(NS, ['banner'], {})).banner.value).toBe('Everyone'); + }); + + it('an unreadable posture is not guessed at for a caller that names no organization: the read and the write fail', async () => { + const svc = withFailingPosture(); + await expect(svc.get(NS, 'motto', {})).rejects.toThrow('tenancy unreadable'); + await expect(svc.set(NS, 'motto', 'Nobody', {})).rejects.toThrow('tenancy unreadable'); + expect(settingRows()).toHaveLength(0); + }); +}); + describe("posture 'single': the default organization keeps its answers", () => { const DEFAULT_ORG = 'org_default'; diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 3139815c736..2983dd21274 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -170,6 +170,12 @@ type OrganizationReach = | { kind: 'organization-less' } | { kind: 'unwalled' }; +/** + * The reach a read of a `global` key is made with: it has no tenant or user + * rung, so which organization's rows the load returns cannot change its answer. + */ +const ANY_ORGANIZATION: OrganizationReach = Object.freeze({ kind: 'unwalled' as const }); + /** * Where the service learns the tenancy posture IN FORCE — the `tenancy` * service's answer, which the plugin supplies at bind time. `undefined` means @@ -1416,7 +1422,10 @@ export class SettingsService { // The user rung's pick compares the owner too (see `resolveKeyFromRows`), // and both rungs read only the rows the caller's organization reaches. const userId = scope === 'user' ? callerUserIdOf(ctx) : null; - const reach = await this.organizationReachOf(ctx); + // A `global` key has no tenant or user rung, so no reach changes its + // answer — and its readers (boot-time plugins, mostly) do not wait on the + // posture for one. + const reach = scope === 'global' ? ANY_ORGANIZATION : await this.organizationReachOf(ctx); const rows = await this.loadRows(namespace, userId, reach); return this.resolveKeyFromRows(reg, key, scope, rows, userId, reach); } @@ -1519,8 +1528,11 @@ export class SettingsService { ...(otherKeys.length > 0 ? [null] : []), ]; // ONE organization reach for the whole call — every key is resolved for - // the same caller, so the grouping above is by user only. - const reach = await this.organizationReachOf(ctx); + // the same caller, so the grouping above is by user only. Asked only + // when a key below the global rung needs it (see `get`). + const reach = pending.some((p) => p.scope !== 'global') + ? await this.organizationReachOf(ctx) + : ANY_ORGANIZATION; const sets = await this.loadRowSets(namespace, groups, reach); const userRows = userKeys.length > 0 ? sets[0] : []; const otherRows = otherKeys.length > 0 ? sets[sets.length - 1] : [];