Skip to content

Commit 87457d3

Browse files
committed
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 <noreply@anthropic.com>
1 parent 0e4dfbe commit 87457d3

3 files changed

Lines changed: 58 additions & 10 deletions

File tree

‎packages/plugins/plugin-auth/src/allowed-ip-ranges.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@
2525
* lock out, and it is let through.
2626
*
2727
* The way back when locked out anyway — a list saved from another address, or
28-
* an address that changed — is the settings env override every auth key has:
29-
* `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, then a restart. The
30-
* refusal names it.
28+
* an address that changed — is the settings env override every auth key has,
29+
* in four steps, and the refusal names them: start the server with
30+
* `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value (the override wins over
31+
* the stored list, so the gate is open), sign in, clear the stored list (a
32+
* settings write of `null` to the key, which the override lets through,
33+
* #30385), then remove the variable. Removing it without the clear brings the
34+
* stored list back at the next restart.
3135
*/
3236

3337
import { resolveLocale } from '@objectstack/core';
@@ -90,7 +94,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = {
9094
'The server cannot establish the address you are connected from, and while any list is set it refuses every '
9195
+ 'such request, your own next sign-in included. Save the list from a connection whose address the server can '
9296
+ 'establish.',
93-
recovery: ` If you are locked out, start the server with ${ALLOWED_IP_RANGES_ENV} set to an empty value.`,
97+
recovery:
98+
` If you are locked out, start the server with ${ALLOWED_IP_RANGES_ENV} set to an empty value, sign in, `
99+
+ 'clear the stored list with a settings request that sets allowed_ip_ranges to null '
100+
+ '(PUT /api/settings/auth), then remove the variable.',
94101
},
95102
'zh-CN': {
96103
outside: (address) =>
@@ -99,7 +106,9 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = {
99106
undetermined:
100107
'服务器无法确定你当前连接所用的地址;只要设置了列表,这样的请求都会被拒绝,你自己下一次登录也不例外。'
101108
+ '请在服务器能确定地址的连接上保存。',
102-
recovery: `如果已被锁在外面,可将环境变量 ${ALLOWED_IP_RANGES_ENV} 设为空值后重启服务器。`,
109+
recovery:
110+
`如果已被锁在外面:将环境变量 ${ALLOWED_IP_RANGES_ENV} 设为空值后重启服务器并登录,`
111+
+ '用一次把 allowed_ip_ranges 设为 null 的设置请求(PUT /api/settings/auth)清除已保存的列表,再去掉该环境变量。',
103112
},
104113
'ja-JP': {
105114
outside: (address) =>
@@ -108,7 +117,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = {
108117
undetermined:
109118
'サーバーは現在の接続のアドレスを確定できません。リストが設定されている間はそのようなリクエストはすべて拒否され、'
110119
+ 'ご自身の次回のサインインも同様です。サーバーがアドレスを確定できる接続から保存してください。',
111-
recovery: `締め出された場合は、環境変数 ${ALLOWED_IP_RANGES_ENV} を空の値にしてサーバーを再起動してください。`,
120+
recovery:
121+
`締め出された場合は、環境変数 ${ALLOWED_IP_RANGES_ENV} を空の値にしてサーバーを再起動してサインインし、`
122+
+ 'allowed_ip_ranges を null にする設定リクエスト(PUT /api/settings/auth)で保存済みのリストを消去してから、'
123+
+ '環境変数を削除してください。',
112124
},
113125
'es-ES': {
114126
outside: (address) =>
@@ -119,7 +131,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = {
119131
'El servidor no puede determinar la dirección desde la que está conectado y, mientras haya una lista, rechaza '
120132
+ 'todas esas solicitudes, incluido su propio próximo inicio de sesión. Guarde la lista desde una conexión cuya '
121133
+ 'dirección el servidor pueda determinar.',
122-
recovery: ` Si queda bloqueado, inicie el servidor con ${ALLOWED_IP_RANGES_ENV} con un valor vacío.`,
134+
recovery:
135+
` Si queda bloqueado, inicie el servidor con ${ALLOWED_IP_RANGES_ENV} con un valor vacío, inicie sesión, `
136+
+ 'borre la lista guardada con una solicitud de ajustes que ponga allowed_ip_ranges a null '
137+
+ '(PUT /api/settings/auth) y después quite la variable.',
123138
},
124139
};
125140

‎packages/services/service-settings/src/settings-service.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2340,7 +2340,12 @@ export class SettingsService {
23402340
};
23412341
}
23422342

2343-
/** Persist a single key. Throws SettingsLockedError when env-locked. */
2343+
/**
2344+
* Persist a single key. Throws SettingsLockedError when an env override in
2345+
* force pins the key, unless `value` is a clear (`null` / `undefined`), which
2346+
* clears the stored value beneath the pin and leaves the env value in force
2347+
* (see {@link setMany}).
2348+
*/
23442349
async set(
23452350
namespace: string,
23462351
key: string,
@@ -2360,6 +2365,14 @@ export class SettingsService {
23602365
* the row it would store names no owner, and the user rung answers only its
23612366
* owner, so no reader would ever see it while the write reported success.
23622367
*
2368+
* A key an env override in force pins refuses every write with
2369+
* {@link SettingsLockedError} (`locked-by-env`, ADR-0007 D6) but a clear
2370+
* (`null` / `undefined`): a clear nulls the row stored beneath the pin, so
2371+
* the key falls back to its default once the override is removed, and
2372+
* changes nothing in force — the answer still reads the env value with
2373+
* `locked: true`. It is judged by the row locks and the write predicates
2374+
* like any other clear.
2375+
*
23632376
* `display` picks the language of a write predicate's refusal and hands it
23642377
* the request the write arrived on, and nothing else (see
23652378
* {@link refuseByWritePredicates}).
@@ -2403,7 +2416,23 @@ export class SettingsService {
24032416
// UI refused with `SETTINGS_LOCKED`, and `get()` reporting `locked: false`
24042417
// to a settings page that would then fail its own save. See
24052418
// `effectiveEnvOverride`.
2406-
if (this.effectiveEnvOverride(reg, namespace, key)) {
2419+
//
2420+
// [#30385] A CLEAR goes through the pin (ADR-0007 D6 keeps refusing every
2421+
// other write): `null` / `undefined`, the spelling every clear in this
2422+
// service uses, never a value such as `''` that a key would store. The
2423+
// pin decides what is in force, and a clear changes nothing there — the
2424+
// env value still wins, and the answer still reads `locked: true` from
2425+
// `env`. What it does change is the stored value the key falls back to
2426+
// once the override is removed, and that value is otherwise reachable by
2427+
// nothing: the override hides it from every read and refuses every
2428+
// write, so an operator who pinned a key to recover from a bad stored
2429+
// value (`OS_AUTH_ALLOWED_IP_RANGES=` for an allow-list that locks them
2430+
// out) gets the same bad value back the moment the pin is lifted. The
2431+
// clear then meets every judgment a clear meets without a pin — the
2432+
// Phase 2 row locks and the platform-rung rule below, the write
2433+
// predicates — so no lock a row carries is bypassed.
2434+
const clears = patch[key] === null || typeof patch[key] === 'undefined';
2435+
if (!clears && this.effectiveEnvOverride(reg, namespace, key)) {
24072436
throw new SettingsLockedError(namespace, key);
24082437
}
24092438

‎packages/services/service-settings/src/settings-service.types.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,11 @@ export function envKeyOf(namespace: string, key: string): string {
594594
return `OS_${slug}`;
595595
}
596596

597-
/** Thrown when a caller tries to write a value pinned by env. */
597+
/**
598+
* Thrown when a caller tries to write a value pinned by env (`locked-by-env`;
599+
* a clear — `null` / `undefined` — is not refused, it clears the stored value
600+
* beneath the pin), or past a locked row (`locked-by-<scope>`).
601+
*/
598602
export class SettingsLockedError extends Error {
599603
readonly code = 'SETTINGS_LOCKED' as const;
600604
constructor(

0 commit comments

Comments
 (0)