diff --git a/.changeset/30302-mfa-enforcement-follow-ups.md b/.changeset/30302-mfa-enforcement-follow-ups.md new file mode 100644 index 00000000000..e711bc9e0eb --- /dev/null +++ b/.changeset/30302-mfa-enforcement-follow-ups.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth)!: the global `auth.mfa_required` switch cannot be saved on while `OS_AUTH_TWO_FACTOR=false` keeps two-factor enrollment off (#30302) + +Clause-②: no (narrowing) — while `OS_AUTH_TWO_FACTOR` is set to off, a save that turns the `auth.mfa_required` setting on is refused `400 SETTINGS_VALIDATION` (`invalid_value` at `mfa_required`), a value an administrator could save before; saving it off, or re-saving a value already in force, passes. No published export, error code, settings key or `/auth/config` member is added; nothing is widened. +Metadata-protocol: none +Skills-docs: updated here — `content/docs/permissions/authentication.mdx` (the `mfa_required` row) and `docs/HARDENING.md` (the MFA row); `skills/**` teaches neither `mfa_required` nor `require_mfa`, so no skill or eval moves. + + + +**BREAKING** accept-set narrowing on the `auth.mfa_required` settings save (Setup → Authentication, `PUT /api/settings/auth`, `SettingsService.setMany`), on a deployment that sets `OS_AUTH_TWO_FACTOR` to off. + +- **Before.** Turning on "Require multi-factor authentication" saved and read as on. The settings pass turns the requirement and the two-factor plugin on as one piece, but the `OS_AUTH_TWO_FACTOR` env var outranks the plugin flag, so under `OS_AUTH_TWO_FACTOR=false` nobody could enroll an authenticator app, and the MFA gate, which holds only a user who can enroll, enforced nothing. An organization's own `require_mfa` already refused this state; the global switch did not. +- **Now.** That save is refused before anything is written: the whole save, other keys in it included, with one field error at `mfa_required` naming `OS_AUTH_TWO_FACTOR`, in the caller's language (English, Simplified Chinese, Japanese, Spanish), and the constraint `{ twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' }`. +- **Unchanged.** With `OS_AUTH_TWO_FACTOR` unset or on, the switch saves and turns enrollment on with the requirement, as before. Turning it off is never refused. A switch saved on before this release is left as stored and still not enforced; `GET /api/v1/auth/config` answers `features.twoFactor: false` there, the same decision the gate makes, which is the reading a console uses to say the requirement is not enforced. + +**The remedy.** Set `OS_AUTH_TWO_FACTOR=true` on the deployment (or remove it) and restart, then turn on `auth.mfa_required`. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index b18612d7f33..b7a6a9820ac 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -604,7 +604,7 @@ often before the account-level lock engages. | Setting | Effect | |---|---| -| `mfa_required` | Require MFA org-wide (also settable per organization via `require_mfa`). | +| `mfa_required` | Require MFA org-wide; saving it on also turns two-factor enrollment on, and the save is refused while `OS_AUTH_TWO_FACTOR=false` pins enrollment off. An organization can require it on its own (`require_mfa`) only while two-factor enrollment is on; a requirement stored while enrollment is off is not enforced, which `GET /api/v1/auth/config` reports as `features.twoFactor: false`. | | `mfa_grace_period_days` | Grace window for existing users to enroll. After it elapses, the session is allowed to sign in but **gated from data access** until TOTP is enrolled — the Console surfaces a remediation overlay that walks the user through enrollment. | ### Sessions diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 5a883c306fd..71594d5949c 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -239,7 +239,7 @@ production deployment, enable at least: | ----------- | ------------------------------------------------------------------------------------ | | Password | `password_reject_breached: true`, `password_require_complexity: true`, `password_history_count: 3–5`, `password_expiry_days: 90` | | 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`), `mfa_grace_period_days: 7` | +| 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 | diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 3cc6eefc156..6ef278f3a58 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -69,6 +69,7 @@ import { import { registerLastAdminGuard } from './last-admin-guard.js'; import { registerApiKeyRestoreGuard } from './api-key-restore-guard.js'; import { registerOrganizationMfaEnrollmentGuard } from './organization-mfa-enrollment-guard.js'; +import { registerMfaRequiredEnrollmentPredicate } from './mfa-required-enrollment-predicate.js'; import { registerPersonalViewHooks } from './personal-view-hooks.js'; import { registerUserPreferenceHooks } from './user-preference-hooks.js'; import { personalViewCallerResolver, registerPersonalViewRoutes, PERSONAL_VIEW_REMOVE_ROUTE, type PersonalViewEngine } from './personal-view-routes.js'; @@ -2271,6 +2272,9 @@ export class AuthPlugin implements Plugin { // gated users would have no way to comply. [#20412] So the enforcement // and the plugin are ONE piece: a refused plugins block leaves MFA at // its standing value too, never enforced without its enrollment path. + // The env var OS_AUTH_TWO_FACTOR outranks this piece's plugin flag, so + // under a pin to off the save itself is refused, before this pass + // (mfa-required-enrollment-predicate.ts). if (isExplicit('mfa_required')) { const on = asBoolean(values.mfa_required, false); if (on) { @@ -2591,6 +2595,11 @@ export class AuthPlugin implements Plugin { } } + // The save door refuses turning `mfa_required` on while OS_AUTH_TWO_FACTOR + // pins enrollment off; the pass below would store it and enforce nothing. + // See mfa-required-enrollment-predicate.ts. + registerMfaRequiredEnrollmentPredicate(settings, ctx.logger); + await applySettings(); if (typeof settings.subscribe === 'function') { settings.subscribe('auth', () => { diff --git a/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts new file mode 100644 index 00000000000..8d6ef12caef --- /dev/null +++ b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The global `auth.mfa_required` switch cannot be saved on while + * `OS_AUTH_TWO_FACTOR` pins enrollment off (`mfa-required-enrollment-predicate.ts`). + * + * Through the REAL settings service (`@objectstack/service-settings`) and the + * real `AuthPlugin` / `AuthManager`: the plugin registers the predicate when it + * binds the `auth` namespace, and a save goes through `setMany`, or through the + * real `PUT /api/settings/:ns` route for the envelope. No data engine is + * composed; the settings service keeps its in-memory store. + * + * Pinned: + * 1. under `OS_AUTH_TWO_FACTOR=false`, turning the switch on is refused + * before anything is written: the stored value, the batch it rode in, the + * runtime requirement and `features.twoFactor` all stay as they were; + * 2. the HTTP door answers 400 `SETTINGS_VALIDATION` at the key, in the + * caller's language; + * 3. controls: with the env var unset or on, the same save is accepted and + * the pass turns the requirement and enrollment on; turning it off is + * never refused; + * 4. a switch stored on before the check is not re-judged: saving it again + * passes, and the gate's own reading stays off. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; +import { + SettingsService, + SettingsValidationError, + builtinSettingsManifests, + registerSettingsRoutes, +} from '@objectstack/service-settings'; +import { AuthPlugin } from './auth-plugin.js'; +import type { AuthManager } from './auth-manager.js'; +import { + MFA_REQUIRED_ENROLLMENT_COPY, + mfaRequiredEnrollmentPredicate, + registerMfaRequiredEnrollmentPredicate, +} from './mfa-required-enrollment-predicate.js'; + +const SECRET = 'test-secret-at-least-32-chars-long'; +const ENV = 'OS_AUTH_TWO_FACTOR'; + +/** The error `setMany` refused with, or a failure naming what happened instead. */ +async function refusalOf(write: Promise): Promise { + const outcome = await write.then( + () => undefined, + (err: unknown) => err, + ); + expect(outcome, 'the save went through').toBeInstanceOf(SettingsValidationError); + return outcome as SettingsValidationError; +} + +describe('the global mfa_required switch needs two-factor enrollment', () => { + let saved: string | undefined; + + beforeEach(() => { + saved = process.env[ENV]; + delete process.env[ENV]; + }); + + afterEach(() => { + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + }); + + const makeSettings = () => { + const settings = new SettingsService({ env: {} }); + for (const manifest of builtinSettingsManifests) { + if (manifest.namespace === 'auth') settings.registerManifest(manifest); + } + return settings; + }; + + /** Boot as a host does: init → start → kernel:ready (the auth namespace binds there). */ + const boot = async (settings: SettingsService = makeSettings()) => { + const hooks = new Map Promise>>(); + const warn = vi.fn(); + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'settings') return settings; + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn((name: string, handler: () => Promise) => { + if (!hooks.has(name)) hooks.set(name, []); + hooks.get(name)!.push(handler); + }), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn, debug: vi.fn() }, + getKernel: vi.fn(), + } as unknown as PluginContext; + const plugin = new AuthPlugin({ secret: SECRET, baseUrl: 'http://localhost:3000' }); + await plugin.init(ctx); + const manager = (ctx.registerService as any).mock.calls.find(([name]: [string]) => name === 'auth')?.[1] as AuthManager; + await plugin.start(ctx); + for (const handler of hooks.get('kernel:ready') ?? []) await handler(); + return { settings, manager, warn }; + }; + + const required = (manager: AuthManager) => (manager as any).config.mfaRequired === true; + + // ── 1. The refusal ────────────────────────────────────────────────────────── + + it('OS_AUTH_TWO_FACTOR=false: turning it on is refused; nothing in the batch is written', async () => { + process.env[ENV] = 'false'; + const { settings, manager } = await boot(); + + const refusal = await refusalOf(settings.setMany('auth', { mfa_required: true, mfa_grace_period_days: 3 })); + expect(refusal.code).toBe('SETTINGS_VALIDATION'); + expect(refusal.fields).toEqual([ + expect.objectContaining({ + field: 'mfa_required', + code: 'invalid_value', + constraint: { twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' }, + }), + ]); + + expect(await settings.get('auth', 'mfa_required')).toMatchObject({ value: false, source: 'default' }); + expect(await settings.get('auth', 'mfa_grace_period_days')).toMatchObject({ source: 'default' }); + expect(required(manager)).toBe(false); + expect(manager.isTwoFactorEnabled()).toBe(false); + expect(manager.getPublicConfig().features.twoFactor).toBe(false); + // Off, or the value in force, is never refused. + await expect(settings.setMany('auth', { mfa_required: false })).resolves.toBeDefined(); + }); + + it('the settings PUT door answers 400 SETTINGS_VALIDATION at the key, in the caller language', async () => { + process.env[ENV] = '0'; + const { settings } = await boot(); + const routes = new Map(); + const add = (method: string) => (path: string, handler: RouteHandler) => { + routes.set(`${method} ${path}`, handler); + return http; + }; + const http = { + get: add('GET'), + post: add('POST'), + put: add('PUT'), + delete: add('DELETE'), + patch: add('PATCH'), + use: () => http, + listen: async () => {}, + close: async () => {}, + getInstance: () => null, + } as unknown as IHttpServer; + registerSettingsRoutes(http, settings, { + contextFromRequest: () => ({ enforced: true, permissions: ['manage_platform_settings'], userId: 'usr_admin' }), + }); + + const state: { status: number; body?: any } = { status: 200 }; + const res = { + json: vi.fn((data: unknown) => { + state.body = data; + }), + send: vi.fn(), + status: vi.fn((code: number) => { + state.status = code; + return res; + }), + header: vi.fn(() => res), + } as unknown as IHttpResponse; + const req = { + params: { namespace: 'auth' }, + query: {}, + body: { mfa_required: true }, + headers: { 'accept-language': 'zh-CN,zh;q=0.9' }, + method: 'PUT', + path: '/api/settings/auth', + } as unknown as IHttpRequest; + await routes.get('PUT /api/settings/:namespace')!(req, res); + + expect(state.status).toBe(400); + expect(state.body.error.code).toBe('SETTINGS_VALIDATION'); + const [field] = state.body.error.details.fields; + expect(field).toMatchObject({ field: 'mfa_required', code: 'invalid_value' }); + expect(field.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY['zh-CN']); + }); + + // ── 3. Controls ───────────────────────────────────────────────────────────── + + it.each([ + ['unset', undefined], + ['on', 'true'], + ])('control: OS_AUTH_TWO_FACTOR %s — the save is accepted and the pass turns enrollment on with it', async (_label, value) => { + if (value !== undefined) process.env[ENV] = value; + const { settings, manager } = await boot(); + + await settings.setMany('auth', { mfa_required: true }); + expect(await settings.get('auth', 'mfa_required')).toMatchObject({ value: true }); + await vi.waitFor(() => expect(required(manager)).toBe(true)); + expect(manager.isTwoFactorEnabled()).toBe(true); + expect(manager.getPublicConfig().features.twoFactor).toBe(true); + }); + + // ── 4. A switch stored on before the check ────────────────────────────────── + + it('a switch stored on before the check is not re-judged, and the gate still reads enrollment off', async () => { + const settings = makeSettings(); + await settings.setMany('auth', { mfa_required: true }); + process.env[ENV] = 'false'; + const { manager } = await boot(settings); + + // The value in force: the settings service asks no predicate about it. + await expect(settings.setMany('auth', { mfa_required: true })).resolves.toBeDefined(); + // Read as on, enforced by nobody: `features.twoFactor` is what says so. + expect(required(manager)).toBe(true); + expect(manager.isTwoFactorEnabled()).toBe(false); + expect(manager.getPublicConfig().features.twoFactor).toBe(false); + }); + + // ── The predicate and its registration, alone ─────────────────────────────── + + it('judges a change to on only, and only while enrollment stays off', () => { + const off = mfaRequiredEnrollmentPredicate(() => false); + expect(off({ next: true, current: false })?.constraint).toEqual({ twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' }); + // Stored toggles read as `bindAuthSettings` reads them. + expect(off({ next: 'true', current: null })).toBeDefined(); + expect(off({ next: 1, current: undefined })).toBeDefined(); + expect(off({ next: false, current: true })).toBeUndefined(); + expect(off({ next: null, current: true })).toBeUndefined(); + expect(off({ next: true, current: 'true' })).toBeUndefined(); + expect(mfaRequiredEnrollmentPredicate(() => true)({ next: true, current: false })).toBeUndefined(); + // Unknown locales fall back to English. + expect(off({ next: true, current: false, locale: 'fr-FR' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.en); + }); + + it('a settings service without write predicates is said at warn, only while the pin makes it matter', () => { + const warn = vi.fn(); + registerMfaRequiredEnrollmentPredicate({}, { warn }); + expect(warn).not.toHaveBeenCalled(); + process.env[ENV] = 'false'; + registerMfaRequiredEnrollmentPredicate({}, { warn }); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toMatch(/OS_AUTH_TWO_FACTOR/); + }); +}); diff --git a/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts new file mode 100644 index 00000000000..c3c4a294145 --- /dev/null +++ b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The global `auth.mfa_required` switch cannot be saved on while the + * deployment pins two-factor enrollment off (ADR-0069 D3). + * + * ## The gap this closes + * + * The settings pass applies `mfa_required = true` as ONE piece with + * `plugins.twoFactor: true` (`auth-plugin.ts`, `bindAuthSettings`), so the + * requirement is never in force without its enrollment path. But whether the + * twoFactor plugin is wired is decided by {@link resolveTwoFactorEnabled}, + * which reads the `OS_AUTH_TWO_FACTOR` env var FIRST: under + * `OS_AUTH_TWO_FACTOR=false` the piece's `plugins.twoFactor: true` changes + * nothing. The switch then saved and read on in Setup while nobody could + * enroll, and the per-user gate, which holds only a user who can enroll + * (`resolveUserAuthGates`), enforced nothing. The organization column already + * refuses this state (`organization-mfa-enrollment-guard.ts`); this is the + * same refusal at the global switch. + * + * ## Where it is refused + * + * At the save door: the settings service's write predicate + * (`registerWritePredicate`), asked before any row is written. The settings + * pass runs AFTER the value is stored, so a refusal there would leave the + * console showing a saved value the runtime ignores. A refusal refuses the + * whole save with `SETTINGS_VALIDATION` and an `invalid_value` field error at + * `mfa_required`, in the caller's language, naming the env var. + * + * ## The rule + * + * A save that turns `mfa_required` ON (from a stored or default off) is + * refused while turning the twoFactor plugin on would still leave enrollment + * off: {@link resolveTwoFactorEnabled} over `plugins.twoFactor: true`, the + * config the piece applies, answers false. That is exactly an + * `OS_AUTH_TWO_FACTOR` value reading as off. Turning the switch off, saving a + * value already in force (the settings service asks a predicate only about a + * change), and every save with the env var unset or on pass untouched. + * + * The rule depends on the env var alone, not on another `auth` key, so a key + * judged on its own cannot be let through by a sibling key in the same save. + * + * A switch saved on before this check, under the pin, is not touched: the + * gate does not enforce it, and `GET /api/v1/auth/config` answers + * `features.twoFactor: false`, the reading a console uses to say so. + */ + +import { resolveLocale } from '@objectstack/core'; +import { resolveTwoFactorEnabled } from './auth-manager.js'; + +/** The `auth` settings key this guards. */ +export const MFA_REQUIRED_SETTING_KEY = 'mfa_required'; + +/** The env var that outranks the settings for two-factor enrollment. */ +export const TWO_FACTOR_ENV = 'OS_AUTH_TWO_FACTOR'; + +/** The same reading `bindAuthSettings` gives a stored toggle. */ +function readToggle(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.toLowerCase() !== 'false'; + if (typeof value === 'number') return value !== 0; + return false; +} + +/** + * Whether a user could enroll once the switch's settings piece is applied: + * the decision point the gate, the plugin list and `features.twoFactor` read, + * over the plugin config the piece sets. + */ +export function twoFactorEnrollableWithSwitchOn(): boolean { + return resolveTwoFactorEnabled({ twoFactor: true }); +} + +/** The refusal, per language. */ +export const MFA_REQUIRED_ENROLLMENT_COPY = { + en: + 'Multi-factor authentication cannot be required on this deployment yet: OS_AUTH_TWO_FACTOR is set to off, ' + + 'and it overrides this setting, so nobody could enroll an authenticator app and the requirement would not be ' + + 'enforced. Set OS_AUTH_TWO_FACTOR=true (or remove it) on the deployment and restart, then turn this on.', + 'zh-CN': + '本部署暂时无法强制多因素认证:OS_AUTH_TWO_FACTOR 被设为关闭,它的优先级高于本设置,' + + '因此没有人能绑定身份验证器应用,这项要求也不会生效。请先在部署上设置 OS_AUTH_TWO_FACTOR=true(或删除它)并重启,再开启此项。', + 'ja-JP': + 'このデプロイではまだ多要素認証を必須にできません。OS_AUTH_TWO_FACTOR がオフに設定されており、' + + 'この設定より優先されるため、誰も認証アプリを登録できず、この要件は適用されません。デプロイで ' + + 'OS_AUTH_TWO_FACTOR=true を設定する(または削除する)して再起動してから、これをオンにしてください。', + 'es-ES': + 'Todavía no se puede exigir la autenticación multifactor en esta implementación: OS_AUTH_TWO_FACTOR está ' + + 'desactivado y prevalece sobre este ajuste, así que nadie podría registrar una aplicación de autenticación y ' + + 'el requisito no se aplicaría. Establezca OS_AUTH_TWO_FACTOR=true (o elimínelo) en la implementación, ' + + 'reinicie y después active esta opción.', +} as const; + +const COPY_LOCALES = Object.keys(MFA_REQUIRED_ENROLLMENT_COPY); + +/** The copy for a caller's locale: exact, then by language, else English. */ +export function mfaRequiredEnrollmentCopy(locale: string | undefined): string { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return MFA_REQUIRED_ENROLLMENT_COPY[(picked ?? 'en') as keyof typeof MFA_REQUIRED_ENROLLMENT_COPY]; +} + +/** What the settings service asks a write predicate (the fields read here). */ +export interface MfaRequiredWriteInput { + next: unknown; + current: unknown; + locale?: string; +} + +/** A refusal, in the settings service's `SettingsWriteRefusal` shape. */ +export interface MfaRequiredWriteRefusal { + message: string; + constraint: { twoFactor: false; overriddenBy: typeof TWO_FACTOR_ENV }; +} + +/** The `auth.mfa_required` write predicate. */ +export function mfaRequiredEnrollmentPredicate( + enrollable: () => boolean = twoFactorEnrollableWithSwitchOn, +): (input: MfaRequiredWriteInput) => MfaRequiredWriteRefusal | undefined { + return ({ next, current, locale }) => { + if (!readToggle(next) || readToggle(current)) return undefined; + if (enrollable()) return undefined; + return { + message: mfaRequiredEnrollmentCopy(locale), + constraint: { twoFactor: false, overriddenBy: TWO_FACTOR_ENV }, + }; + }; +} + +/** The settings service surface this registers on (structural: no package edge). */ +interface WritePredicateHost { + registerWritePredicate( + namespace: string, + key: string, + predicate: (input: MfaRequiredWriteInput) => MfaRequiredWriteRefusal | undefined, + ): void; +} + +/** + * Register the predicate on the `settings` service. A service without write + * predicates cannot refuse the save; that is said at `warn` when it matters, + * that is while the env var pins enrollment off. A service without the `auth` + * namespace or the key cannot save it, so there is nothing to guard. + */ +export function registerMfaRequiredEnrollmentPredicate( + settings: unknown, + logger: { warn(message: string): void }, +): void { + const host = settings as Partial | undefined; + if (typeof host?.registerWritePredicate !== 'function') { + if (!twoFactorEnrollableWithSwitchOn()) { + logger.warn( + `[auth] the settings service takes no write predicates, so saving auth.mfa_required on cannot be refused ` + + `while ${TWO_FACTOR_ENV} is set to off, which overrides it: such a save reads as on while nobody can ` + + `enroll and nothing is enforced. Set ${TWO_FACTOR_ENV}=true before requiring multi-factor authentication.`, + ); + } + return; + } + try { + host.registerWritePredicate('auth', MFA_REQUIRED_SETTING_KEY, mfaRequiredEnrollmentPredicate()); + } catch { + // No `auth` namespace or no such key on this service: nothing saves it. + } +}