Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/30411-org-require-mfa-follow-ups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/platform-objects': patch
---

fix(plugin-auth,platform-objects): an organization's require-MFA refusal reads in the caller's language, an accepted save that turns it on names the grace period in force, and the field help says two-factor must be on (#30411)

Clause-②: no
Metadata-protocol: none

- **Before.** Saving `sys_organization.require_mfa` on while two-factor enrollment was off was refused with an English sentence, whatever language the administrator's console used. An accepted save that turned it on answered nothing about when members would be blocked, and the configured grace period (`auth.mfa_grace_period_days`) was readable only through the `auth` settings, which need `manage_platform_settings`, a capability an organization administrator does not hold. The field help did not say the requirement needs two-factor on.
- **Now.** The refusal is in the request's language (English, Simplified Chinese, Japanese, Spanish; matched from `Accept-Language` exactly, then by language, else English), with the same shape as before: `400 VALIDATION_FAILED`, one `require_mfa` / `invalid_value` field entry carrying the same sentence as the top-level message. A save that is admitted and turns the requirement on (two-factor on, the stored value off, or an insert) answers a save `notice` on the create or update answer, in the same language: every member without an authenticator app must enroll, within the grace period in force, in days, read per write from the same setting the MFA gate's clock holds to. A save that turns nothing on (clearing it, re-saving a stored `true`, writing other columns) answers no notice. The field help, in all four languages, says the requirement needs two-factor authentication on for the deployment (`OS_AUTH_TWO_FACTOR=true`) and that saving it on is refused while it is off.
- **Unchanged.** Which writes are refused and which are admitted, the refusal's code, status and field entry, the MFA gate and its grace clock, and every `auth` setting and its read permission. No member is added to any answer: `notice` is the create and update answers' existing optional member.
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
require_mfa: {
label: "Require Multi-Factor Auth",
help: "When true, every member of this organization must enroll an authenticator app to access data."
help: "When true, every member of this organization must enroll an authenticator app to access data. Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, saving this as true is refused."
},
timezone: {
label: "Timezone",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
require_mfa: {
label: "Exigir autenticación multifactor",
help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos."
help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos. Requiere que la autenticación de dos factores esté activada en esta implementación (OS_AUTH_TWO_FACTOR=true); mientras esté desactivada, guardarlo como true se rechaza."
},
timezone: {
label: "Zona horaria",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
require_mfa: {
label: "多要素認証を必須化",
help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。"
help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。このデプロイで二要素認証がオンになっている必要があります(OS_AUTH_TWO_FACTOR=true)。オフの間は true で保存すると拒否されます。"
},
timezone: {
label: "タイムゾーン",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
require_mfa: {
label: "强制多因素认证",
help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。"
help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true);双重验证关闭时,保存为 true 会被拒绝。"
},
timezone: {
label: "时区",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
import { describe, it, expect } from 'vitest';
import { checkManagedApiMethodAffordances } from '@objectstack/spec/data';
import { SysOrganization } from './sys-organization.object';
import { enObjects } from '../apps/translations/en.objects.generated.js';
import { esESObjects } from '../apps/translations/es-ES.objects.generated.js';
import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js';
import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js';

describe('#15873 — sys_organization.enable.apiMethods admits `update`, and only `update`', () => {
it('declares exactly get / list / update — no create, no delete, no bulk', () => {
Expand Down Expand Up @@ -89,3 +93,23 @@ describe('#15873 — better-auth keeps its own door for its own columns', () =>
expect(create.target).toBe('/api/v1/auth/organization/create');
});
});

describe('#30411 — require_mfa\'s help says it needs two-factor on, in the source and in every locale', () => {
const bundles = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects };

it('the source description names the deployment switch the save is refused without', () => {
const description = SysOrganization.fields.require_mfa?.description;
expect(description).toMatch(/two-factor authentication on for this deployment/);
expect(description).toMatch(/OS_AUTH_TWO_FACTOR=true/);
});

it.each(Object.entries(bundles))('%s: the generated help carries the condition', (_locale, bundle) => {
const help = (bundle as any).sys_organization?.fields?.require_mfa?.help;
expect(help).toEqual(expect.any(String));
expect(help).toMatch(/OS_AUTH_TWO_FACTOR=true/);
});

it('the en bundle is the source, verbatim (the generator writes it)', () => {
expect((enObjects as any).sys_organization.fields.require_mfa.help).toBe(SysOrganization.fields.require_mfa?.description);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -395,13 +395,18 @@ export const SysOrganization = ObjectSchema.create({
// ADR-0069 D3 — per-org MFA tightening above the global floor. When true,
// members of this org must enrol TOTP to access data (enforced at the
// session-validation gate). An org can only tighten, never loosen, the
// global `mfa_required` setting.
// global `mfa_required` setting. Saving it on is refused while two-factor
// enrollment is off for the deployment (plugin-auth's
// `organization-mfa-enrollment-guard.ts`), which the help says (#30411).
require_mfa: Field.boolean({
label: 'Require Multi-Factor Auth',
required: false,
defaultValue: false,
group: 'Configuration',
description: 'When true, every member of this organization must enroll an authenticator app to access data.',
description:
'When true, every member of this organization must enroll an authenticator app to access data. ' +
'Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, ' +
'saving this as true is refused.',
}),

// #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ export interface PasswordSessionPolicyHost {
resolveActor: AuthManager['resolveActor'];
}

/**
* The MFA grace period in force, in whole days (ADR-0069 D3): the configured
* `mfaGracePeriodDays` (the `auth.mfa_grace_period_days` setting), 7 when
* unset, never below 0. The one reading of it: the gate's clock below and the
* organization MFA guard's save notice (`AuthManager#getMfaGracePeriodDays`)
* both ask here, so the number a saver is told is the number the gate holds to.
*/
export function resolveMfaGracePeriodDays(config: { mfaGracePeriodDays?: number }): number {
return Math.max(0, Math.floor(Number(config.mfaGracePeriodDays ?? 7)));
}

/** The body of `AuthManager#assertPasswordComplexity`; the method's docblock there is its contract. */
export async function assertPasswordComplexity(this: Pick<PasswordSessionPolicyHost, 'config'>, password: string): Promise<void> {
if (!this.config.passwordRequireComplexity) return;
Expand Down Expand Up @@ -222,7 +233,7 @@ export async function resolveUserAuthGates(
if (mfaRequired && unenrolled && !this.isTwoFactorEnabled()) {
this.noteMfaRequirementWithoutEnrollment();
} else if (mfaRequired && unenrolled) {
const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7)));
const graceDays = resolveMfaGracePeriodDays(this.config);
let requiredAt = u?.mfa_required_at;
if (!requiredAt) {
requiredAt = new Date();
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import {
recordPasswordHistory,
refreshMustChangeCacheIfStale,
refreshOrgMfaCacheIfStale,
resolveMfaGracePeriodDays,
resolvePasswordChangeUserId,
resolveUserAuthGates,
stampPasswordChangedAt,
Expand Down Expand Up @@ -5360,6 +5361,20 @@ export class AuthManager {
return resolveTwoFactorEnabled(this.config.plugins);
}

/**
* @internal Package-internal: read by the organization MFA enrollment guard
* `AuthPlugin` registers; not a supported entry point.
*
* [#30411] The MFA grace period in force, in whole days — the
* `auth.mfa_grace_period_days` setting as the settings pass applied it,
* {@link resolveMfaGracePeriodDays} over the live config, the same answer
* the MFA gate's clock reads. Read per call, so a settings change is seen at
* once.
*/
public getMfaGracePeriodDays(): number {
return resolveMfaGracePeriodDays(this.config);
}

/**
* [#30201] Called by the MFA gate when a user is under an MFA requirement
* (the global `mfaRequired` or their organization's `require_mfa`) that it
Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1944,6 +1944,9 @@ export class AuthPlugin implements Plugin {
registerOrganizationMfaEnrollmentGuard(engine, {
packageId: 'com.objectstack.plugin-auth.organization-mfa-enrollment-guard',
isTwoFactorEnabled: () => this.authManager?.isTwoFactorEnabled() ?? false,
// [#30411] The grace period an admitted turn-on's save notice names.
// No manager means two-factor reads off above, so nothing is admitted.
mfaGracePeriodDays: () => this.authManager?.getMfaGracePeriodDays() ?? Number.NaN,
});
// A reader's own named list views: the body is judged on every save as
// a ListView for the row's own object. See personal-view-hooks.ts.
Expand Down
Loading
Loading