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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/30385-clear-env-locked-setting.md
Original file line number Diff line number Diff line change
@@ -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-<scope>`), 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.
29 changes: 19 additions & 10 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

---

Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/kernel/config-resolution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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-<scope>`) |
| Key not declared by the manifest | `UnknownKeyError` | `SETTINGS_UNKNOWN_KEY` |
| Namespace has no manifest | `UnknownNamespaceError` | `SETTINGS_UNKNOWN_NAMESPACE` |
Expand Down
2 changes: 1 addition & 1 deletion docs/HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
37 changes: 37 additions & 0 deletions packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -257,3 +261,36 @@ describe('#30320 an IPv6 range is judged at both doors as the range it names', (
expect(manager.isClientIpAllowed('2001:db8:1234::7')).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<string, string | undefined> = {};
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);
});
});
29 changes: 22 additions & 7 deletions packages/plugins/plugin-auth/src/allowed-ip-ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) =>
Expand All @@ -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) =>
Expand All @@ -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) =>
Expand All @@ -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.',
},
};

Expand Down
7 changes: 5 additions & 2 deletions packages/services/service-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 104 additions & 0 deletions packages/services/service-settings/src/settings-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) => 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<string, string | undefined> = {};
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<string, string | undefined> = { 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 });
});
});
Loading
Loading