diff --git a/.changeset/30300-keep-the-last-identity-provider.md b/.changeset/30300-keep-the-last-identity-provider.md new file mode 100644 index 00000000000..aa349cdd4aa --- /dev/null +++ b/.changeset/30300-keep-the-last-identity-provider.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth)!: deleting the last identity provider is refused, and a boot that serves no sign-in method says so (#30300) + +Clause-②: yes (narrowing) — a request an administrator could make before is now refused: `POST /api/v1/auth/sso/delete-provider` (Setup's "Delete SSO Provider") on the last identity provider, while email and password is off and nothing else signs anyone in, answers `409 DELETE_RESTRICTED` (an existing standard code, the one an organization delete refused for the same reason answers), or `503 SERVICE_UNAVAILABLE` when the provider list cannot be read to tell. No new error code, setting or field. The widening half: `AuthManager` gains `setSsoProviderDeleteFloor()`, marked `@internal`, through which `AuthPlugin` hands it the judge. The boot report is a new `error` log line only; nothing about the boot's outcome changes. +Metadata-protocol: none +Skills-docs: none — no file under `skills/**` teaches deleting an SSO provider or the sign-in boot reports; `content/docs/deployment/self-hosting.mdx` gains "No sign-in method left" with the three doors and the recovery. + + + +**BREAKING** accept-set narrowing at `POST /api/v1/auth/sso/delete-provider`: deleting the identity provider that is the last way to sign in is refused, where it was deleted before. + +- **Before.** Email and password off, one SSO identity provider registered and nothing else in force. Setup → the provider → "Delete SSO Provider": `200 {"success":true}`, the row gone. From then on nobody could sign in, the administrator included (measured on a real boot). +- **Now.** That delete answers `409` with `code: DELETE_RESTRICTED`, `lastSignInMethod: enterprise_sso`, and a message in the caller's language (en, zh-CN, ja-JP, es-ES) saying to turn email and password back on, or register another identity provider or set up another sign-in method, first. Nothing is deleted. When the provider list cannot be read to tell, it answers `503 SERVICE_UNAVAILABLE` instead of letting the delete through. +- **Still allowed.** Deleting a provider while another one remains, while email and password is on, while enterprise SSO is switched off, or while another way in counts: a social or OIDC provider with a client ID and secret, phone-number sign-in, or a host sign-in handoff. An unknown provider is still the vendor's `404`, and a caller with no session the vendor's `401`; the vendor's own provider-access check still decides who may delete which provider. +- **The redeploy door.** A boot whose configuration no longer declares the last way in (for example a redeploy without the `GOOGLE_CLIENT_*` pair while email and password is off) still boots, as before, and now reports at `error` under the grep token `no_sign_in_method_at_boot`, naming the consequence and the recovery. It is a positive claim only: a provider table that cannot be read says nothing. Refusing such a boot is a later option, not done: a deployment that signs people in through a door the auth plugin cannot see must keep starting. +- **Already locked out.** Set `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` and restart: the environment override wins over a stored `auth.email_password_enabled = false` and over the stack config, and email and password sign-in comes back. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 7763f827c46..6fc7ddaf7a7 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -628,6 +628,28 @@ logins too, or leave the directory empty until the first administrator exists. A deployment that boots with zero human rows is the case both paths above describe, and it needs none of this. +### No sign-in method left + +Accounts are not enough if nothing serves a way to use them. With email and +password sign-in off, the deployment depends on its other sign-in methods: a +social or OIDC provider with a client ID and secret, enterprise SSO with at +least one registered identity provider, phone-number sign-in, or a host +sign-in handoff. Three doors used to remove the last of them without a word; +each now answers: + +| Door | What happens now | +|:---|:---| +| The auth settings save turns email and password off, or removes Google, leaving nothing | Refused `400 SETTINGS_VALIDATION` at the field | +| Setup's **Delete SSO Provider** on the last identity provider (`POST /api/v1/auth/sso/delete-provider`) | Refused `409 DELETE_RESTRICTED`, nothing deleted; `503 SERVICE_UNAVAILABLE` when the provider list cannot be read to tell | +| A redeploy whose configuration no longer declares the last provider (for example, without the `GOOGLE_CLIENT_*` pair) | Boots, and reports at **error** under the grep token `no_sign_in_method_at_boot` | + +The boot is not refused: a deployment that signs people in through a door the +auth plugin cannot see keeps starting. **Recovery:** set +`OS_AUTH_EMAIL_PASSWORD_ENABLED=true` and restart. The environment override +wins over a stored `auth.email_password_enabled = false` and over the stack +config, and email and password sign-in comes back. Or restore the sign-in +method the configuration no longer declares. + ## Who may sign up — and what each posture tells a stranger `auth.audience.posture` is the one declaration answering who may become a user diff --git a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts index abf531133d8..3b91c1cf489 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts @@ -129,6 +129,7 @@ export interface AuthInstanceHost { assertPhoneOtpSendAllowed: AuthManager['assertPhoneOtpSendAllowed']; assertRemoveMemberPermitted: AuthManager['assertRemoveMemberPermitted']; assertRevokeSessionIdentifiesRecord: AuthManager['assertRevokeSessionIdentifiesRecord']; + assertSsoProviderDeletable: AuthManager['assertSsoProviderDeletable']; auditRefusedSignIn: AuthManager['auditRefusedSignIn']; buildPluginList: AuthManager['buildPluginList']; composeDatabaseHooks: AuthManager['composeDatabaseHooks']; @@ -780,6 +781,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise this.assertPhoneOtpSendAllowed(...a), assertRemoveMemberPermitted: (...a) => this.assertRemoveMemberPermitted(...a), assertRevokeSessionIdentifiesRecord: (...a) => this.assertRevokeSessionIdentifiesRecord(...a), + assertSsoProviderDeletable: (...a) => this.assertSsoProviderDeletable(...a), get config() { return readConfig(); }, existingAddressSignUpAnswersSuccess: (...a) => this.existingAddressSignUpAnswersSuccess(...a), hasExistingUserFor: (...a) => this.hasExistingUserFor(...a), diff --git a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts index 0aea932b6cf..09d7b2f1f49 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts @@ -71,6 +71,7 @@ import { } from './last-local-credential.js'; import { ORGANIZATION_DELETE_PATH } from './organization-delete.js'; import { carriesPrincipalSession } from './principal-session-mint.js'; +import { SSO_PROVIDER_DELETE_PATH } from './sign-in-method-floor.js'; import { snapshotRoleChangeGrants, UPDATE_MEMBER_ROLE_PATH } from './role-change-granted-set.js'; import { refuseVisitorResendWithoutLiveTransport } from './visitor-verification-resend.js'; @@ -91,6 +92,7 @@ export interface BeforeHookHost { assertPhoneOtpSendAllowed: AuthManager['assertPhoneOtpSendAllowed']; assertRemoveMemberPermitted: AuthManager['assertRemoveMemberPermitted']; assertRevokeSessionIdentifiesRecord: AuthManager['assertRevokeSessionIdentifiesRecord']; + assertSsoProviderDeletable: AuthManager['assertSsoProviderDeletable']; readonly config: AuthManager['config']; existingAddressSignUpAnswersSuccess: AuthManager['existingAddressSignUpAnswersSuccess']; hasExistingUserFor: AuthManager['hasExistingUserFor']; @@ -308,6 +310,17 @@ export async function globalBeforeHook( // fall through — the vendor's own provider-access check still runs } + // ── [#30300] nor delete the last way to sign in ─────────────────── + // With email and password off and one identity provider as the only + // other way in, Setup's "Delete SSO Provider" answered 200 and nobody + // could sign in again (measured on a real boot). Asked after the + // held-caller refusal above and before the vendor's first write; the + // sign-in method floor decides (`sign-in-method-floor.ts`). + if (ctx?.path === SSO_PROVIDER_DELETE_PATH) { + await this.assertSsoProviderDeletable(ctx); + // fall through — the vendor's own provider-access check still runs + } + // ── ADR-0135 D6 + ADR-0068 D4: registering an identity provider is ── // a PLATFORM-OPERATOR action. // diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e83cb87f9db..0d5bddca603 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -82,6 +82,7 @@ import type { TenancyService } from './tenancy-service.js'; import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js'; import { assertPasswordResetEndsSessions } from './password-reset-ends-sessions.js'; import type { CounterStore } from './rate-limit-storage.js'; +import type { SsoProviderDeleteRefusal } from './sign-in-method-floor.js'; import { applyUserBan, applyUserUnban, @@ -1588,6 +1589,22 @@ export class AuthManager { setOrganizationDeleteStandingJudge(judge: (organizationId: string) => Promise): void { this.organizationDeleteStandingJudge = judge; } + + /** + * [#30300] The sign-in method floor's question about an identity-provider + * delete (`sign-in-method-floor.ts` `ssoProviderDeleteRefusal`), handed over + * by `AuthPlugin`, which holds what the floor reads beyond this manager (the + * host's sign-in handoff, the boot's provider credentials). Absent on a + * manager no plugin composed, where nothing judges the delete. + */ + private ssoProviderDeleteFloor?: (providerId: string, locale?: string) => Promise; + + /** @internal [#30300] See {@link ssoProviderDeleteFloor}; called by `AuthPlugin`. */ + setSsoProviderDeleteFloor( + judge: (providerId: string, locale?: string) => Promise, + ): void { + this.ssoProviderDeleteFloor = judge; + } // ADR-0069 — cached "does any org require MFA" flag (per-org tightening). // Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap. private _orgMfaCache: { value: boolean; at: number } = { value: false, at: 0 }; @@ -1744,6 +1761,7 @@ export class AuthManager { assertPhoneOtpSendAllowed: (...a) => this.assertPhoneOtpSendAllowed(...a), assertRemoveMemberPermitted: (...a) => this.assertRemoveMemberPermitted(...a), assertRevokeSessionIdentifiesRecord: (...a) => this.assertRevokeSessionIdentifiesRecord(...a), + assertSsoProviderDeletable: (...a) => this.assertSsoProviderDeletable(...a), auditRefusedSignIn: (...a) => this.auditRefusedSignIn(...a), buildPluginList: (...a) => this.buildPluginList(...a), composeDatabaseHooks: (...a) => this.composeDatabaseHooks(...a), @@ -5034,6 +5052,35 @@ export class AuthManager { return assertOrganizationDeletable.call(host, ctx); } + /** + * [#30300] `/sso/delete-provider` admission: refuse the delete that would + * remove the last way to sign in, before the vendor deletes anything + * ({@link ssoProviderDeleteFloor} decides; `409 DELETE_RESTRICTED`, or `503 + * SERVICE_UNAVAILABLE` when the provider list cannot be read). Asked only of + * an authenticated caller with a `providerId`: what the vendor itself answers + * (no session, a malformed body) falls through to it. The refusal states a + * deployment-wide fact `/auth/config` already publishes (email and password + * off, what else signs anyone in), so it is not held back for a caller the + * vendor would refuse as not the provider's owner. + */ + private async assertSsoProviderDeletable(ctx: any): Promise { + const judge = this.ssoProviderDeleteFloor; + if (!judge) return; + const providerId = typeof ctx?.body?.providerId === 'string' ? ctx.body.providerId : ''; + if (!providerId) return; + const actor = await this.resolveActor(ctx); + if (!actor?.userId) return; + const header = acceptLanguageHeader(ctx); + const refusal = await judge(providerId, header ? preferredLocaleFromHeader(header) : undefined); + if (!refusal) return; + const { APIError } = await import('better-auth/api'); + throw new APIError(refusal.status, { + code: refusal.code, + message: refusal.message, + lastSignInMethod: refusal.lastSignInMethod, + }); + } + private assertRemoveMemberPermitted(ctx: any): Promise { // [#25224 cut 1] The body moved to `auth-manager-actor-guards.ts` (see `ActorGuardHost`). const readOrgRolesMap = () => this.orgRolesMap; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 6ef278f3a58..6b3e7685753 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -110,7 +110,14 @@ import { reportIfNoSignInAccountExists, type SignInPathConfigView, } from './boot-sign-in-reachability.js'; -import { providerCredentialIndex, providerCredentialsFrom, registerSignInMethodFloor } from './sign-in-method-floor.js'; +import { + providerCredentialIndex, + providerCredentialsFrom, + registerSignInMethodFloor, + reportIfNoSignInMethodAtBoot, + ssoProviderDeleteRefusal, + type SignInMethodFloorDeps, +} from './sign-in-method-floor.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { decideDemoStaffEnvironmentGate, @@ -800,6 +807,12 @@ export class AuthPlugin implements Plugin { // ADR-0092 D6 — remember the storage better-auth will actually use so the // identity write guard can keep cached session snapshots coherent. this.effectiveSecondaryStorage = authConfig.secondaryStorage; + // [#30300] Deleting the last identity provider is judged by the same + // sign-in method floor as the auth settings save (`sign-in-method-floor.ts`). + const floorDeps = this.signInMethodFloorDeps(ctx, this.authManager); + this.authManager.setSsoProviderDeleteFloor((providerId, locale) => + ssoProviderDeleteRefusal(floorDeps, providerId, locale), + ); // Register auth service ctx.registerService('auth', this.authManager); @@ -1509,6 +1522,23 @@ export class AuthPlugin implements Plugin { ctx.logger, ); } + + // [#30300, ruling A] "Is there ANY way to sign in?" — the question the + // account report above does not ask: a redeploy that drops the last + // identity provider while email and password is off boots a deployment + // that serves no sign-in method at all, and said nothing. Said at + // `error` in every posture, NEVER refused: a platform-SSO tenant that + // signs in through a door this plugin cannot see must keep booting. + // Judged after the auth settings are applied, by the same floor as the + // settings save (`sign-in-method-floor.ts`). + if (this.authManager) { + try { + await this.ensureAuthSettingsBound(ctx); + } catch { + // The binding reports its own failure; judge what is in force. + } + await reportIfNoSignInMethodAtBoot(this.signInMethodFloorDeps(ctx, this.authManager), ctx.logger); + } }); // Dev-only: provision a known, loginable platform admin on an empty DB. @@ -2013,6 +2043,31 @@ export class AuthPlugin implements Plugin { return this.authSettingsBinding; } + /** + * [#30202, #30300] The facts the sign-in method floor reads, live: the + * manager's advertisement, the data engine `sys_sso_provider` is read from, + * the host handoff declaration, and the boot credential index. One builder + * for the three doors that judge by it — the auth settings save, the delete + * of an identity provider, and the boot report — so they cannot drift apart. + */ + private signInMethodFloorDeps(ctx: PluginContext, manager: AuthManager): SignInMethodFloorDeps { + return { + publicConfig: () => manager.getPublicConfig(), + engine: () => { + try { + return ctx.getService('objectql'); + } catch { + return undefined; + } + }, + hostSignInHandoff: this.options.hostSignInHandoff === true, + stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google, + // Read when judged: the index is set in init, after `auth:configure`. + providerHasCredentials: (provider) => providerCredentialsFrom(this.bootProviderCredentials)(provider), + env: () => ((globalThis as any)?.process?.env ?? {}) as Record, + }; + } + /** * [#23367, ruling A] Record the API-key lifecycle policy's landing — the * anchor of the inactivity clock for keys with no recorded use, and of the @@ -2086,26 +2141,7 @@ export class AuthPlugin implements Plugin { // The save door refuses turning off the last sign-in method, before the // value is stored; the pass below would only apply it. See the module. - const floorManager = this.authManager; - registerSignInMethodFloor( - settings, - { - publicConfig: () => floorManager.getPublicConfig(), - engine: () => { - try { - return ctx.getService('objectql'); - } catch { - return undefined; - } - }, - hostSignInHandoff: this.options.hostSignInHandoff === true, - stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google, - // Read at save time: the index is set in init, after `auth:configure`. - providerHasCredentials: (provider) => providerCredentialsFrom(this.bootProviderCredentials)(provider), - env: () => ((globalThis as any)?.process?.env ?? {}) as Record, - }, - ctx.logger, - ); + registerSignInMethodFloor(settings, this.signInMethodFloorDeps(ctx, this.authManager), ctx.logger); const applySettings = async (): Promise => { if (!this.authManager) return; diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts index 37064e0fd46..44f12359cb9 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts @@ -30,14 +30,39 @@ * accepted; * 7. a provider listed without a credential pair is no way in, and one the * boot did not declare counts only as the settings-added Google. + * + * [#30300] The two doors that are not a settings save, below the first + * describe, each with its own composition: + * 8. deleting the last identity provider (`POST /sso/delete-provider`) while + * email and password is off is refused 409 `DELETE_RESTRICTED`, in the + * caller's language, nothing deleted; CONTROLS: another provider left, + * email and password on, an unknown provider (the vendor's 404), no + * session (the vendor's 401). Real `ObjectKernel` with `ObjectQLPlugin` + * over in-memory SQLite, `SettingsServicePlugin` and `AuthPlugin` with + * the SSO plugin, a real sign-up and session cookie; provider rows are + * written straight into `sys_sso_provider` (registering one needs an IdP); + * 9. a boot that serves no way in (a redeploy without the `GOOGLE_CLIENT_*` + * pair, email and password off) reports `no_sign_in_method_at_boot` at + * `error` and still boots (ruling A); CONTROLS: the deploy that still + * declares the provider, and the recovery `OS_AUTH_EMAIL_PASSWORD_ENABLED=true`; + * 10. the judge and the report against a fault the composition cannot make: + * an unreadable provider list is 503 on the delete and silence at boot. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PluginContext } from '@objectstack/core'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; -import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql'; import { + ObjectQLPlugin, + assertEngineFindOnePredicate, + type EngineFindOneQueryInput, + type ObjectQL, +} from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + InMemoryCryptoProvider, SettingsService, + SettingsServicePlugin, SettingsValidationError, builtinSettingsManifests, registerSettingsRoutes, @@ -46,7 +71,17 @@ import { import { AuthPlugin } from './auth-plugin.js'; import type { AuthManager } from './auth-manager.js'; import { SSO_PROVIDER_OBJECT } from './sso-client-secret.js'; -import { SIGN_IN_FLOOR_COPY, providerCredentialIndex, providerCredentialsFrom } from './sign-in-method-floor.js'; +import { + NO_SIGN_IN_METHOD_AT_BOOT, + SIGN_IN_FLOOR_COPY, + providerCredentialIndex, + providerCredentialsFrom, + reportIfNoSignInMethodAtBoot, + resolveNoSignInMethodReport, + ssoProviderDeleteRefusal, + type SignInMethodConfigView, + type SignInMethodFloorDeps, +} from './sign-in-method-floor.js'; const SECRET = 'test-secret-at-least-32-chars-long'; @@ -425,3 +460,305 @@ describe('the auth settings save keeps a way to sign in', () => { expect(passwordOn((await boot({ settings: recovered })).manager)).toBe(true); }); }); + +// ── [#30300] The doors that are not a settings save ───────────────────────── + +const BASE = 'http://localhost:3000'; +const AUTH = `${BASE}/api/v1/auth`; +const SECRET_30300 = 'test-secret-at-least-32-chars-long-30300'; +const PASSWORD = 'S3cure!Passw0rd-30300'; +const SYS = { isSystem: true } as const; + +const ENV_KEYS_30300 = [ + 'OS_AUTH_EMAIL_PASSWORD_ENABLED', + 'OS_AUTH_GOOGLE_ENABLED', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'OS_SSO_ENABLED', + 'OS_AUTH_SSO_ONLY', + 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', + 'OS_PLATFORM_OWNER_EMAIL', +] as const; + +function sqliteDriverPlugin(): Plugin { + return { + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService( + 'driver.default', + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + ); + }, + }; +} + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +describe('the last way to sign in is kept, and a boot without one is said (#30300)', () => { + let kernel: ObjectKernel | undefined; + const savedEnv: Partial> = {}; + + beforeEach(() => { + for (const key of ENV_KEYS_30300) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a stopped kernel */ + } + kernel = undefined; + for (const key of ENV_KEYS_30300) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + vi.restoreAllMocks(); + }); + + /** + * Boot the composition. The kernel logger's `error` is spied on the + * instance every plugin's `ctx.logger` is (the seeded-boot pin's way). + */ + async function boot(authOptions: Record = {}) { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + const kernelLogger = (kernel as unknown as { logger: { error: (message: string, meta?: unknown) => void } }).logger; + const error = vi.spyOn(kernelLogger, 'error'); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AuthPlugin({ secret: SECRET_30300, baseUrl: BASE, ...authOptions })); + await kernel.use(new SettingsServicePlugin({ registerRoutes: false, cryptoProvider: new InMemoryCryptoProvider() })); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + const manager = kernel.getService('auth'); + const settings = kernel.getService('settings'); + const reports = () => + error.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(NO_SIGN_IN_METHOD_AT_BOOT)); + return { ql, manager, settings, reports }; + } + + // ── Door 1 ───────────────────────────────────────────────────────────────── + + /** A signed-in administrator who owns `providers`, on a deployment with enterprise SSO switched on. */ + async function adminWithProviders(providers: string[]) { + const env = await boot({ plugins: { sso: true } }); + const { ql, manager } = env; + const signedUp = await manager.handleRequest( + new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email: 'admin@acme.test', password: PASSWORD, name: 'Admin' }), + }), + ); + expect(signedUp.status, await signedUp.clone().text()).toBe(200); + const cookie = cookieFrom(signedUp); + const [user] = (await ql.find('sys_user', { where: { email: 'admin@acme.test' }, limit: 1, context: SYS })) as any[]; + for (const providerId of providers) { + await ql.insert( + SSO_PROVIDER_OBJECT, + { + id: `ssop_${providerId}`, + provider_id: providerId, + issuer: `https://${providerId}.idp.test`, + domain: 'acme.test', + oidc_config: JSON.stringify({ clientId: `${providerId}-client`, discoveryEndpoint: `https://${providerId}.idp.test/.well-known/openid-configuration` }), + user_id: String(user.id), + }, + { context: SYS }, + ); + } + const providerIds = async () => + ((await ql.find(SSO_PROVIDER_OBJECT, { limit: 10, context: SYS })) as any[]).map((r) => r.provider_id).sort(); + const remove = (providerId: string, opts: { cookie?: string; language?: string } = {}) => + manager.handleRequest( + new Request(`${AUTH}/sso/delete-provider`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: BASE, + ...(opts.cookie === '' ? {} : { cookie: opts.cookie ?? cookie }), + ...(opts.language ? { 'accept-language': opts.language } : {}), + }, + body: JSON.stringify({ providerId }), + }), + ); + /** Email and password off through the console, as the operator did; the floor admits it while a provider row exists. */ + const passwordOff = async () => { + await env.settings.setMany('auth', { email_password_enabled: false }); + await vi.waitFor(() => expect(manager.getPublicConfig().emailPassword.enabled).toBe(false)); + }; + return { ...env, cookie, providerIds, remove, passwordOff }; + } + + it('refuses deleting the last identity provider while email and password is off: 409, localized, nothing deleted', async () => { + const { remove, providerIds, passwordOff } = await adminWithProviders(['okta']); + await passwordOff(); + + const res = await remove('okta', { language: 'zh-CN,zh;q=0.9' }); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(409); + expect(body).toMatchObject({ + code: 'DELETE_RESTRICTED', + message: SIGN_IN_FLOOR_COPY['zh-CN'].enterpriseSso, + lastSignInMethod: 'enterprise_sso', + }); + expect(await providerIds()).toEqual(['okta']); + + // Same refusal in the default language. + const en = await remove('okta'); + expect(en.status).toBe(409); + expect((await en.json()).message).toBe(SIGN_IN_FLOOR_COPY.en.enterpriseSso); + expect(await providerIds()).toEqual(['okta']); + }, 30_000); + + it('control: while another provider remains the delete goes through; the one left is then the last and is refused', async () => { + const { remove, providerIds, passwordOff } = await adminWithProviders(['okta', 'entra']); + await passwordOff(); + + const first = await remove('okta'); + expect(first.status, await first.clone().text()).toBe(200); + expect(await providerIds()).toEqual(['entra']); + + const last = await remove('entra'); + expect(last.status, await last.clone().text()).toBe(409); + expect(await providerIds()).toEqual(['entra']); + }, 30_000); + + it('control: while email and password is on, deleting the last provider goes through', async () => { + const { remove, providerIds } = await adminWithProviders(['okta']); + + const res = await remove('okta'); + expect(res.status, await res.clone().text()).toBe(200); + expect(await providerIds()).toEqual([]); + }, 30_000); + + it('control: an unknown provider is still the vendor 404, and no session is still the vendor 401', async () => { + const { remove, providerIds, passwordOff } = await adminWithProviders(['okta']); + await passwordOff(); + + expect((await remove('nobody')).status).toBe(404); + expect((await remove('okta', { cookie: '' })).status).toBe(401); + expect(await providerIds()).toEqual(['okta']); + }, 30_000); + + // ── Door 2 ───────────────────────────────────────────────────────────────── + + it('a redeploy that drops the last provider while email and password is off is said at error, and still boots', async () => { + const { manager, reports } = await boot({ emailAndPassword: { enabled: false } }); + + // The boot completed (bootstrap resolved, the manager serves) and serves no way in. + expect(manager.getPublicConfig().emailPassword.enabled).toBe(false); + expect(manager.getPublicConfig().socialProviders).toEqual([]); + expect(reports()).toHaveLength(1); + expect(reports()[0]).toContain('nobody can sign in'); + expect(reports()[0]).toContain('OS_AUTH_EMAIL_PASSWORD_ENABLED=true'); + }, 30_000); + + it('the same with enterprise SSO switched on but no identity provider row', async () => { + const { reports } = await boot({ emailAndPassword: { enabled: false }, plugins: { sso: true } }); + expect(reports()).toHaveLength(1); + }, 30_000); + + it('control: the deploy that still declares the provider says nothing', async () => { + process.env.GOOGLE_CLIENT_ID = 'google-env-client-id'; + process.env.GOOGLE_CLIENT_SECRET = 'google-env-client-secret'; + const { manager, reports } = await boot({ emailAndPassword: { enabled: false } }); + + expect(manager.getPublicConfig().socialProviders.map((p: { id: string }) => p.id)).toContain('google'); + expect(reports()).toEqual([]); + }, 30_000); + + it('recovery: OS_AUTH_EMAIL_PASSWORD_ENABLED=true turns email and password back on over the stack config, and nothing is said', async () => { + process.env.OS_AUTH_EMAIL_PASSWORD_ENABLED = 'true'; + const { manager, reports } = await boot({ emailAndPassword: { enabled: false } }); + + expect(manager.getPublicConfig().emailPassword.enabled).toBe(true); + expect(reports()).toEqual([]); + }, 30_000); +}); + +// ── The judge and the report, against a fault the composition cannot make ─── + +describe('the floor judges a provider delete and a boot by what it can read (#30300)', () => { + const OFF: SignInMethodConfigView = { emailPassword: { enabled: false }, socialProviders: [], features: { sso: true } }; + + const deps = ( + over: { config?: SignInMethodConfigView; rows?: unknown[] | 'throws'; hostSignInHandoff?: boolean } = {}, + ): SignInMethodFloorDeps => ({ + publicConfig: () => over.config ?? OFF, + engine: () => ({ + find: async (_object: string, query?: { limit?: number }) => { + if (over.rows === 'throws') throw new Error('provider table unreadable'); + const rows = (over.rows ?? []) as any[]; + return query && 'limit' in query && typeof query.limit === 'number' ? rows.slice(0, query.limit) : rows; + }, + }) as any, + hostSignInHandoff: over.hostSignInHandoff ?? false, + stackConfiguredGoogle: () => false, + providerHasCredentials: () => true, + env: () => ({}), + }); + + it('the last provider: 409 DELETE_RESTRICTED; an unreadable provider list: 503 SERVICE_UNAVAILABLE, never a pass', async () => { + await expect(ssoProviderDeleteRefusal(deps({ rows: [{ provider_id: 'okta' }] }), 'okta', 'ja-JP')).resolves.toEqual({ + status: 'CONFLICT', + code: 'DELETE_RESTRICTED', + message: SIGN_IN_FLOOR_COPY['ja-JP'].enterpriseSso, + lastSignInMethod: 'enterprise_sso', + }); + await expect(ssoProviderDeleteRefusal(deps({ rows: 'throws' }), 'okta', 'es-ES')).resolves.toEqual({ + status: 'SERVICE_UNAVAILABLE', + code: 'SERVICE_UNAVAILABLE', + message: SIGN_IN_FLOOR_COPY['es-ES'].enterpriseSsoUnverified, + }); + }); + + it.each([ + { name: 'email and password on', d: () => deps({ config: { ...OFF, emailPassword: { enabled: true } }, rows: [{ provider_id: 'okta' }] }) }, + { name: 'enterprise SSO not switched on', d: () => deps({ config: { ...OFF, features: { sso: false } }, rows: [{ provider_id: 'okta' }] }) }, + { name: 'another provider row', d: () => deps({ rows: [{ provider_id: 'okta' }, { provider_id: 'entra' }] }) }, + { name: 'no row at all (the vendor answers 404)', d: () => deps({ rows: [] }) }, + { name: 'a social provider with credentials', d: () => deps({ config: { ...OFF, socialProviders: [{ id: 'github', type: 'social' }] }, rows: [{ provider_id: 'okta' }] }) }, + { name: 'phone-number sign-in', d: () => deps({ config: { ...OFF, features: { sso: true, phoneNumber: true } }, rows: [{ provider_id: 'okta' }] }) }, + { name: 'a host sign-in handoff', d: () => deps({ hostSignInHandoff: true, rows: [{ provider_id: 'okta' }] }) }, + ])('passes the delete with $name', async ({ d }) => { + await expect(ssoProviderDeleteRefusal(d(), 'okta')).resolves.toBeUndefined(); + }); + + it('the boot report is a positive claim: an unreadable provider table says nothing, an empty one says it', async () => { + expect(await resolveNoSignInMethodReport(deps({ rows: 'throws' }))).toBeNull(); + expect(await resolveNoSignInMethodReport(deps({ rows: [{ provider_id: 'okta' }] }))).toBeNull(); + expect(await resolveNoSignInMethodReport(deps({ rows: [] }))).toContain(NO_SIGN_IN_METHOD_AT_BOOT); + // Enterprise SSO not switched on: its table is not read, and nothing else is in force. + expect(await resolveNoSignInMethodReport(deps({ config: { ...OFF, features: { sso: false } }, rows: 'throws' }))).toContain( + NO_SIGN_IN_METHOD_AT_BOOT, + ); + expect(await resolveNoSignInMethodReport(deps({ hostSignInHandoff: true }))).toBeNull(); + }); + + it('says it once at error (warn on a sink without error), and never throws', async () => { + const error = vi.fn(); + const warn = vi.fn(); + const said = await reportIfNoSignInMethodAtBoot(deps(), { error, warn } as any); + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith(said); + expect(warn).not.toHaveBeenCalled(); + + const warnOnly = vi.fn(); + await reportIfNoSignInMethodAtBoot(deps(), { warn: warnOnly } as any); + expect(warnOnly).toHaveBeenCalledTimes(1); + + const throwing = { error: () => { throw new Error('sink down'); }, warn: vi.fn() }; + await expect(reportIfNoSignInMethodAtBoot(deps(), throwing as any)).resolves.toContain(NO_SIGN_IN_METHOD_AT_BOOT); + }); +}); diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts index 6b2fcf634a1..bf12206b2b7 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts @@ -62,10 +62,42 @@ * A deployment that stored the dead-end state before this check existed gets * back in with `OS_AUTH_EMAIL_PASSWORD_ENABLED=true`, the settings environment * override, which wins over the stored value, and a restart. + * + * ## The two doors that are not a settings save + * + * The same end state is reached without saving the auth settings, so the same + * list of ways in is asked at two more places: + * + * - **Deleting the last identity provider.** Setup's "Delete SSO Provider" + * posts better-auth's `/sso/delete-provider`. Measured on `main` + * `bfca0efb` with email and password off and one SAML provider as the only + * other way in: `200`, the row gone, and after sign-out every sign-in + * answered `400` or `404`. {@link ssoProviderDeleteRefusal} answers it + * from the global before-hook, ahead of the vendor's first write: `409 + * DELETE_RESTRICTED` when the provider is the last row and nothing but + * enterprise SSO is in force, and `503 SERVICE_UNAVAILABLE` when the + * provider list cannot be read to tell. The data API is not a door: it + * refuses a delete on `sys_sso_provider` (`405`), and the object has no + * switch that disables a provider. + * - **A boot that serves no way in.** A redeploy whose configuration no + * longer declares the last provider (measured: Google declared by + * `GOOGLE_CLIENT_*`, dropped at the next boot) started clean and served + * nothing. Nothing is refused there: a provider can leave the + * configuration in many ways, and a deployment that delegates sign-in in a + * way this list does not count (`ssoOnlyMode` alone) must still boot. The + * boot says it once, at `error`, with the recovery + * ({@link reportIfNoSignInMethodAtBoot}). */ import { resolveLocale } from '@objectstack/core'; -import { probeSsoProvidersPresence, type BootProbeEngine } from './boot-sign-in-reachability.js'; +import type { StandardErrorCode } from '@objectstack/spec/api'; +import { + probeSsoProvidersPresence, + type BootDiagnosticLogger, + type BootProbeEngine, + type BootStorePresence, +} from './boot-sign-in-reachability.js'; +import { SSO_PROVIDER_OBJECT } from './sso-client-secret.js'; /** A way in other than email and password, as {@link otherSignInMethods} names it. */ export type OtherSignInMethod = 'social_provider' | 'enterprise_sso' | 'phone_number' | 'host_handoff'; @@ -155,11 +187,12 @@ function providerCounts( /** * The ways in besides email and password that are in force now. `withoutGoogle` * leaves the settings-managed Google provider out, to judge a save that may - * remove or add it. + * remove or add it; `withoutEnterpriseSso` leaves enterprise SSO out (and reads + * no provider row), for a caller that judges the provider rows itself. */ export async function otherSignInMethods( deps: SignInMethodFloorDeps, - options: { withoutGoogle?: boolean } = {}, + options: { withoutGoogle?: boolean; withoutEnterpriseSso?: boolean } = {}, ): Promise { const config = deps.publicConfig(); const methods: OtherSignInMethod[] = []; @@ -169,7 +202,11 @@ export async function otherSignInMethods( if (providers.length > 0) methods.push('social_provider'); if (config.features?.phoneNumber === true) methods.push('phone_number'); if (deps.hostSignInHandoff) methods.push('host_handoff'); - if (config.features?.sso === true && (await probeSsoProvidersPresence(deps.engine())) === 'present') { + if ( + !options.withoutEnterpriseSso && + config.features?.sso === true && + (await probeSsoProvidersPresence(deps.engine())) === 'present' + ) { methods.push('enterprise_sso'); } return methods; @@ -180,6 +217,10 @@ export async function otherSignInMethods( interface SignInFloorCopy { emailPassword: string; google: string; + /** Deleting the last identity provider ({@link ssoProviderDeleteRefusal}). */ + enterpriseSso: string; + /** The provider list could not be read to judge that delete. */ + enterpriseSsoUnverified: string; } /** One entry per locale the settings bundle ships, as the currency lock's copy. */ @@ -194,6 +235,14 @@ export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Sign 'With email and password off, this change to Google would leave no way to sign in, so everyone would be ' + 'locked out, administrators included. Keep email and password sign-in on, or set up another sign-in method, ' + 'before or in this save.', + enterpriseSso: + 'This is the last enterprise SSO identity provider, and with email and password off nothing else signs ' + + 'anyone in, so deleting it would lock everyone out, administrators included. First turn email and password ' + + 'sign-in back on, or register another identity provider or set up another sign-in method, then delete this one.', + enterpriseSsoUnverified: + 'The identity provider was not deleted: the list of identity providers could not be read to confirm that ' + + 'another way to sign in remains, and with email and password off this one may be the last. Try again once ' + + 'the database is reachable.', }, 'zh-CN': { emailPassword: @@ -202,6 +251,12 @@ export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Sign google: '邮箱/密码登录关闭时,这项 Google 修改会导致没有任何登录方式,所有人(包括管理员)都将无法登录。' + '请保持邮箱/密码登录开启,或在本次保存之前或同时配置另一种登录方式。', + enterpriseSso: + '这是最后一个企业 SSO 身份提供方,邮箱/密码登录又已关闭,没有其他登录方式,删除后所有人(包括管理员)都将无法登录。' + + '请先重新开启邮箱/密码登录,或登记另一个身份提供方、配置另一种登录方式,然后再删除它。', + enterpriseSsoUnverified: + '身份提供方未删除:无法读取身份提供方列表来确认仍有其他登录方式,而邮箱/密码登录已关闭,它可能是最后一种。' + + '请在数据库可以访问时重试。', }, 'ja-JP': { emailPassword: @@ -213,6 +268,14 @@ export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Sign 'メール/パスワードログインがオフの状態でこの Google の変更を行うとログイン方法がなくなり、管理者を含む全員が' + 'ログインできなくなります。メール/パスワードログインをオンのままにするか、この保存の前か同じ保存で別のログイン' + '方法を設定してください。', + enterpriseSso: + 'これは最後のエンタープライズ SSO の ID プロバイダーで、メール/パスワードログインもオフのため他にログイン方法が' + + 'ありません。削除すると管理者を含む全員がログインできなくなります。先にメール/パスワードログインをオンに戻すか、' + + '別の ID プロバイダーを登録するか別のログイン方法を設定してから削除してください。', + enterpriseSsoUnverified: + 'ID プロバイダーは削除されませんでした。ID プロバイダーの一覧を読み取れず、他のログイン方法が残ることを確認できません' + + 'でした。メール/パスワードログインがオフのため、これが最後のログイン方法かもしれません。データベースに接続できる' + + 'ようになってから再試行してください。', }, 'es-ES': { emailPassword: @@ -225,6 +288,15 @@ export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Sign 'Con el correo y la contraseña desactivados, este cambio en Google dejaría sin ninguna forma de iniciar ' + 'sesión, así que todo el mundo quedaría fuera, incluidos los administradores. Mantenga activado el inicio de ' + 'sesión con correo y contraseña o configure otro método, antes o en este mismo guardado.', + enterpriseSso: + 'Este es el último proveedor de identidad de SSO empresarial y, con el correo y la contraseña desactivados, ' + + 'nada más permite iniciar sesión, así que eliminarlo dejaría fuera a todo el mundo, incluidos los ' + + 'administradores. Vuelva a activar primero el inicio de sesión con correo y contraseña, o registre otro ' + + 'proveedor de identidad o configure otro método, y después elimine este.', + enterpriseSsoUnverified: + 'El proveedor de identidad no se ha eliminado: no se pudo leer la lista de proveedores de identidad para ' + + 'confirmar que queda otra forma de iniciar sesión y, con el correo y la contraseña desactivados, este podría ' + + 'ser el último. Vuelva a intentarlo cuando la base de datos esté disponible.', }, }; @@ -388,3 +460,129 @@ export function registerSignInMethodFloor( } } } + +// ── Door 1: deleting the last identity provider ───────────────────────────── + +/** The better-auth endpoint Setup's "Delete SSO Provider" posts. */ +export const SSO_PROVIDER_DELETE_PATH = '/sso/delete-provider'; + +/** + * Whether a `sys_sso_provider` row other than `providerId` exists. One bounded + * read of at most two rows answers it: the only way none of them is another + * provider is that the table holds exactly the provider being deleted. + * `absent` therefore means "`providerId` is the last row", and `not_found` + * that the table holds no row at all, so the delete removes nothing (the + * vendor answers it `404`). A read that fails, or no engine, is `unknown`. + */ +export async function probeOtherSsoProviders( + engine: BootProbeEngine | undefined, + providerId: string, +): Promise { + if (!engine || typeof engine.find !== 'function') return 'unknown'; + try { + const rows = await engine.find(SSO_PROVIDER_OBJECT, { limit: 2 }, { context: { isSystem: true } }); + if (rows.length === 0) return 'not_found'; + return rows.some((row) => row?.provider_id !== providerId) ? 'present' : 'absent'; + } catch { + return 'unknown'; + } +} + +/** The refusal of a `/sso/delete-provider` request; the caller throws it as better-auth's `APIError`. */ +export interface SsoProviderDeleteRefusal { + /** The better-auth status name. */ + status: 'CONFLICT' | 'SERVICE_UNAVAILABLE'; + code: StandardErrorCode; + message: string; + /** Set on the `409`: the way in the delete would remove, in the settings floor's vocabulary. */ + lastSignInMethod?: 'enterprise_sso'; +} + +/** + * Whether deleting the identity provider `providerId` would leave no way to + * sign in, asked before the vendor deletes anything. Nothing is refused while + * email and password is on, while enterprise SSO is not switched on (its rows + * then sign nobody in either way), or while another way in counts + * ({@link otherSignInMethods}, enterprise SSO left out) or another provider row + * remains. Otherwise the delete would remove the last way in: `409 + * DELETE_RESTRICTED` (the standard code for a delete refused while something + * depends on the row; `organization-delete.ts` answers the same), or `503 + * SERVICE_UNAVAILABLE` when the provider list cannot be read to tell, because a + * guard that could not be evaluated does not pass. + */ +export async function ssoProviderDeleteRefusal( + deps: SignInMethodFloorDeps, + providerId: string, + locale?: string, +): Promise { + const config = deps.publicConfig(); + if (config.emailPassword?.enabled !== false) return undefined; + if (config.features?.sso !== true) return undefined; + if ((await otherSignInMethods(deps, { withoutEnterpriseSso: true })).length > 0) return undefined; + const others = await probeOtherSsoProviders(deps.engine(), providerId); + if (others === 'present' || others === 'not_found') return undefined; + const copy = signInFloorCopy(locale); + if (others === 'unknown') { + return { status: 'SERVICE_UNAVAILABLE', code: 'SERVICE_UNAVAILABLE', message: copy.enterpriseSsoUnverified }; + } + return { + status: 'CONFLICT', + code: 'DELETE_RESTRICTED', + message: copy.enterpriseSso, + lastSignInMethod: 'enterprise_sso', + }; +} + +// ── Door 2: a boot that serves no way in ──────────────────────────────────── + +/** The grep token of the boot report, beside `no_sign_in_account_at_boot`. */ +export const NO_SIGN_IN_METHOD_AT_BOOT = 'no_sign_in_method_at_boot'; + +/** + * The boot report for a deployment that serves no way to sign in, or `null`. + * It makes a positive claim only: email and password is off, no other way in + * counts, and when enterprise SSO is switched on the provider table was READ + * and holds no row. A provider table that cannot be read proves nothing either + * way, so it stays silent, as `no_sign_in_account_at_boot` does on an + * unanswered probe. + */ +export async function resolveNoSignInMethodReport(deps: SignInMethodFloorDeps): Promise { + const config = deps.publicConfig(); + if (config.emailPassword?.enabled !== false) return null; + if ((await otherSignInMethods(deps, { withoutEnterpriseSso: true })).length > 0) return null; + if (config.features?.sso === true && (await probeSsoProvidersPresence(deps.engine())) !== 'absent') return null; + return ( + `[auth] ${NO_SIGN_IN_METHOD_AT_BOOT}: this deployment serves NO way to sign in, so nobody can sign in, ` + + 'administrators included, while it keeps looking healthy. Email and password sign-in is off, and no other ' + + 'sign-in method is in force: no social or OIDC provider with a client ID and secret, no enterprise SSO identity ' + + 'provider, no phone-number sign-in and no host sign-in handoff. A provider the previous deployment declared ' + + 'may be gone from its configuration. Recover: set OS_AUTH_EMAIL_PASSWORD_ENABLED=true and restart (it overrides ' + + 'the stored auth.email_password_enabled), or restore the sign-in method this configuration no longer declares; ' + + 'then set up another sign-in method before turning email and password off again. The boot continues.' + ); +} + +/** + * Say {@link resolveNoSignInMethodReport} once, at `error` (or at `warn` on a + * sink without `error`, the `boot-sign-in-reachability.ts` way). Never throws + * and never refuses the boot. Answers the message it printed, or `null`. + */ +export async function reportIfNoSignInMethodAtBoot( + deps: SignInMethodFloorDeps, + logger?: BootDiagnosticLogger, +): Promise { + let message: string | null; + try { + message = await resolveNoSignInMethodReport(deps); + } catch { + return null; + } + if (!message || !logger) return message; + try { + if (typeof logger.error === 'function') logger.error(message); + else logger.warn(message); + } catch { + // A sink that throws is not a reason to fail the boot. + } + return message; +}