From 87457d35ec5fcd2a11873e665cbc078576c5b165 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:56:21 +0000 Subject: [PATCH 1/4] fix(service-settings): a clear of an env-pinned setting clears the stored value beneath the pin An env override in force refused every write, clears included, so the documented allowed-IP recovery (OS_AUTH_ALLOWED_IP_RANGES set empty) could not clear the stored list and removing the variable locked the caller out again. A clear (null/undefined) now goes through the pin; every other write stays 409 SETTINGS_LOCKED. The refusal copy names the full recovery. Claude-Session: https://claude.ai/code/session_01Utp6CZjAtpVuoYZBzB9n5i Co-authored-by: Claude --- .../plugin-auth/src/allowed-ip-ranges.ts | 29 ++++++++++++---- .../service-settings/src/settings-service.ts | 33 +++++++++++++++++-- .../src/settings-service.types.ts | 6 +++- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts index 007e9add7ee..5791f10e1f4 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts @@ -25,9 +25,13 @@ * lock out, and it is let through. * * The way back when locked out anyway — a list saved from another address, or - * an address that changed — is the settings env override every auth key has: - * `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, then a restart. The - * refusal names it. + * an address that changed — is the settings env override every auth key has, + * in four steps, and the refusal names them: start the server with + * `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value (the override wins over + * the stored list, so the gate is open), sign in, clear the stored list (a + * settings write of `null` to the key, which the override lets through, + * #30385), then remove the variable. Removing it without the clear brings the + * stored list back at the next restart. */ import { resolveLocale } from '@objectstack/core'; @@ -90,7 +94,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { 'The server cannot establish the address you are connected from, and while any list is set it refuses every ' + 'such request, your own next sign-in included. Save the list from a connection whose address the server can ' + 'establish.', - recovery: ` If you are locked out, start the server with ${ALLOWED_IP_RANGES_ENV} set to an empty value.`, + recovery: + ` If you are locked out, start the server with ${ALLOWED_IP_RANGES_ENV} set to an empty value, sign in, ` + + 'clear the stored list with a settings request that sets allowed_ip_ranges to null ' + + '(PUT /api/settings/auth), then remove the variable.', }, 'zh-CN': { outside: (address) => @@ -99,7 +106,9 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { undetermined: '服务器无法确定你当前连接所用的地址;只要设置了列表,这样的请求都会被拒绝,你自己下一次登录也不例外。' + '请在服务器能确定地址的连接上保存。', - recovery: `如果已被锁在外面,可将环境变量 ${ALLOWED_IP_RANGES_ENV} 设为空值后重启服务器。`, + recovery: + `如果已被锁在外面:将环境变量 ${ALLOWED_IP_RANGES_ENV} 设为空值后重启服务器并登录,` + + '用一次把 allowed_ip_ranges 设为 null 的设置请求(PUT /api/settings/auth)清除已保存的列表,再去掉该环境变量。', }, 'ja-JP': { outside: (address) => @@ -108,7 +117,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { undetermined: 'サーバーは現在の接続のアドレスを確定できません。リストが設定されている間はそのようなリクエストはすべて拒否され、' + 'ご自身の次回のサインインも同様です。サーバーがアドレスを確定できる接続から保存してください。', - recovery: `締め出された場合は、環境変数 ${ALLOWED_IP_RANGES_ENV} を空の値にしてサーバーを再起動してください。`, + recovery: + `締め出された場合は、環境変数 ${ALLOWED_IP_RANGES_ENV} を空の値にしてサーバーを再起動してサインインし、` + + 'allowed_ip_ranges を null にする設定リクエスト(PUT /api/settings/auth)で保存済みのリストを消去してから、' + + '環境変数を削除してください。', }, 'es-ES': { outside: (address) => @@ -119,7 +131,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { 'El servidor no puede determinar la dirección desde la que está conectado y, mientras haya una lista, rechaza ' + 'todas esas solicitudes, incluido su propio próximo inicio de sesión. Guarde la lista desde una conexión cuya ' + 'dirección el servidor pueda determinar.', - recovery: ` Si queda bloqueado, inicie el servidor con ${ALLOWED_IP_RANGES_ENV} con un valor vacío.`, + recovery: + ` Si queda bloqueado, inicie el servidor con ${ALLOWED_IP_RANGES_ENV} con un valor vacío, inicie sesión, ` + + 'borre la lista guardada con una solicitud de ajustes que ponga allowed_ip_ranges a null ' + + '(PUT /api/settings/auth) y después quite la variable.', }, }; diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 556051f9a85..56ef11e58dd 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2340,7 +2340,12 @@ export class SettingsService { }; } - /** Persist a single key. Throws SettingsLockedError when env-locked. */ + /** + * Persist a single key. Throws SettingsLockedError when an env override in + * force pins the key, unless `value` is a clear (`null` / `undefined`), which + * clears the stored value beneath the pin and leaves the env value in force + * (see {@link setMany}). + */ async set( namespace: string, key: string, @@ -2360,6 +2365,14 @@ export class SettingsService { * the row it would store names no owner, and the user rung answers only its * owner, so no reader would ever see it while the write reported success. * + * A key an env override in force pins refuses every write with + * {@link SettingsLockedError} (`locked-by-env`, ADR-0007 D6) but a clear + * (`null` / `undefined`): a clear nulls the row stored beneath the pin, so + * the key falls back to its default once the override is removed, and + * changes nothing in force — the answer still reads the env value with + * `locked: true`. It is judged by the row locks and the write predicates + * like any other clear. + * * `display` picks the language of a write predicate's refusal and hands it * the request the write arrived on, and nothing else (see * {@link refuseByWritePredicates}). @@ -2403,7 +2416,23 @@ export class SettingsService { // UI refused with `SETTINGS_LOCKED`, and `get()` reporting `locked: false` // to a settings page that would then fail its own save. See // `effectiveEnvOverride`. - if (this.effectiveEnvOverride(reg, namespace, key)) { + // + // [#30385] A CLEAR goes through the pin (ADR-0007 D6 keeps refusing every + // other write): `null` / `undefined`, the spelling every clear in this + // service uses, never a value such as `''` that a key would store. The + // pin decides what is in force, and a clear changes nothing there — the + // env value still wins, and the answer still reads `locked: true` from + // `env`. What it does change is the stored value the key falls back to + // once the override is removed, and that value is otherwise reachable by + // nothing: the override hides it from every read and refuses every + // write, so an operator who pinned a key to recover from a bad stored + // value (`OS_AUTH_ALLOWED_IP_RANGES=` for an allow-list that locks them + // out) gets the same bad value back the moment the pin is lifted. The + // clear then meets every judgment a clear meets without a pin — the + // Phase 2 row locks and the platform-rung rule below, the write + // predicates — so no lock a row carries is bypassed. + const clears = patch[key] === null || typeof patch[key] === 'undefined'; + if (!clears && this.effectiveEnvOverride(reg, namespace, key)) { throw new SettingsLockedError(namespace, key); } diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index b1234bdcd4c..53e8f6480cd 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -594,7 +594,11 @@ export function envKeyOf(namespace: string, key: string): string { return `OS_${slug}`; } -/** Thrown when a caller tries to write a value pinned by env. */ +/** + * Thrown when a caller tries to write a value pinned by env (`locked-by-env`; + * a clear — `null` / `undefined` — is not refused, it clears the stored value + * beneath the pin), or past a locked row (`locked-by-`). + */ export class SettingsLockedError extends Error { readonly code = 'SETTINGS_LOCKED' as const; constructor( From 0b04207d6ac8a91fc5886ba165827169c3a3110e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:59:14 +0000 Subject: [PATCH 2/4] test: pin the clear through an env pin and the allowed-IP recovery end to end Claude-Session: https://claude.ai/code/session_01Utp6CZjAtpVuoYZBzB9n5i Co-authored-by: Claude --- .../src/allowed-ip-ranges.pin.test.ts | 37 +++++++ .../src/settings-service.test.ts | 104 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts index 95dc4b3cc42..08793235046 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts @@ -21,6 +21,10 @@ * the deployment declares `trustProxy`. A list that includes it saves. * - The recovery the refusal names: `OS_AUTH_ALLOWED_IP_RANGES` set empty * opens the gate on the next boot over a stored list. + * - [#30385] The rest of that recovery: while the override is in force a + * clear (`null`) goes through the settings door and clears the stored + * list, so removing the override does not bring it back; any other value + * is still refused `409 SETTINGS_LOCKED`. * * `@objectstack/service-settings` resolves to its `src/` here (this package's * `vitest.config.ts`), so the routes and the service are the checkout's. @@ -229,3 +233,36 @@ describe('#30203 the recovery the refusal names', () => { expect(recovered.manager.isClientIpAllowed('::1')).toBe(true); }); }); + +describe('#30385 the env recovery clears the stored list, so lifting the override does not lock the caller out again', () => { + it('set the override empty, sign in, clear the stored list through the settings door, remove the override: the caller is admitted', async () => { + const env: Record = {}; + const svc = new SettingsService({ env }); + svc.registerManifest(authManifest); + await svc.set('auth', 'allowed_ip_ranges', OFFICE, {}); + expect((await boot({ svc })).manager.isClientIpAllowed('::1')).toBe(false); + + // Start the server with the override set empty: the gate opens. + env.OS_AUTH_ALLOWED_IP_RANGES = ''; + const recovered = await boot({ svc }); + expect(recovered.manager.isClientIpAllowed('::1')).toBe(true); + + // A value is still refused while the override is in force, the empty string included. + const refused = await recovered.save({ allowed_ip_ranges: '' }, { remoteAddress: '::1' }); + expect(refused.status).toBe(409); + expect(refused.body.error.code).toBe('SETTINGS_LOCKED'); + expect(refused.body.error.details).toEqual({ namespace: 'auth', key: 'allowed_ip_ranges', reason: 'locked-by-env' }); + + // The clear goes through; the answer still reads the override, locked, and the gate stays open. + const cleared = await recovered.save({ allowed_ip_ranges: null }, { remoteAddress: '::1' }); + expect(cleared.status).toBe(200); + expect(cleared.body.data.values.allowed_ip_ranges).toMatchObject({ value: '', source: 'env', locked: true }); + expect(recovered.manager.isClientIpAllowed('::1')).toBe(true); + + // Remove the override and restart: no stored list comes back. + delete env.OS_AUTH_ALLOWED_IP_RANGES; + expect((await svc.get('auth', 'allowed_ip_ranges', {})).source).toBe('default'); + const restarted = await boot({ svc }); + expect(restarted.manager.isClientIpAllowed('::1')).toBe(true); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index b3e1b7349bb..d0cee47b1e6 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -2745,3 +2745,107 @@ describe('auth lockout window is enforced again (#7169)', () => { } }); }); + +describe('SettingsService — a clear goes through an env pin, every other write does not (#30385)', () => { + // Two keys a pin can sit on: one below the global rung, one on it. + const PINNED = { + namespace: 'pinned', + version: 1, + label: 'Pinned', + scope: 'tenant', + readPermission: 'setup.access', + writePermission: 'setup.write', + specifiers: [ + { type: 'text', key: 'motto', label: 'Motto', scope: 'tenant', default: 'none' }, + { type: 'text', key: 'banner', label: 'Banner', scope: 'global', default: 'none' }, + ], + } as any; + const orgAdmin = { enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_org_admin' }; + const platformAdmin = { + enforced: true, + permissions: ['setup.access', 'setup.write', 'manage_platform_settings'], + userId: 'usr_platform_admin', + }; + const refusalOf = (p: Promise) => p.then(() => undefined, (e: any) => e); + + /** A stored value under a pin that is set after it: the operator's recovery. */ + async function pinnedOverStored() { + const env: Record = {}; + const svc = new SettingsService({ env }); + svc.registerManifest(PINNED); + await svc.set('pinned', 'motto', 'Stored'); + env.OS_PINNED_MOTTO = 'Pinned'; + return { env, svc }; + } + + it('a clear clears the stored value beneath the pin and changes nothing in force: the answer still reads env, locked', async () => { + const { env, svc } = await pinnedOverStored(); + const changes: unknown[] = []; + svc.subscribe('pinned', (e) => { changes.push(e); }); + + const answer = await svc.set('pinned', 'motto', null); + expect(answer).toMatchObject({ value: 'Pinned', source: 'env', locked: true }); + expect(answer.lockedReason).toContain('OS_PINNED_MOTTO'); + expect(await svc.get('pinned', 'motto')).toMatchObject({ value: 'Pinned', source: 'env', locked: true }); + expect(changes).toEqual([expect.objectContaining({ key: 'motto', action: 'reset' })]); + + // Lift the pin: the key falls back to its default, not to the old value. + delete env.OS_PINNED_MOTTO; + expect(await svc.get('pinned', 'motto')).toMatchObject({ value: 'none', source: 'default', locked: false }); + }); + + it('`undefined` is a clear too, alongside an ordinary write in the same batch', async () => { + const { env, svc } = await pinnedOverStored(); + await svc.setMany('pinned', { motto: undefined, banner: 'Saved' }); + delete env.OS_PINNED_MOTTO; + expect((await svc.get('pinned', 'motto')).source).toBe('default'); + expect((await svc.get('pinned', 'banner')).value).toBe('Saved'); + }); + + it('every other write is refused SETTINGS_LOCKED as before, the empty string included, and the whole batch stores nothing', async () => { + const { env, svc } = await pinnedOverStored(); + for (const value of ['Other', '']) { + const refusal = await refusalOf(svc.set('pinned', 'motto', value)); + expect(refusal).toBeInstanceOf(SettingsLockedError); + expect(refusal).toMatchObject({ code: 'SETTINGS_LOCKED', reason: 'locked-by-env', key: 'motto' }); + } + const batch = await refusalOf(svc.setMany('pinned', { banner: 'Saved', motto: 'Other' })); + expect(batch).toMatchObject({ code: 'SETTINGS_LOCKED', reason: 'locked-by-env', key: 'motto' }); + + delete env.OS_PINNED_MOTTO; + expect(await svc.get('pinned', 'motto')).toMatchObject({ value: 'Stored', source: 'tenant' }); + expect((await svc.get('pinned', 'banner')).source).toBe('default'); + }); + + it('a locked row at an upper rung still refuses a clear below it', async () => { + const { env, svc } = await pinnedOverStored(); + await (svc as any).upsertRow({ + namespace: 'pinned', key: 'motto', scope: 'global', user_id: null, value: 'Platform motto', + value_enc: null, encrypted: false, locked: true, locked_reason: 'Set by the platform.', + }); + + const refusal = await refusalOf(svc.set('pinned', 'motto', null)); + expect(refusal).toBeInstanceOf(SettingsLockedError); + expect(refusal).toMatchObject({ code: 'SETTINGS_LOCKED', reason: 'locked-by-global', key: 'motto' }); + + delete env.OS_PINNED_MOTTO; + expect(await svc.get('pinned', 'motto')).toMatchObject({ value: 'Platform motto', source: 'global', locked: true }); + }); + + it('a locked row at the write\'s own rung refuses a non-platform clear, and the platform writes through it', async () => { + const env: Record = { OS_PINNED_BANNER: 'Pinned banner' }; + const svc = new SettingsService({ env }); + svc.registerManifest(PINNED); + await (svc as any).upsertRow({ + namespace: 'pinned', key: 'banner', scope: 'global', user_id: null, value: 'Platform banner', + value_enc: null, encrypted: false, locked: true, locked_reason: 'Set by the platform.', + }); + + const refusal = await refusalOf(svc.setMany('pinned', { banner: null }, orgAdmin)); + expect(refusal).toBeInstanceOf(SettingsLockedError); + expect(refusal).toMatchObject({ code: 'SETTINGS_LOCKED', reason: 'locked-by-global', key: 'banner' }); + + const answer = await svc.setMany('pinned', { banner: null }, platformAdmin); + expect(answer.banner).toMatchObject({ value: 'Pinned banner', source: 'env', locked: true }); + }); +}); From da2c28f54a35a61d0db1bb3d387e5383014a1507 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:02:26 +0000 Subject: [PATCH 3/4] docs: state the clear through an env pin and the full allowed-IP recovery; changeset Claude-Session: https://claude.ai/code/session_01Utp6CZjAtpVuoYZBzB9n5i Co-authored-by: Claude --- .changeset/30385-clear-env-locked-setting.md | 13 +++++++++++++ content/docs/protocol/kernel/config-resolution.mdx | 2 +- docs/HARDENING.md | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/30385-clear-env-locked-setting.md diff --git a/.changeset/30385-clear-env-locked-setting.md b/.changeset/30385-clear-env-locked-setting.md new file mode 100644 index 00000000000..9a86f1ab3f1 --- /dev/null +++ b/.changeset/30385-clear-env-locked-setting.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-settings': patch +'@objectstack/plugin-auth': patch +--- + +fix(service-settings): a clear of an env-pinned setting clears the stored value beneath the pin, so the allowed-IP lockout recovery holds once the variable is removed + +Clause-②: yes (widening) — `SettingsService.setMany` / `set` (and `PUT /api/settings/:namespace`) accept a clear (`null` / `undefined`) of a key an `OS_*` override in force pins, where every write to such a key was refused `409 SETTINGS_LOCKED`; every other write is still refused. No export, type or error code changes. +Metadata-protocol: none + +- **A clear goes through an env pin.** An `OS_*` override in force decides the value in force and still refuses every write but a clear. A clear (`null` / `undefined`, never a value such as `''` that a key would store) clears the value stored beneath the pin, so the key falls back to its default once the override is removed. It changes nothing in force: the answer still reads the env value with `source: 'env'` and `locked: true`. It meets every judgment a clear meets without a pin: a locked row at an upper scope, or a locked row at the write's own scope for a caller that is not the platform, still refuses it `SETTINGS_LOCKED` (`locked-by-`), and the key's write predicates still run. +- **Why.** The override hides the stored value from every read, and used to refuse every write to it, so an operator who pinned a key to recover from a bad stored value got the same value back the moment the pin was lifted. For `auth.allowed_ip_ranges` that meant the documented recovery (`OS_AUTH_ALLOWED_IP_RANGES` set empty) opened the gate only until the variable was removed, and the next restart locked the administrator out again. +- **Recovery when locked out by `allowed_ip_ranges` (`@objectstack/plugin-auth`).** Start the server with `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, sign in, clear the stored list with a settings request that sets `allowed_ip_ranges` to `null` (`PUT /api/settings/auth`), then remove the variable. The save refusal's message names these four steps in all four of its languages. This supersedes the recovery sentence of the earlier entry for the allowed-IP save refusal ("remove it once the list is fixed"): removing the variable without the clear brings the stored list back at the next restart. The Setup console still shows a pinned field read-only, so the clear is a settings API request for now. diff --git a/content/docs/protocol/kernel/config-resolution.mdx b/content/docs/protocol/kernel/config-resolution.mdx index dab1795277d..0cd12f22820 100644 --- a/content/docs/protocol/kernel/config-resolution.mdx +++ b/content/docs/protocol/kernel/config-resolution.mdx @@ -218,7 +218,7 @@ Writes fail loudly rather than silently degrading: | Condition | Thrown | Code | | :--- | :--- | :--- | -| An `OS_*` override is in force for the key | `SettingsLockedError` | `SETTINGS_LOCKED` | +| An `OS_*` override is in force for the key, and the write is not a clear | `SettingsLockedError` | `SETTINGS_LOCKED` | | A row at an upper scope has `locked=true`, or a row at the write's own scope has it and the caller is not the platform | `SettingsLockedError` | `SETTINGS_LOCKED` (`locked-by-`) | | Key not declared by the manifest | `UnknownKeyError` | `SETTINGS_UNKNOWN_KEY` | | Namespace has no manifest | `UnknownNamespaceError` | `SETTINGS_UNKNOWN_NAMESPACE` | diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 71594d5949c..4c13aa30452 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -241,7 +241,7 @@ production deployment, enable at least: | Anti-abuse | `lockout_threshold: 5`, `lockout_duration_minutes: 15`, `rate_limit_max` / `rate_limit_window_seconds` | | MFA | `mfa_required: true` (or per-org `require_mfa`, which needs two-factor enrollment on), `mfa_grace_period_days: 7`; neither saves while `OS_AUTH_TWO_FACTOR=false` | | Sessions | `session_idle_timeout_minutes: 30–60`, `session_absolute_max_hours: 8–12`, `max_concurrent_sessions_per_user` | -| Network | `allowed_ip_ranges` (IPv4 CIDR) for tenant- or office-scoped access | +| Network | `allowed_ip_ranges` (IPv4 CIDR) for tenant- or office-scoped access; locked out, restart with `OS_AUTH_ALLOWED_IP_RANGES=` (empty), sign in, clear the stored list (`PUT /api/settings/auth` with `allowed_ip_ranges: null`), then remove the variable | Password expiry and enforced MFA share a *session-validation gate*: a non-compliant session can authenticate but is blocked from data access (auth + From 5c0cbbfb967798f14934e44889b2589f3fe918cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:06:25 +0000 Subject: [PATCH 4/4] docs(settings,auth): a clear goes through an env pin; the lockout recovery is four steps - service-settings README, Env override: every write but a clear (null) fails with HTTP 409; a clear removes the value stored beneath the override and changes nothing in force. - authentication.mdx, "If you are already locked out": PR #30382 merged first, so this PR rewrites the bullet (seat ruling: the later lander rewrites it) to the four steps that work: start with OS_AUTH_ALLOWED_IP_RANGES empty, sign in, PUT /api/settings/auth with allowed_ip_ranges null, remove the variable. The forward reference above it now says "steps". Claude-Session: https://claude.ai/code/session_01Utp6CZjAtpVuoYZBzB9n5i Co-authored-by: Claude --- content/docs/permissions/authentication.mdx | 29 +++++++++++++------- packages/services/service-settings/README.md | 7 +++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 5fa09d18ba4..cd060ae3039 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -662,7 +662,7 @@ These rules apply to `allowed_ip_ranges`. API save, `PUT /api/settings/auth`, which **Setup → Authentication** calls, so any HTTP client gets the same answer. The message names your address, says the list would refuse your own next sign-in, and asks you to add your address or its range, - or to save from an address the list matches. It ends with the recovery step below. + or to save from an address the list matches. It ends with the recovery steps below. It is written in your language (English, Chinese, Japanese or Spanish). The field error in `details.fields` also carries your address as `constraint.clientAddress`. If the server cannot establish your address at all, any non-empty list is refused, @@ -673,15 +673,24 @@ These rules apply to `allowed_ip_ranges`. the gate goes back to the list the deployment itself declares (`allowedIpRanges` in its auth configuration, for example set by an `auth:configure` handler), or to no restriction when it declares none. A clear is never refused. -- **If you are already locked out**, start the server with - `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value (`OS_AUTH_ALLOWED_IP_RANGES=`). - Environment variables are read at startup, so a restart is needed. An empty value - is not the same as an unset variable: any value that is set, the empty string - included, is an env override in force. It wins over the stored list, and an empty - list means no restriction. While the variable is set, the stored list is kept but - not used, and the field is locked: a save to it is refused with - `409 SETTINGS_LOCKED`. Remove the variable, and the stored list applies again at - the next restart. +- **If you are already locked out**, recover in four steps: + 1. Start the server with `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value + (`OS_AUTH_ALLOWED_IP_RANGES=`). Environment variables are read at startup, so a + restart is needed. An empty value is not the same as an unset variable: any + value that is set, the empty string included, is an env override in force. It + wins over the stored list, and an empty list means no restriction. + 2. Sign in. + 3. Clear the stored list: send `PUT /api/settings/auth` with + `{ "allowed_ip_ranges": null }`. While the variable is set the field is locked, + so **Setup → Authentication** shows it read-only and any other save to it is + refused with `409 SETTINGS_LOCKED`; a clear (`null`) is the one write the lock + lets through. It clears the stored value and changes nothing in force: the + answer still reads the empty value from the environment. + 4. Remove the variable and restart. With the stored list cleared, nothing brings + it back: the gate applies the list the deployment declares, or no restriction. + + Removing the variable without step 3 brings the stored list back at the next + restart, and locks you out again. --- diff --git a/packages/services/service-settings/README.md b/packages/services/service-settings/README.md index b3c0910e396..40e70ff078e 100644 --- a/packages/services/service-settings/README.md +++ b/packages/services/service-settings/README.md @@ -30,8 +30,11 @@ a resolver that layers `OS_* env > Tenant > User > Default`. to underscores) takes precedence over any stored value. For example, `mail.smtp_host` resolves from `OS_MAIL_SMTP_HOST`, and `ai.log_prompts` resolves from `OS_AI_LOG_PROMPTS`. -Such fields are returned with `source: 'env', locked: true` and writes -(service or REST) fail with HTTP 409. +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. ## Encryption