diff --git a/.changeset/30823-settings-stored-beneath-pin.md b/.changeset/30823-settings-stored-beneath-pin.md new file mode 100644 index 00000000000..07bc7603e48 --- /dev/null +++ b/.changeset/30823-settings-stored-beneath-pin.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-settings': patch +--- + +feat(service-settings): the namespace read lists what is stored beneath a field an `OS_*` variable pins, so Setup can tell whether "clear the saved value" has anything to clear + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Now.** `getNamespace()`, the read `GET /api/settings/:namespace` serves, answers a key whose `OS_*` override is in force with the same `value`, `source: 'env'`, `locked: true` and `lockedReason` as before, and its `cascadeChain` now lists, below the `env` entry, the entries the key's read carries with no override in force: the stored rows the caller's cascade draws on (`global`, `tenant`, `user`, each with its own lock), then `default`. The `env` entry stays first and is still the only one flagged `effective`. An entry at the key's declared scope with a non-null value is the value a clear (`null`, accepted through a pin since the ADR-0007 D6 amendment) removes; after that clear the entry reads `null`. +- **Secrets.** A secret key's entries beneath a pin carry the read mask when a value is stored and `null` when none is, in process too; the stored secret is never decrypted for them, so a key pinned because its stored secret no longer decrypts still reads. +- **Unchanged.** `get()` and `getMany()` still answer an override in force from the env alone, without reading the store: they are the reads request paths call. An override whose value coerces to `null` keeps its single `env` entry, because any non-null entry listed below it would read as the effective one. +- **Cost.** A pinned key joins the namespace read's existing grouped row load, so the read costs one more load only when every key of a group is pinned. diff --git a/content/docs/protocol/kernel/config-resolution.mdx b/content/docs/protocol/kernel/config-resolution.mdx index 0cd12f22820..a20b7e62dca 100644 --- a/content/docs/protocol/kernel/config-resolution.mdx +++ b/content/docs/protocol/kernel/config-resolution.mdx @@ -262,6 +262,19 @@ OS_ALLOWED_ORIGINS=["a","b","c"] # default is array/object → JSON.parse If the raw value cannot be parsed for the expected type, the raw string is used. +**What is stored beneath a pin is listed by the namespace read.** An override in +force answers `source: 'env'`, `locked: true`, and `get()` / `getMany()` build +that answer from the env alone, without reading the store. `getNamespace()` — the +read `GET /api/settings/:namespace` serves to the Setup page — also lists, below +the `env` entry of `cascadeChain`, the entries the key's read would carry with no +override in force: its stored rows, then the default. The `env` entry stays the +effective one. An entry at the key's declared scope with a non-null value is the +value a clear (`null`) removes, so the page can tell whether "clear the saved +value" has anything to clear. A secret's entries beneath the pin carry the read +mask or `null`, never its value. An override whose value coerces to `null` keeps +its single `env` entry, since any non-null entry below it would read as the +effective one. + **A value outside a declared option table is ignored, not repaired.** When the specifier declares `options` and the env value matches none of them, the env layer contributes nothing at all — no value and no `cascadeChain` entry — and diff --git a/packages/services/service-settings/README.md b/packages/services/service-settings/README.md index 40e70ff078e..e076c934c49 100644 --- a/packages/services/service-settings/README.md +++ b/packages/services/service-settings/README.md @@ -34,7 +34,11 @@ Such fields are returned with `source: 'env', locked: true`. Every write (service or REST) but a clear (`null`) fails with HTTP 409. A clear removes the value stored beneath the override and changes nothing in force: the key still answers the env value, and falls back to its default once the variable -is removed. +is removed. The namespace read (`getNamespace`, `GET /api/settings/:namespace`) +lists the stored entries beneath such a field's `env` entry in `cascadeChain`, +so a client can tell whether a clear has a value to remove (a secret's entry +shows the mask, never the value); `get()` and `getMany()` answer it from the +env alone. ## Encryption diff --git a/packages/services/service-settings/src/settings-getmany.test.ts b/packages/services/service-settings/src/settings-getmany.test.ts index 557bc67b651..68d390d3ac8 100644 --- a/packages/services/service-settings/src/settings-getmany.test.ts +++ b/packages/services/service-settings/src/settings-getmany.test.ts @@ -242,3 +242,24 @@ describe('[#10826] SettingsService.getMany', () => { expect((await svc.get('localization', 'timezone', ctx)).value).toBe('America/New_York'); }); }); + +describe('[#30823] getNamespace lists a pinned key\'s stored rows from the same grouped load', () => { + it('a pin over a global row lists that row beneath its env entry, at no more finds than the read without the pin', async () => { + const { svc, engine } = await makeService(); + const ctx = { userId: 'u1' }; + engine.find.mockClear(); + await svc.getNamespace('localization', ctx); + const unpinnedFinds = engine.find.mock.calls.length; + + process.env.OS_LOCALIZATION_TIMEZONE = 'Asia/Tokyo'; + engine.find.mockClear(); + const ns = await svc.getNamespace('localization', ctx); + expect(engine.find).toHaveBeenCalledTimes(unpinnedFinds); + expect(ns.values.timezone).toMatchObject({ value: 'Asia/Tokyo', source: 'env', locked: true }); + expect(ns.values.timezone.cascadeChain).toEqual([ + expect.objectContaining({ scope: 'env', value: 'Asia/Tokyo', effective: true }), + { scope: 'global', value: 'America/New_York', locked: false, lockedReason: undefined }, + { scope: 'default', value: 'UTC' }, + ]); + }); +}); diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index 7b10445497b..97ec66c9906 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -614,8 +614,10 @@ describe('settings-routes — #7522 encrypted values are redacted at the REST bo expect(r1.state.body.data.values.smtp_password.source).toBe('env'); expect(r1.state.body.data.values.smtp_password.locked).toBe(true); expect(r1.state.body.data.values.smtp_password.lockedReason).toContain('OS_SECRETSNS_SMTP_PASSWORD'); + // Beneath the env entry, the stored cascade: nothing stored, so the default. expect(r1.state.body.data.values.smtp_password.cascadeChain).toEqual([ expect.objectContaining({ scope: 'env', value: SETTINGS_SECRET_MASK, locked: true }), + { scope: 'default', value: null }, ]); const write = http.routes.get('PUT /api/settings/:namespace')!; @@ -921,3 +923,78 @@ describe('settings-routes — the two retired Branding keys (#24766)', () => { expect(await storedRetiredRows()).toEqual(RETIRED_BRANDING_ROWS); }); }); + +describe('settings-routes — GET /:ns lists what is stored beneath an env pin', () => { + /** The secret stack with an env the test pins after storing. */ + function pinnableSecretStack() { + const env: Record = {}; + const secretRows = new Map(); + const cryptoProvider = new LocalCryptoProvider(); + const svc = new SettingsService({ + env, + cryptoProvider, + secretStore: { + async insert(row) { secretRows.set(row.id, row); return { id: row.id }; }, + async get(id) { return secretRows.get(id) ?? null; }, + async update(id, patch) { secretRows.set(id, { ...secretRows.get(id), ...patch }); }, + }, + }); + svc.registerManifest(secretsManifest); + const http = new MockHttp(); + registerSettingsRoutes(http, svc, { contextFromRequest: secretAdmin }); + const read = async () => { + const r = makeReqRes({ params: { namespace: 'secretsns' } }); + await http.routes.get('GET /api/settings/:namespace')!(r.req, r.res); + return r.state; + }; + return { env, svc, http, cryptoProvider, read }; + } + + it('a pinned key\'s stored row rides beneath its env entry; a pinned secret\'s shows the mask, never the value', async () => { + const { env, svc, cryptoProvider, read } = pinnableSecretStack(); + await svc.set('secretsns', 'smtp_host', 'stored.example.com', { userId: 'usr_1' }); + await svc.set('secretsns', 'smtp_password', SMTP_PLAINTEXT, { userId: 'usr_1' }); + env.OS_SECRETSNS_SMTP_HOST = 'env.example.com'; + env.OS_SECRETSNS_SMTP_PASSWORD = 'env-supplied-secret'; + env.OS_SECRETSNS_WEBHOOK_TOKEN = 'env-supplied-token'; + const decrypt = vi.spyOn(cryptoProvider, 'decrypt'); + + const state = await read(); + expect(state.status).toBe(200); + const values = state.body.data.values; + expect(values.smtp_host).toMatchObject({ value: 'env.example.com', source: 'env', locked: true }); + expect(values.smtp_host.cascadeChain).toEqual([ + expect.objectContaining({ scope: 'env', value: 'env.example.com', effective: true }), + { scope: 'user', value: 'stored.example.com' }, + { scope: 'default', value: 'smtp.example.com' }, + ]); + expect(values.smtp_password.cascadeChain).toEqual([ + expect.objectContaining({ scope: 'env', value: SETTINGS_SECRET_MASK, effective: true }), + { scope: 'user', value: SETTINGS_SECRET_MASK }, + { scope: 'default', value: null }, + ]); + // A pinned secret with nothing stored lists no stored entry. + expect(values.webhook_token.cascadeChain.slice(1)).toEqual([{ scope: 'default', value: null }]); + const wire = JSON.stringify(state.body); + expect(wire).not.toContain(SMTP_PLAINTEXT); + expect(wire).not.toContain('env-supplied-secret'); + expect(decrypt).not.toHaveBeenCalled(); + }); + + it('after PUT clears the stored value through the pin, the next read lists it null', async () => { + const { env, svc, http, read } = pinnableSecretStack(); + await svc.set('secretsns', 'smtp_host', 'stored.example.com', { userId: 'usr_1' }); + env.OS_SECRETSNS_SMTP_HOST = 'env.example.com'; + + const put = makeReqRes({ params: { namespace: 'secretsns' }, body: { smtp_host: null } }); + await http.routes.get('PUT /api/settings/:namespace')!(put.req, put.res); + expect(put.state.status).toBe(200); + + const values = (await read()).body.data.values; + expect(values.smtp_host).toMatchObject({ value: 'env.example.com', source: 'env', locked: true }); + expect(values.smtp_host.cascadeChain.slice(1)).toEqual([ + { scope: 'user', value: null }, + { scope: 'default', value: 'smtp.example.com' }, + ]); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 6058880afdb..66af755d7ce 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -18,6 +18,7 @@ import { companySettingsManifest } from './manifests/company.manifest.js'; import { brandingSettingsManifest } from './manifests/branding.manifest.js'; import { builtinSettingsManifests } from './manifests/index.js'; import { evaluateVisibility, visibilitySource } from './visibility-eval.js'; +import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; import { SettingsManifestSchema } from '@objectstack/spec/system'; describe('reference manifests are spec-valid', () => { @@ -2969,3 +2970,161 @@ describe('SettingsService — a reset clears the value stored beneath an env pin expect(r.message).toBe('Cleared 1 saved value(s); environment/default configuration is back in effect.'); }); }); + +describe('SettingsService — the namespace read lists what is stored beneath an env pin', () => { + // A pin on each rung a clear can land at, a secret, and a JSON key whose env + // value can coerce to null. + const BENEATH = { + namespace: 'beneath', + version: 1, + label: 'Beneath', + scope: 'tenant', + specifiers: [ + { type: 'text', key: 'motto', label: 'Motto', scope: 'tenant', default: 'none' }, + { type: 'text', key: 'banner', label: 'Banner', scope: 'global', default: 'none' }, + { type: 'text', key: 'nickname', label: 'Nickname', scope: 'user', default: 'none' }, + { type: 'password', key: 'api_secret', label: 'API secret', scope: 'tenant' }, + { type: 'password', key: 'spare_secret', label: 'Spare secret', scope: 'tenant' }, + { type: 'json', key: 'rules', label: 'Rules', scope: 'tenant', default: [] }, + ], + } as any; + const envEntry = (value: unknown, envName: string) => ({ + scope: 'env', value, locked: true, lockedReason: `Set via env: ${envName}`, effective: true, + }); + + /** A crypto adapter whose decrypt is watched, so a read can prove it never opened a secret. */ + function watchedCrypto(): CryptoAdapter & { decrypt: ReturnType } { + return { + confidential: true, + encrypt: async (plain: string) => 'kms:' + Buffer.from(plain, 'utf8').toString('base64'), + decrypt: vi.fn(async (cipher: string) => Buffer.from(cipher.replace(/^kms:/, ''), 'base64').toString('utf8')), + digest: () => 'fnv32:deadbeef', + }; + } + + /** `motto` stored then pinned: the operator's recovery, before the clear. */ + async function pinnedOverStored() { + const env: Record = {}; + const crypto = watchedCrypto(); + const svc = new SettingsService({ env, crypto }); + svc.registerManifest(BENEATH); + await svc.set('beneath', 'motto', 'Stored motto'); + env.OS_BENEATH_MOTTO = 'Pinned motto'; + return { env, svc, crypto }; + } + + it('a pinned key over a stored row lists that row beneath its env entry; the answer is otherwise the pin\'s', async () => { + const { svc } = await pinnedOverStored(); + const { values } = await svc.getNamespace('beneath'); + + expect(values.motto).toEqual({ + value: 'Pinned motto', + source: 'env', + locked: true, + lockedReason: 'Set via env: OS_BENEATH_MOTTO', + cascadeChain: [ + envEntry('Pinned motto', 'OS_BENEATH_MOTTO'), + { scope: 'tenant', value: 'Stored motto', locked: false, lockedReason: undefined }, + { scope: 'default', value: 'none' }, + ], + }); + // The env entry is the only effective one, and the first non-null one. + expect(values.motto.cascadeChain!.filter((e) => e.effective)).toHaveLength(1); + }); + + it('after the clear through the pin the stored entry reads null; a pin with no row lists none', async () => { + const { env, svc } = await pinnedOverStored(); + await svc.set('beneath', 'motto', null); + env.OS_BENEATH_BANNER = 'Pinned banner'; + + const { values } = await svc.getNamespace('beneath'); + expect(values.motto.cascadeChain).toEqual([ + envEntry('Pinned motto', 'OS_BENEATH_MOTTO'), + { scope: 'tenant', value: null, locked: false, lockedReason: undefined }, + { scope: 'default', value: 'none' }, + ]); + expect(values.banner.cascadeChain).toEqual([ + envEntry('Pinned banner', 'OS_BENEATH_BANNER'), + { scope: 'default', value: 'none' }, + ]); + }); + + it('a global key lists its global row, and an upper row\'s lock rides along, so a refused clear is visible', async () => { + const { env, svc } = await pinnedOverStored(); + await svc.set('beneath', 'banner', 'Stored banner'); + env.OS_BENEATH_BANNER = 'Pinned banner'; + await (svc as any).upsertRow({ + namespace: 'beneath', key: 'motto', scope: 'global', user_id: null, value: 'Platform motto', + value_enc: null, encrypted: false, locked: true, locked_reason: 'Set by the platform.', + }); + + const { values } = await svc.getNamespace('beneath'); + expect(values.banner.cascadeChain).toEqual([ + envEntry('Pinned banner', 'OS_BENEATH_BANNER'), + { scope: 'global', value: 'Stored banner', locked: false, lockedReason: undefined }, + { scope: 'default', value: 'none' }, + ]); + expect(values.motto.cascadeChain!.slice(1, 3)).toEqual([ + { scope: 'global', value: 'Platform motto', locked: true, lockedReason: 'Set by the platform.' }, + { scope: 'tenant', value: 'Stored motto', locked: false, lockedReason: undefined }, + ]); + // The pin still answers: an upper row's lock does not take the lock over. + expect(values.motto).toMatchObject({ source: 'env', lockedReason: 'Set via env: OS_BENEATH_MOTTO' }); + }); + + it('a user key lists the caller\'s own row only', async () => { + const env: Record = {}; + const svc = new SettingsService({ env }); + svc.registerManifest(BENEATH); + await svc.set('beneath', 'nickname', 'Ada', { userId: 'usr_a' }); + await svc.set('beneath', 'nickname', 'Bob', { userId: 'usr_b' }); + env.OS_BENEATH_NICKNAME = 'Pinned'; + + const forA = (await svc.getNamespace('beneath', { userId: 'usr_a' })).values.nickname.cascadeChain!; + expect(forA.slice(1)).toEqual([{ scope: 'user', value: 'Ada' }, { scope: 'default', value: 'none' }]); + const forNobody = (await svc.getNamespace('beneath')).values.nickname.cascadeChain!; + expect(forNobody.slice(1)).toEqual([{ scope: 'default', value: 'none' }]); + }); + + it('a secret beneath a pin shows that a value is stored, never the value, and is never decrypted', async () => { + const { env, svc, crypto } = await pinnedOverStored(); + await svc.set('beneath', 'api_secret', 'stored-secret-plaintext'); + env.OS_BENEATH_API_SECRET = 'env-secret'; + env.OS_BENEATH_SPARE_SECRET = 'env-spare'; + crypto.decrypt.mockClear(); + + const { values } = await svc.getNamespace('beneath'); + expect(values.api_secret.cascadeChain).toEqual([ + envEntry('env-secret', 'OS_BENEATH_API_SECRET'), + { scope: 'tenant', value: SETTINGS_SECRET_MASK, locked: false, lockedReason: undefined }, + { scope: 'default', value: null }, + ]); + expect(values.spare_secret.cascadeChain!.slice(1)).toEqual([{ scope: 'default', value: null }]); + expect(JSON.stringify(values)).not.toContain('stored-secret-plaintext'); + expect(crypto.decrypt).not.toHaveBeenCalled(); + + // Cleared through the pin, the secret's stored entry reads null. + await svc.set('beneath', 'api_secret', null); + const after = (await svc.getNamespace('beneath')).values.api_secret.cascadeChain!; + expect(after[1]).toEqual({ scope: 'tenant', value: null, locked: false, lockedReason: undefined }); + }); + + it('an override whose value coerces to null keeps its single env entry', async () => { + const env: Record = {}; + const svc = new SettingsService({ env }); + svc.registerManifest(BENEATH); + await svc.set('beneath', 'rules', ['stored']); + env.OS_BENEATH_RULES = 'null'; + + const { values } = await svc.getNamespace('beneath'); + expect(values.rules).toMatchObject({ value: null, source: 'env', locked: true }); + expect(values.rules.cascadeChain).toEqual([envEntry(null, 'OS_BENEATH_RULES')]); + }); + + it('get() and getMany() still answer a pin from the env alone', async () => { + const { svc } = await pinnedOverStored(); + const pinOnly = [envEntry('Pinned motto', 'OS_BENEATH_MOTTO')]; + expect((await svc.get('beneath', 'motto')).cascadeChain).toEqual(pinOnly); + expect((await svc.getMany('beneath', ['motto'])).motto.cascadeChain).toEqual(pinOnly); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index c66f6dbbdc6..b3f8570733a 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -1949,6 +1949,10 @@ export class SettingsService { // picked up by the `lockedEntry` scan below and reported as locking a value // it is not even providing, which is the opposite of what the read API owes // its caller. + // + // An override in force answers here without touching the store: this is + // the lightweight read request paths call. What is stored beneath the pin + // is listed by the inspection read, `getNamespace` (#30823). const envOverride = this.effectiveEnvOverride(reg, namespace, key); if (envOverride) { const { envName, value } = envOverride; @@ -2053,6 +2057,29 @@ export class SettingsService { namespace: string, keys: readonly string[], ctx: SettingsContext = {}, + ): Promise> { + return this.resolveMany(namespace, keys, ctx, false); + } + + /** + * {@link getMany}, and, when `beneathPins` is set, the stored cascade + * beneath every in-force env override too ({@link withStoredBeneath}). + * + * `beneathPins` is the inspection read's, {@link getNamespace}: the + * namespace read the settings page draws from, where a field an `OS_*` + * variable pins must say whether a value is stored beneath the pin, so the + * page can offer the clear {@link setMany} lets through it (ADR-0007 D6). + * `getMany` and {@link get} leave it off: they are the lightweight resolver + * that request paths call (the localization read behind every request + * context), and an override answers there without touching the store. + * A pinned key joins the grouped load below, so the inspection read costs + * no extra load unless every key of a group is pinned. + */ + private async resolveMany( + namespace: string, + keys: readonly string[], + ctx: SettingsContext, + beneathPins: boolean, ): Promise> { const reg = this.registry.get(namespace); if (!reg) throw new UnknownNamespaceError(namespace); @@ -2060,7 +2087,7 @@ export class SettingsService { if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key); } const out: Record = {}; - const pending: Array<{ key: string; scope: SpecifierScope }> = []; + const pending: Array<{ key: string; scope: SpecifierScope; pinned?: true }> = []; for (const key of keys) { const envOverride = this.effectiveEnvOverride(reg, namespace, key); if (envOverride) { @@ -2074,6 +2101,12 @@ export class SettingsService { { scope: 'env', value, locked: true, lockedReason: `Set via env: ${envName}`, effective: true }, ], }; + // An override whose value is null keeps its one entry: anything listed + // beneath it would be the first non-null entry of the chain, which the + // read contract makes the effective source, and the env value is. + if (beneathPins && value !== null && value !== undefined) { + pending.push({ key, scope: reg.scopes.get(key)!, pinned: true }); + } continue; } pending.push({ key, scope: reg.scopes.get(key)! }); @@ -2098,11 +2131,26 @@ export class SettingsService { 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, reach); + const resolve = async ( + { key, scope, pinned }: (typeof pending)[number], + rows: SettingsRow[], + rowUserId: string | null, + ): Promise => { + if (!pinned) return this.resolveKeyFromRows(reg, key, scope, rows, rowUserId, reach); + // A secret beneath a pin is not in force, so no caller needs its + // plaintext, and decrypting it could only fail the read: its rows say + // whether a value is stored, as the REST read's mask says it. + const materialise = reg.encryptedKeys.has(key) + ? async (row: SettingsRow) => storedSecretPresence(row) + : undefined; + const stored = await this.resolveKeyFromRows(reg, key, scope, rows, rowUserId, reach, materialise); + return withStoredBeneath(out[key], stored); + }; + for (const entry of userKeys) { + out[entry.key] = await resolve(entry, userRows, userId); } - for (const { key, scope } of otherKeys) { - out[key] = await this.resolveKeyFromRows(reg, key, scope, otherRows, null, reach); + for (const entry of otherKeys) { + out[entry.key] = await resolve(entry, otherRows, null); } } return out; @@ -2123,6 +2171,9 @@ export class SettingsService { * The tenant and user rungs each take ONE row, the one the reach owns * ({@link preferredRow}) — never whichever row a positional `find` happens * to meet first. + * + * `materialise` turns a row into the value its entry carries; by default + * {@link materialiseRow}, which decrypts a secret. */ private async resolveKeyFromRows( reg: RegisteredManifest, @@ -2131,6 +2182,7 @@ export class SettingsService { rows: SettingsRow[], userId: string | null, reach: OrganizationReach, + materialise: (row: SettingsRow) => Promise = (row) => this.materialiseRow(row), ): Promise> { // 2. cascade walk — OS_* env (handled by callers) > global > tenant > user > default // @@ -2141,7 +2193,7 @@ export class SettingsService { const globalRow = rows.find((r) => r.key === key && r.scope === 'global'); if (globalRow) { - const value = await this.materialiseRow(globalRow); + const value = await materialise(globalRow); chain.push({ scope: 'global', value, @@ -2158,7 +2210,7 @@ export class SettingsService { if (tenantRow) { chain.push({ scope: 'tenant', - value: await this.materialiseRow(tenantRow), + value: await materialise(tenantRow), locked: !!tenantRow.locked, lockedReason: tenantRow.locked_reason ?? undefined, }); @@ -2177,7 +2229,7 @@ export class SettingsService { if (userRow) { chain.push({ scope: 'user', - value: await this.materialiseRow(userRow), + value: await materialise(userRow), }); } } @@ -2281,7 +2333,17 @@ export class SettingsService { return posture !== undefined && postureEnforcesWall(posture); } - /** Resolve every value in a namespace + return the manifest. */ + /** + * Resolve every value in a namespace + return the manifest. + * + * The inspection read: the one the settings page draws from + * (`GET /api/settings/:namespace` serves it, redacted). Each key resolves + * as {@link getMany} resolves it, and a key an `OS_*` override pins also + * lists, beneath its env entry, the stored cascade the key falls back to + * once the override is removed (#30823) — so the page can tell whether a + * value is stored beneath the pin, at the scope a clear would null. A + * secret's entries there carry the read mask or `null`, never its value. + */ async getNamespace( namespace: string, ctx: SettingsContext = {}, @@ -2294,8 +2356,9 @@ export class SettingsService { this.assertPermitted(reg.manifest, 'read', ctx); // [#10826] One grouped row load instead of one per key — same resolution - // per key by construction (getMany shares get()'s extracted cascade). - const values = await this.getMany(namespace, Array.from(reg.scopes.keys()), ctx); + // per key by construction (getMany shares get()'s extracted cascade), and + // [#30823] the stored cascade beneath each pin from that same load. + const values = await this.resolveMany(namespace, Array.from(reg.scopes.keys()), ctx, true); // A key whose options a source serves is offered what the running system // offers, for the value in effect (`registerOptionSource`). return { manifest: this.servedManifest(reg, namespace, values, display), values }; @@ -4110,6 +4173,32 @@ export class SettingsService { // Local helpers // --------------------------------------------------------------------------- +/** + * [#30823] An env-pinned answer with the stored cascade beneath the pin: the + * env entry stays first and the effective one, and below it come the entries + * the key's read lists when no override is in force (the rows the caller's + * cascade draws on, then the default), so a reader can tell whether a value is + * stored at the scope a clear would null. Their own `effective` flag goes: + * the stored pick is not what is in force. Nothing else of the answer moves. + */ +function withStoredBeneath(pinned: ResolvedSettingValue, stored: ResolvedSettingValue): ResolvedSettingValue { + const beneath = (stored.cascadeChain ?? []).map((entry) => { + const copy = { ...entry }; + delete copy.effective; + return copy; + }); + return { ...pinned, cascadeChain: [...(pinned.cascadeChain ?? []), ...beneath] }; +} + +/** + * What a secret's row shows beneath a pin: the read mask when it stores a + * value, else `null`. Read off the row, never decrypted. + */ +function storedSecretPresence(row: SettingsRow): string | null { + const stored = row.encrypted ? row.value_enc : row.value; + return stored === null || stored === undefined ? null : SETTINGS_SECRET_MASK; +} + /** Stable stringify so the audit digest is order-independent. */ function stableStringify(input: unknown): string { if (input === null || typeof input !== 'object') return JSON.stringify(input);