From 15ec27e32dfac56cb6753b58f54db053e5962b97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:25:13 +0000 Subject: [PATCH 01/17] wip(plugin-auth): per-organization access policy Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../src/apps/setup-nav.contributions.ts | 6 + .../src/apps/translations/en.ts | 1 + .../src/apps/translations/es-ES.ts | 1 + .../src/apps/translations/ja-JP.ts | 1 + .../src/apps/translations/zh-CN.ts | 1 + .../src/identity/sys-organization.object.ts | 26 +- .../plugin-auth/src/allowed-ip-ranges.ts | 12 +- .../src/auth-manager-after-hook.ts | 8 +- .../src/auth-manager-database-hooks.ts | 39 +- .../auth-manager-password-session-policy.ts | 65 +-- .../src/auth-manager-plugin-list.ts | 8 +- .../plugins/plugin-auth/src/auth-manager.ts | 245 +++++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 106 ++++- .../plugin-auth/src/auth-settings-declared.ts | 6 +- .../src/managed-extension-fields.ts | 12 +- .../src/mfa-required-enrollment-predicate.ts | 22 +- .../src/organization-access-policy-binding.ts | 273 +++++++++++ .../src/organization-access-policy.ts | 444 ++++++++++++++++++ .../src/organization-mfa-enrollment-guard.ts | 373 ++------------- .../src/manifests/access-policy.manifest.ts | 114 +++++ .../src/manifests/auth.manifest.ts | 27 +- .../service-settings/src/manifests/index.ts | 3 + .../service-settings/src/translations/ar.ts | 21 + .../service-settings/src/translations/en.ts | 20 + .../src/translations/es-ES.ts | 21 + .../service-settings/src/translations/fr.ts | 21 + .../src/translations/ja-JP.ts | 21 + .../src/translations/pt-BR.ts | 21 + .../src/translations/zh-CN.ts | 21 + .../src/translations/zh-Hant.ts | 22 +- 30 files changed, 1520 insertions(+), 441 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/organization-access-policy-binding.ts create mode 100644 packages/plugins/plugin-auth/src/organization-access-policy.ts create mode 100644 packages/services/service-settings/src/manifests/access-policy.manifest.ts diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 626016aa6c3..ec0c95cdd69 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -141,6 +141,12 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // with the `branding` namespace, which is offered only where no // organization wall stands (#24766) — so the entry lives and dies with it. { id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, + // [ADR-0069 TL;DR 6] An organization's own sign-in restrictions, held to + // the deployment's Authentication values (the `access_policy` namespace, + // scope `tenant`). No `requiredPermissions`: like Localization, the + // manifest's `setup.access` read is the app's base permission, and an + // organization administrator writes it (`setup.write`). + { id: 'nav_settings_access_policy', type: 'url', label: 'Access policy', url: '/apps/setup/system/settings/access_policy', icon: 'shield-check' }, // Every entry below opening a settings page carries that page's hub // title, and every hub page has one (#27854): the sidebar and the // "All Settings" hub are two lists of one set of pages. Pinned against diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 544f5090b59..536335c440f 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -126,6 +126,7 @@ export const en: TranslationData = { nav_settings_sms: { label: 'SMS Delivery' }, nav_settings_branding: { label: 'Branding' }, nav_settings_auth: { label: 'Authentication' }, + nav_settings_access_policy: { label: 'Access policy' }, nav_settings_storage: { label: 'File Storage' }, nav_settings_ai: { label: 'AI & Embedder' }, nav_settings_knowledge: { label: 'Knowledge' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index da29be32475..3e62df24d86 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -85,6 +85,7 @@ export const esES: TranslationData = { nav_settings_sms: { label: 'Envío de SMS' }, nav_settings_branding: { label: 'Marca' }, nav_settings_auth: { label: 'Autenticación' }, + nav_settings_access_policy: { label: 'Política de acceso' }, nav_settings_storage: { label: 'Almacenamiento de archivos' }, nav_settings_ai: { label: 'IA y Embedder' }, nav_settings_knowledge: { label: 'Conocimiento' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 65e70c2774b..d20a49fdf08 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -85,6 +85,7 @@ export const jaJP: TranslationData = { nav_settings_sms: { label: 'SMS 配信' }, nav_settings_branding: { label: 'ブランディング' }, nav_settings_auth: { label: '認証' }, + nav_settings_access_policy: { label: 'アクセスポリシー' }, nav_settings_storage: { label: 'ファイルストレージ' }, nav_settings_ai: { label: 'AI と Embedder' }, nav_settings_knowledge: { label: 'ナレッジ' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 52a721fa15c..5b8e5dcc98a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -89,6 +89,7 @@ export const zhCN: TranslationData = { nav_settings_sms: { label: '短信投递' }, nav_settings_branding: { label: '品牌' }, nav_settings_auth: { label: '认证' }, + nav_settings_access_policy: { label: '访问策略' }, nav_settings_storage: { label: '文件存储' }, nav_settings_ai: { label: 'AI 与 Embedder' }, nav_settings_knowledge: { label: '知识库' }, diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index d4c1770418e..5725023818d 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -36,7 +36,7 @@ export const SysOrganization = ObjectSchema.create({ // D2 identity write guard clamps every user-context update on this table to // the registered column whitelist, `MANAGED_EXTENSION_EDITABLE_FIELDS // .sys_organization` in plugin-auth — the platform-owned extension columns - // (`require_mfa`, `parent_organization_id`, `sort_order`, `timezone`) and + // (`parent_organization_id`, `sort_order`, `timezone`) and // nothing else. Per D4's form-rendering constraint, every column outside that // whitelist is marked `readonly` below, so the edit form cannot offer a write // the server will refuse or strip. @@ -412,21 +412,23 @@ export const SysOrganization = ObjectSchema.create({ group: 'configuration', }), - // 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. 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). + // ADR-0069 D3 — the toggle-era per-org MFA column, RETIRED. An + // organization's multi-factor requirement has three levels now (ruling of + // record on objectstack-ai/cloud#3668, 「可以,按三档做(推荐)」) and lives in + // its access policy (`access_policy.mfa_required`, plugin-auth's + // `organization-access-policy.ts`). plugin-auth's boot move carries a + // stored `true` there as `everyone` and clears this column; nothing reads + // it otherwise, so it is readonly (the D2 guard strips a user-context + // write to it) and kept only for that move. require_mfa: Field.boolean({ - label: 'Require Multi-Factor Auth', + label: 'Require Multi-Factor Auth (retired)', required: false, + readonly: true, defaultValue: false, group: 'configuration', description: - 'Turn on to require every member of this organization to set up an authenticator app before they can access data. ' + - 'Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, ' + - 'saving this as on is refused.', + 'Retired. Set this organization\'s multi-factor requirement in Settings → Access policy. ' + + 'A value that was on here was moved there as "everyone" and cleared.', }), // #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the @@ -462,7 +464,7 @@ export const SysOrganization = ObjectSchema.create({ // registered in plugin-auth's `MANAGED_EXTENSION_FIELDS` (whose collision // guard proves better-auth's organization schema owns no `timezone` at the // pinned version) and generically editable under the ADR-0092 D2 whitelist - // (`MANAGED_EXTENSION_EDITABLE_FIELDS`) — the path `require_mfa` above and + // (`MANAGED_EXTENSION_EDITABLE_FIELDS`) — the path // the D6 group-structure fields below take. ⚠️ Same shape as // `sys_business_unit.timezone` by design (`text`, optional, `maxLength: 64`, // `valueDomain: 'iana_time_zone'`, no default): the card's thesis is that diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts index 4f72605433e..cda3194825f 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts @@ -260,11 +260,17 @@ export function allowedIpRangesRefusalCopy(locale: string | undefined): AllowedI } /** - * `auth.allowed_ip_ranges`'s settings write predicate. `manager` is read per - * write, so the address rule is the one in force when the save happens. + * `auth.allowed_ip_ranges`'s settings write predicate, and the same check on + * an organization's own list (`access_policy.allowed_ip_ranges`, + * `organization-access-policy.ts`). `manager` is read per write, so the + * address rule is the one in force when the save happens. `recovery: false` + * leaves out the env-override way back, which reopens the deployment's list + * only: an organization's list is reopened by the deployment's administrator, + * whom it never holds. */ export function allowedIpRangesWritePredicate( manager: () => Pick | null | undefined, + options: { recovery?: boolean } = {}, ): (input: unknown) => AllowedIpRangesWriteRefusal | undefined { return (input) => { // The settings write predicate's input (`SettingsWritePredicateInput`), read structurally. @@ -278,7 +284,7 @@ export function allowedIpRangesWritePredicate( if (address && ranges.some((range) => ipMatchesRange(address, range))) return undefined; const copy = allowedIpRangesRefusalCopy(locale); return { - message: (address ? copy.outside(address) : copy.undetermined) + copy.recovery, + message: (address ? copy.outside(address) : copy.undetermined) + (options.recovery === false ? '' : copy.recovery), constraint: { clientAddress: address ?? null }, }; }; diff --git a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts index 0e3ac141098..7adea600967 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts @@ -166,7 +166,13 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) { if (!succeeded) await this.auditRefusedSignIn(ctx, email, lockStamped); if (succeeded) { const uid = ctx?.context?.returned?.user?.id; - if (typeof uid === 'string') await this.enforceConcurrentCap(uid); + // [ADR-0069 TL;DR 6] The cap of the organization the new session + // was scoped to at creation (ADR-0093 D9), never above the + // deployment's. + const scopedOrg = ctx?.context?.newSession?.session?.activeOrganizationId; + if (typeof uid === 'string') { + await this.enforceConcurrentCap(uid, typeof scopedOrg === 'string' && scopedOrg ? scopedOrg : undefined); + } // ADR-0069 D7 — login audit: stamp last_login_at/ip on success. // Independent of lockout config (recordSignInOutcome no-ops when // lockout is off). The IP is the address `handleRequest` diff --git a/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts b/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts index 13a8ed725e9..7308ce60836 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts @@ -58,6 +58,7 @@ import { emitAuthSessionAuditEvent, loginEventFor, logoutEventFor } from './auth import { holdPrincipalSessionExpiry } from './principal-session-mint.js'; import { canonicalClientAddress } from './client-address-canonical.js'; import { readRequestClientAddress } from './request-client-address.js'; +import { SESSION_SCOPING_REFUSAL } from './organization-access-policy.js'; /** * The `AuthManager` members the functions below read — every one of them, and @@ -73,6 +74,7 @@ export interface DatabaseHooksHost { audienceLogError: AuthManager['audienceLogError']; getDataEngine: AuthManager['getDataEngine']; isCreatedInThisRequest: AuthManager['isCreatedInThisRequest']; + judgeSessionScoping: AuthManager['judgeSessionScoping']; linkingContext: AuthManager['linkingContext']; logDurabilityDegradation: AuthManager['logDurabilityDegradation']; settleMembership: AuthManager['settleMembership']; @@ -83,7 +85,7 @@ export interface DatabaseHooksHost { /** The body of `AuthManager#composeDatabaseHooks`; the method's docblock there is its contract. */ export function composeDatabaseHooks( - this: Pick, + this: Pick, host?: BetterAuthOptions['databaseHooks'], ): BetterAuthOptions['databaseHooks'] { // A landed link ends a standing unlink record (implicit-account-linking.ts, @@ -299,13 +301,32 @@ export function composeDatabaseHooks( const ipAddress = canonicalClientAddress((await readRequestClientAddress()) ?? derived); return ipAddress === derived ? session : { ...session, ipAddress }; }; + // [ADR-0069 TL;DR 6, D5] A session is scoped to an organization only from + // an address that organization's own allowed IP ranges admit + // (`organization-access-policy.ts`). Judged where a session gains an + // organization — its creation (the sign-in, after ADR-0093 D9 picked the + // organization above) and a switch of the active organization (below) — + // and only on a request that came through the HTTP auth door: its + // established address is in the request state, exactly the address the + // deployment's list judged at that door. A programmatic `auth.api.*` call + // carries no request and is not judged, as the door never judged it. + const refuseUnadmittedScoping = async (userId: unknown, organizationId: unknown, ctx: any) => { + if (!ctx?.request || typeof userId !== 'string' || !userId) return; + if (typeof organizationId !== 'string' || !organizationId) return; + const verdict = await this.judgeSessionScoping(userId, organizationId, await readRequestClientAddress()); + if (verdict === 'admitted') return; + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', { ...SESSION_SCOPING_REFUSAL }); + }; const sessionBefore = async (session: any, ctx: any) => { const captured = await captureSessionAddress(session); const result = sessionActiveOrgBefore ? await sessionActiveOrgBefore(captured, ctx) : undefined; if (result === false) return false; - if (result && typeof result === 'object' && 'data' in result) { - return { data: { ...captured, ...(result as any).data } }; - } + const final = result && typeof result === 'object' && 'data' in result + ? { ...captured, ...(result as any).data } + : captured; + await refuseUnadmittedScoping(final?.userId, final?.activeOrganizationId, ctx); + if (result && typeof result === 'object' && 'data' in result) return { data: final }; return captured === session ? result : { data: captured }; }; @@ -351,6 +372,16 @@ export function composeDatabaseHooks( draft = { ...draft, ...(hostResult as any).data }; } } + // A switch of the active organization re-scopes the session: judged as + // the scoping at creation is (above). + if (typeof draft?.activeOrganizationId === 'string' && draft.activeOrganizationId) { + const session = ctx?.context?.session; + await refuseUnadmittedScoping( + draft.userId ?? session?.session?.userId ?? session?.user?.id, + draft.activeOrganizationId, + ctx, + ); + } const held = holdPrincipalSessionExpiry(draft, ctx); if (held) return { data: held }; return draft === update ? undefined : { data: draft }; diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts index f7160fdba31..60af6a56201 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts @@ -46,6 +46,7 @@ import type { AuthManager } from './auth-manager.js'; import type { AuthGate } from '@objectstack/core'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; +import { effectiveMfaLevel, effectiveSessionLimits } from './organization-access-policy.js'; /** * The `AuthManager` members the functions below read — every one of them, and @@ -61,7 +62,10 @@ export interface PasswordSessionPolicyHost { _mustChangeRefreshing: AuthManager['_mustChangeRefreshing']; _orgMfaCache: AuthManager['_orgMfaCache']; _orgMfaRefreshing: AuthManager['_orgMfaRefreshing']; + anyOrganizationRequiresMfa: AuthManager['anyOrganizationRequiresMfa']; getDataEngine: AuthManager['getDataEngine']; + getOrganizationAccessPolicy: AuthManager['getOrganizationAccessPolicy']; + isMfaAdministrator: AuthManager['isMfaAdministrator']; isTwoFactorEnabled: AuthManager['isTwoFactorEnabled']; logDurabilityDegradation: AuthManager['logDurabilityDegradation']; noteMfaRequirementWithoutEnrollment: AuthManager['noteMfaRequirementWithoutEnrollment']; @@ -125,20 +129,17 @@ export function refreshMustChangeCacheIfStale(this: Pick): void { - if (this.config.mfaRequired === true) return; +export function refreshOrgMfaCacheIfStale(this: Pick): void { + // [ADR-0069 TL;DR 6] An organization can only strengthen the deployment's + // level, so at `everyone` there is nothing an organization adds. + if ((this.config.mfaRequirement ?? 'off') === 'everyone') return; if (this._orgMfaRefreshing) return; if (Date.now() - this._orgMfaCache.at < 60_000) return; - const engine = this.getDataEngine(); - if (!engine) return; this._orgMfaRefreshing = true; void (async () => { try { - const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; - const n = await engine.count('sys_organization', { - where: { require_mfa: true }, context: SYSTEM_CTX, - }); - this._orgMfaCache = { value: typeof n === 'number' && n > 0, at: Date.now() }; + const value = await this.anyOrganizationRequiresMfa(); + this._orgMfaCache = { value: value === true, at: Date.now() }; } catch { // leave the prior value; try again after the TTL } finally { @@ -149,7 +150,7 @@ export function refreshOrgMfaCacheIfStale(this: Pick, + this: Pick, userId: string, options: { organizationId?: string } = {}, ): Promise { @@ -160,13 +161,14 @@ export async function resolveUserAuthGates( this.refreshMustChangeCacheIfStale(); const activeOrgId = options.organizationId; const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0); - const mfaGlobal = this.config.mfaRequired === true; - // Per-org tightening: an org may require MFA above the global floor. - const orgMaybeRequires = !mfaGlobal && !!activeOrgId && this._orgMfaCache.value; + // [ADR-0069 D3, ruling 「可以,按三档做(推荐)」] The deployment's level is + // the floor; the session's organization may only strengthen it. + const deploymentLevel = this.config.mfaRequirement ?? 'off'; + const orgMaybeRequires = deploymentLevel !== 'everyone' && !!activeOrgId && this._orgMfaCache.value; // #2766 V1 — an admin-issued must-change-password flag activates the gate // even with every config feature off (per-user state, cached like org MFA). const mustChangeMaybe = this._mustChangeCache.value; - if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires && !mustChangeMaybe) { + if (expiryDays <= 0 && deploymentLevel === 'off' && !orgMaybeRequires && !mustChangeMaybe) { return []; // no gate feature active } const engine = this.getDataEngine(); @@ -180,14 +182,11 @@ export async function resolveUserAuthGates( context: SYSTEM_CTX, }); - // Effective MFA requirement: global floor OR the active org's require_mfa. - let mfaRequired = mfaGlobal; - if (!mfaRequired && orgMaybeRequires) { - const org = await engine.findOne('sys_organization', { - where: { id: activeOrgId }, fields: ['require_mfa'], context: SYSTEM_CTX, - }); - mfaRequired = org?.require_mfa === true || org?.require_mfa === 1; - } + // The level in force: the stronger of the deployment's and the active + // organization's own (`organization-access-policy.ts`). + const level = orgMaybeRequires + ? effectiveMfaLevel({ mfaRequirement: deploymentLevel }, await this.getOrganizationAccessPolicy(activeOrgId)) + : deploymentLevel; // ── Admin-issued force change (#2766 V1) ────────────────────────── // [#23287, ruling C′ item 2] Its OWN code now, scope `credential`: it @@ -230,6 +229,10 @@ export async function resolveUserAuthGates( // (`organization-mfa-enrollment-guard.ts`); this covers rows stored before // it, and an `OS_AUTH_TWO_FACTOR=false` pin under the global setting. const unenrolled = !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1); + // `administrators` holds ADR-0090 D12's three kinds and lets everyone else + // through; asked only for an unenrolled user, the one it can hold. + const mfaRequired = + level === 'everyone' || (level === 'administrators' && unenrolled && (await this.isMfaAdministrator(userId, activeOrgId))); if (mfaRequired && unenrolled && !this.isTwoFactorEnabled()) { this.noteMfaRequirementWithoutEnrollment(); } else if (mfaRequired && unenrolled) { @@ -612,9 +615,15 @@ function sessionHeartbeatMemoFor(engine: object): SessionHeartbeatMemo { } /** The body of `AuthManager#enforceSessionControls`; the method's docblock there is its contract. */ -export async function enforceSessionControls(this: Pick, sessionId: string | undefined, createdAtHint: unknown): Promise { - const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0); - const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0); +export async function enforceSessionControls(this: Pick, sessionId: string | undefined, createdAtHint: unknown, organizationId?: string): Promise { + // [ADR-0069 TL;DR 6] The limits in force for this session: the shorter of + // the deployment's and those of the organization it is scoped to. + const limits = effectiveSessionLimits( + this.config, + await this.getOrganizationAccessPolicy(organizationId), + ); + const idleMin = limits.idleMinutes; + const absHrs = limits.absoluteHours; // [#25343] Both limits off no longer returns here: `last_activity_at` is // "the last request on this session" on every deployment, not only where an // idle timeout reads it (ruling 6358844136, letter A). What both-off buys @@ -709,8 +718,10 @@ export async function enforceSessionControls(this: Pick, userId: string): Promise { - const cap = Math.floor(Number(this.config.maxConcurrentSessions) || 0); +export async function enforceConcurrentCap(this: Pick, userId: string, organizationId?: string): Promise { + // [ADR-0069 TL;DR 6] The cap in force: the smaller of the deployment's and + // that of the organization the new session is scoped to. + const cap = effectiveSessionLimits(this.config, await this.getOrganizationAccessPolicy(organizationId)).concurrent; if (cap <= 0 || !userId) return; const engine = this.getDataEngine(); if (!engine) return; diff --git a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts index 88eb257a895..e61b8ba0c4e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts @@ -986,7 +986,13 @@ export async function buildPluginList(this: PluginListHost): Promise { // the transport seams to enforce. See computeAuthGate(). // ADR-0069 D4 — session controls (idle / absolute). Best-effort; // revokes the session in place when exceeded (next request → 401). - await this.enforceSessionControls((session as any)?.id, (session as any)?.createdAt); + // [ADR-0069 TL;DR 6] Against the limits of the organization the session + // is scoped to, never below the deployment's. + await this.enforceSessionControls( + (session as any)?.id, + (session as any)?.createdAt, + (session as any)?.activeOrganizationId ?? undefined, + ); // An impersonation session carries the administrator who started it // (`impersonatedBy`, written by the admin plugin's impersonation and diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 20d509e0696..0f950bc9204 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -38,8 +38,42 @@ import { // [#25083] Is this process a cloud-managed (hosted) environment? The ONE // accessor every managed-mode judgement here asks, and nothing else. isCloudManaged, + resolveUserAuthzGrants, } from '@objectstack/core'; import type { AuthGate } from '@objectstack/core'; +import { + judgeSessionScopingAddress, + readOrganizationAccessPolicy, + type DeploymentAccessFloor, + type MfaRequirementLevel, + type OrganizationAccessPolicy, +} from './organization-access-policy.js'; + +/** + * [ADR-0069 TL;DR 6] Where `AuthManager` reads each organization's own access + * policy, supplied by `AuthPlugin` from the settings service + * ({@link AuthManager.setOrganizationAccessPolicySource}). + */ +export interface OrganizationAccessPolicySource { + /** The organization's resolved `access_policy` values, `key → value`. */ + read(organizationId: string): Promise | undefined>; + /** Does any organization set an `access_policy.mfa_required` level other than `off`? */ + anyOrganizationRequiresMfa(): Promise; + /** + * Does the user hold a delegated administrator's permission set (one with an + * `adminScope`, ADR-0090 D12) in the organization? `grants` are the position + * and permission-set names the user's grants resolve there. Absent: no delegated + * administrator is recognised. + */ + isDelegatedAdministrator?( + userId: string, + organizationId: string | undefined, + grants: { positions: readonly string[]; permissions: readonly string[] }, + ): Promise; +} + +/** How long one organization's access policy is kept before it is read again. */ +export const ORG_ACCESS_POLICY_TTL_MS = 30_000; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { ApiKeyLifecyclePolicy, @@ -1017,12 +1051,19 @@ export interface AuthManagerOptions extends Partial { passwordExpiryDays?: number; /** - * ADR-0069 D3 — enforced MFA. When true, an authenticated user without TOTP - * enrolled (`sys_user.two_factor_enabled`) is gated out of protected resources - * (`MFA_REQUIRED`) once their grace window elapses, until they enroll. Shares - * the `customSession` → `user.authGate` seam with password expiry. + * ADR-0069 D3 — enforced MFA, as one of three levels (ruling of record on + * objectstack-ai/cloud#3668, 「可以,按三档做(推荐)」): `off`; + * `administrators`, which holds ADR-0090 D12's tenant-level, + * organization-level and delegated administrators; `everyone`. A user the + * level holds who has no TOTP enrolled (`sys_user.two_factor_enabled`) is + * gated out of protected resources (`MFA_REQUIRED`) once their grace window + * elapses, until they enroll. Shares the `customSession` → `user.authGate` + * seam with password expiry. This is the deployment's level — the default + * and the floor of every organization's (`organization-access-policy.ts`). + * Bound from the `auth.mfa_required` setting. + * @default 'off' */ - mfaRequired?: boolean; + mfaRequirement?: MfaRequirementLevel; /** Days a user may defer MFA enrollment before the hard block. Default 7. */ mfaGracePeriodDays?: number; @@ -1619,6 +1660,15 @@ export class AuthManager { // Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap. private _orgMfaCache: { value: boolean; at: number } = { value: false, at: 0 }; private _orgMfaRefreshing = false; + // [ADR-0069 TL;DR 6] Where each organization's own access policy is read + // (`access_policy` settings, scope `tenant`), set by `AuthPlugin` once the + // settings service is bound; unset, every organization reads as setting + // nothing and the deployment's values alone hold. + private orgAccessPolicySource: OrganizationAccessPolicySource | undefined; + // Per-organization policy, read through `orgAccessPolicySource`, kept for + // ORG_ACCESS_POLICY_TTL_MS and dropped whenever the namespace changes. + private readonly _orgAccessPolicyCache = new Map(); + private _orgAccessPolicyReadFailureNoted = false; // #2766 V1 — cached "does any user have must_change_password set" flag, so // the admin-issued temp-password gate activates without making // isAuthGateActive() (and thus every request's extra session read) hot in @@ -5215,11 +5265,12 @@ export class AuthManager { // [#25224 cut 4] The body moved to `auth-manager-database-hooks.ts` (see `DatabaseHooksHost`). const readConfig = () => this.config; const readIdpLocale = () => this.idpLocale; - const hookHost: Pick = { + const hookHost: Pick = { audienceLogError: (...a) => this.audienceLogError(...a), get config() { return readConfig(); }, get idpLocale() { return readIdpLocale(); }, isCreatedInThisRequest: (...a) => this.isCreatedInThisRequest(...a), + judgeSessionScoping: (...a) => this.judgeSessionScoping(...a), linkingContext: (...a) => this.linkingContext(...a), settleMembership: (...a) => this.settleMembership(...a), settleSelfRegistrationGrant: (...a) => this.settleSelfRegistrationGrant(...a), @@ -5346,7 +5397,7 @@ export class AuthManager { this.refreshMustChangeCacheIfStale(); return ( Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || - this.config.mfaRequired === true || + (this.config.mfaRequirement ?? 'off') !== 'off' || this._orgMfaCache.value || this._mustChangeCache.value ); @@ -5390,7 +5441,7 @@ export class AuthManager { /** * [#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 + * (the deployment's `mfaRequirement` or their organization's `access_policy.mfa_required`) that it * does not enforce, because the twoFactor plugin is off and the user has no * way to enroll. A functional degradation (AGENTS.md, degradation log * levels): the requirement is visibly not in force, nothing stored is lost. @@ -5401,11 +5452,11 @@ export class AuthManager { this._mfaWithoutEnrollmentNoted = true; this.config.logger?.warn( '[auth] multi-factor authentication is required (the auth.mfa_required setting, or an organization\'s ' + - 'require_mfa) but is NOT enforced: the two-factor feature is off, so nobody can enroll an authenticator ' + + 'access_policy.mfa_required) but is NOT enforced: the two-factor feature is off, so nobody can enroll an authenticator ' + 'app, and holding them would lock every affected member out, the owner included. Remedy: set ' + 'OS_AUTH_TWO_FACTOR=true; or, unless OS_AUTH_TWO_FACTOR is set to false (it overrides the settings), turn ' + - 'on the auth.mfa_required setting, which turns two-factor on with the requirement; or clear require_mfa on ' + - 'the organization.', + 'on the auth.mfa_required setting, which turns two-factor on with the requirement; or set the organization\'s ' + + 'access_policy.mfa_required back to off.', ); } @@ -5486,13 +5537,13 @@ export class AuthManager { const readOrgMfaRefreshing = () => this._orgMfaRefreshing; const writeOrgMfaRefreshing = (v: AuthManager['_orgMfaRefreshing']) => { this._orgMfaRefreshing = v; }; const readConfig = () => this.config; - const host: Pick = { + const host: Pick = { get _orgMfaCache() { return readOrgMfaCache(); }, set _orgMfaCache(v) { writeOrgMfaCache(v); }, get _orgMfaRefreshing() { return readOrgMfaRefreshing(); }, set _orgMfaRefreshing(v) { writeOrgMfaRefreshing(v); }, + anyOrganizationRequiresMfa: (...a) => this.anyOrganizationRequiresMfa(...a), get config() { return readConfig(); }, - getDataEngine: (...a) => this.getDataEngine(...a), }; return refreshOrgMfaCacheIfStale.call(host); } @@ -5617,11 +5668,13 @@ export class AuthManager { const readMustChangeCache = () => this._mustChangeCache; const readOrgMfaCache = () => this._orgMfaCache; const readConfig = () => this.config; - const host: Pick = { + const host: Pick = { get _mustChangeCache() { return readMustChangeCache(); }, get _orgMfaCache() { return readOrgMfaCache(); }, get config() { return readConfig(); }, getDataEngine: (...a) => this.getDataEngine(...a), + getOrganizationAccessPolicy: (...a) => this.getOrganizationAccessPolicy(...a), + isMfaAdministrator: (...a) => this.isMfaAdministrator(...a), isTwoFactorEnabled: (...a) => this.isTwoFactorEnabled(...a), noteMfaRequirementWithoutEnrollment: (...a) => this.noteMfaRequirementWithoutEnrollment(...a), refreshMustChangeCacheIfStale: (...a) => this.refreshMustChangeCacheIfStale(...a), @@ -5981,15 +6034,20 @@ export class AuthManager { * limits or not; with both off, a per-process 60-second memo per session * skips the `sys_session` read too. Best-effort — never throws. */ - private enforceSessionControls(sessionId: string | undefined, createdAtHint: unknown): Promise { + private enforceSessionControls( + sessionId: string | undefined, + createdAtHint: unknown, + organizationId?: string, + ): Promise { // [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`). const readConfig = () => this.config; - const host: Pick = { + const host: Pick = { get config() { return readConfig(); }, getDataEngine: (...a) => this.getDataEngine(...a), + getOrganizationAccessPolicy: (...a) => this.getOrganizationAccessPolicy(...a), logDurabilityDegradation: (...a) => this.logDurabilityDegradation(...a), }; - return enforceSessionControls.call(host, sessionId, createdAtHint); + return enforceSessionControls.call(host, sessionId, createdAtHint, organizationId); } /** @@ -5997,15 +6055,162 @@ export class AuthManager { * Keeps the newest `maxConcurrentSessions` live sessions for the user and * revokes the rest (oldest first). No-op when off. Best-effort. */ - private enforceConcurrentCap(userId: string): Promise { + private enforceConcurrentCap(userId: string, organizationId?: string): Promise { // [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`). const readConfig = () => this.config; - const host: Pick = { + const host: Pick = { get config() { return readConfig(); }, getDataEngine: (...a) => this.getDataEngine(...a), + getOrganizationAccessPolicy: (...a) => this.getOrganizationAccessPolicy(...a), logDurabilityDegradation: (...a) => this.logDurabilityDegradation(...a), }; - return enforceConcurrentCap.call(host, userId); + return enforceConcurrentCap.call(host, userId, organizationId); + } + + /** + * @internal Package-internal: set by `AuthPlugin` once the settings service + * is bound; not a supported entry point. + * + * [ADR-0069 TL;DR 6] Where each organization's own access policy is read. + * Setting a source drops every cached policy. + */ + public setOrganizationAccessPolicySource(source: OrganizationAccessPolicySource | undefined): void { + this.orgAccessPolicySource = source; + this.clearOrganizationAccessPolicyCache(); + } + + /** + * @internal Package-internal: called by `AuthPlugin` when the + * `access_policy` settings namespace changes. + * + * Drops every cached organization policy and the "any organization requires + * MFA" flag, so the next request reads what was saved. + */ + public clearOrganizationAccessPolicyCache(): void { + this._orgAccessPolicyCache.clear(); + this._orgMfaCache = { value: this._orgMfaCache.value, at: 0 }; + } + + /** + * @internal Package-internal: read by the policy bodies and by the + * `access_policy` save checks; not a supported entry point. + * + * The deployment's access-policy values — the floor every organization's + * policy is held to (`organization-access-policy.ts`) — as the settings + * pass applied them. Read per call. + */ + public getDeploymentAccessFloor(): DeploymentAccessFloor { + return { + allowedIpRanges: this.config.allowedIpRanges ?? [], + sessionIdleTimeoutMinutes: this.config.sessionIdleTimeoutMinutes ?? 0, + sessionAbsoluteMaxHours: this.config.sessionAbsoluteMaxHours ?? 0, + maxConcurrentSessions: this.config.maxConcurrentSessions ?? 0, + mfaRequirement: this.config.mfaRequirement ?? 'off', + }; + } + + /** + * @internal Package-internal: read by the policy bodies and the session + * scoping hook; not a supported entry point. + * + * [ADR-0069 TL;DR 6] One organization's own access policy + * ({@link OrganizationAccessPolicy}), read through the source `AuthPlugin` + * set and kept for {@link ORG_ACCESS_POLICY_TTL_MS}. No organization, or no + * source, reads as an organization that sets nothing. A read that fails + * keeps the last policy read for that organization, or nothing beyond the + * deployment's when there is none, and says so once: the deployment's + * policy, the floor, is still enforced either way. + */ + public async getOrganizationAccessPolicy(organizationId: string | null | undefined): Promise { + const source = this.orgAccessPolicySource; + if (!organizationId || !source) return readOrganizationAccessPolicy(undefined); + const cached = this._orgAccessPolicyCache.get(organizationId); + if (cached && Date.now() - cached.at < ORG_ACCESS_POLICY_TTL_MS) return cached.policy; + try { + const policy = readOrganizationAccessPolicy(await source.read(organizationId)); + this._orgAccessPolicyCache.set(organizationId, { policy, at: Date.now() }); + return policy; + } catch (err) { + if (!this._orgAccessPolicyReadFailureNoted) { + this._orgAccessPolicyReadFailureNoted = true; + this.config.logger?.warn( + '[auth] an organization\'s access policy (the access_policy settings) could not be read, so its own ' + + 'IP ranges, session limits and multi-factor level are not applied while the read fails; the ' + + 'deployment\'s values (the auth settings) are still enforced. Remedy: check the settings store; the ' + + `read is retried on the next request. (${String((err as Error)?.message ?? err)})`, + ); + } + return cached?.policy ?? readOrganizationAccessPolicy(undefined); + } + } + + /** + * @internal Package-internal: asked by the session hooks + * (`auth-manager-database-hooks.ts`); not a supported entry point. + * + * [ADR-0069 TL;DR 6, D5] May a session of `userId` be scoped to + * `organizationId` from `address` (the address the server established for + * the request, `undefined` when it established none)? The organization's own + * allowed IP ranges decide, and a tenant-level administrator is never held by + * them (break-glass): {@link judgeSessionScopingAddress}. + */ + public async judgeSessionScoping( + userId: string, + organizationId: string, + address: string | undefined, + ): Promise<'admitted' | 'refused'> { + return judgeSessionScopingAddress({ + address, + organization: await this.getOrganizationAccessPolicy(organizationId), + isPlatformAdmin: () => this.isPlatformAdminUserId(userId), + }); + } + + /** + * @internal Package-internal: read by the "any organization requires MFA" + * cache refresh; not a supported entry point. + */ + public async anyOrganizationRequiresMfa(): Promise { + const source = this.orgAccessPolicySource; + if (!source) return false; + return source.anyOrganizationRequiresMfa(); + } + + /** + * @internal Package-internal: read by the MFA gate at the `administrators` + * level; not a supported entry point. + * + * Is `userId` an administrator in `organizationId`, as ADR-0090 D12 (as + * amended 2026-10-17) names the three kinds — through the predicates that + * already decide each, never a new concept: + * + * 1. tenant-level — the `PLATFORM_ADMIN` posture rung; + * 2. organization-level — the `TENANT_ADMIN` rung, which the + * `organization_admin` grant resolves, conferred by an owner or admin + * membership of that organization; + * 3. delegated — a permission set carrying an `adminScope`, decided by the + * security plugin's own resolution of the user's sets + * ({@link OrganizationAccessPolicySource.isDelegatedAdministrator}). + * + * The two rungs are `resolveUserAuthzGrants`' (`@objectstack/core`), the + * session payload's own reading. A delegated check that fails counts as an + * administrator: the requirement holds rather than lapses on an unreadable + * grant. + */ + public async isMfaAdministrator(userId: string, organizationId: string | undefined): Promise { + const engine = this.getDataEngine(); + if (!engine) return false; + const grants = await resolveUserAuthzGrants(engine as any, userId, { + ...(organizationId ? { tenantId: organizationId } : {}), + }); + if (grants.posture === 'PLATFORM_ADMIN' || grants.posture === 'TENANT_ADMIN') return true; + const delegated = this.orgAccessPolicySource?.isDelegatedAdministrator; + if (!delegated) return false; + try { + return await delegated(userId, organizationId, { positions: grants.positions, permissions: grants.permissions }); + } catch { + return true; + } } /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index c438d171ff4..29ecea93823 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -68,7 +68,8 @@ import { } from './identity-write-guard.js'; import { registerLastAdminGuard } from './last-admin-guard.js'; import { registerApiKeyRestoreGuard } from './api-key-restore-guard.js'; -import { registerOrganizationMfaEnrollmentGuard } from './organization-mfa-enrollment-guard.js'; +import { bindOrganizationAccessPolicy } from './organization-access-policy-binding.js'; +import { MFA_REQUIREMENT_LEVELS, readMfaRequirementLevel } from './organization-access-policy.js'; import { registerMfaRequiredEnrollmentPredicate } from './mfa-required-enrollment-predicate.js'; import { registerPersonalViewHooks } from './personal-view-hooks.js'; import { registerUserPreferenceHooks } from './user-preference-hooks.js'; @@ -559,6 +560,8 @@ export class AuthPlugin implements Plugin { * present. */ private allowListUndeterminedWarned = false; + /** Set once the settings pass has said that `auth.mfa_required` reads a toggle spelling. */ + private legacyMfaRequiredNoted = false; private allowListUntrustedHeaderWarned = false; /** * [#30203, #30312] What the deployment itself declared (plugin options and @@ -1951,17 +1954,6 @@ export class AuthPlugin implements Plugin { packageId: 'com.objectstack.plugin-auth.api-key-restore-guard', logger: ctx.logger, }); - // [#30201] ADR-0069 D3 — an organization's `require_mfa` is refused - // while nobody can enroll a second factor, the pairing the global - // `auth.mfa_required` switch gets from its settings piece below. Asks - // the live manager per write. See organization-mfa-enrollment-guard.ts. - 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. registerPersonalViewHooks(engine, { @@ -2342,15 +2334,38 @@ export class AuthPlugin implements Plugin { // 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). + // + // [ruling 「可以,按三档做(推荐)」] Three levels: `off`, + // `administrators`, `everyone`. Either level that requires anything is + // one piece with the enrollment path. A toggle spelling can only come + // from the env override (a stored toggle is rewritten to a level at + // boot, `migrateLegacyMfaRequiredSetting`); it keeps its meaning and is + // reported once. A value that is no level keeps the level in force. if (isExplicit('mfa_required')) { - const on = asBoolean(values.mfa_required, false); - if (on) { - refusable.push({ - key: 'mfa_required', - patch: { mfaRequired: true, plugins: { twoFactor: true } as AuthManagerOptions['plugins'] }, - }); + const read = readMfaRequirementLevel(values.mfa_required); + if (!read) { + ctx.logger.error( + `[auth] mfa_required '${String(values.mfa_required)}' is not a multi-factor requirement level — IGNORED, ` + + `the deployment keeps its current level ('${authManager.getDeploymentAccessFloor().mfaRequirement}'). ` + + `Set auth.mfa_required (or OS_AUTH_MFA_REQUIRED) to one of: ${MFA_REQUIREMENT_LEVELS.join(', ')}.`, + ); } else { - patch.mfaRequired = false; + if (read.legacy && !this.legacyMfaRequiredNoted) { + this.legacyMfaRequiredNoted = true; + ctx.logger.warn( + `[auth] auth.mfa_required reads '${String(values.mfa_required)}', a value from before the setting ` + + `had levels; it is read as '${read.level}'. Set OS_AUTH_MFA_REQUIRED (or the setting) to one of ` + + `${MFA_REQUIREMENT_LEVELS.join(', ')}: the toggle spelling is accepted for one major release.`, + ); + } + if (read.level !== 'off') { + refusable.push({ + key: 'mfa_required', + patch: { mfaRequirement: read.level, plugins: { twoFactor: true } as AuthManagerOptions['plugins'] }, + }); + } else { + patch.mfaRequirement = 'off'; + } } } if (isExplicit('mfa_grace_period_days')) { @@ -2529,16 +2544,17 @@ export class AuthPlugin implements Plugin { sessionRestoring = true; restoredInPatch.push(key); break; - case 'mfaRequired': - // As a save of the declared value: on is one piece with its - // enrollment path; off leaves enrolled factors verifiable. - if (declared.mfaRequired === true) { + case 'mfaRequirement': + // As a save of the declared value: a level that requires anything + // is one piece with its enrollment path; off leaves enrolled + // factors verifiable. + if (declared.mfaRequirement && declared.mfaRequirement !== 'off') { refusable.push({ key, - patch: { mfaRequired: true, plugins: { twoFactor: true } as AuthManagerOptions['plugins'] }, + patch: { mfaRequirement: declared.mfaRequirement, plugins: { twoFactor: true } as AuthManagerOptions['plugins'] }, }); } else { - patch.mfaRequired = declared.mfaRequired; + patch.mfaRequirement = declared.mfaRequirement; restoredInPatch.push(key); } break; @@ -2754,6 +2770,11 @@ export class AuthPlugin implements Plugin { // See mfa-required-enrollment-predicate.ts. registerMfaRequiredEnrollmentPredicate(settings, ctx.logger); + // [ADR-0069 TL;DR 6] Each organization's own access policy: the reader the + // manager asks per request, the save checks that hold it to the floor, and + // the one-off moves of the toggle-era values onto the levels. + await this.bindOrganizationAccessPolicy(ctx, settings); + await applySettings(); if (typeof settings.subscribe === 'function') { settings.subscribe('auth', () => { @@ -2763,6 +2784,41 @@ export class AuthPlugin implements Plugin { } } + /** + * [ADR-0069 TL;DR 6] Each organization's own access policy: the one-off + * moves of the toggle-era MFA values, the per-request reader, the save + * checks and the change listener. See organization-access-policy-binding.ts. + */ + private async bindOrganizationAccessPolicy(ctx: PluginContext, settings: SettingsReadSurface): Promise { + if (!this.authManager) return; + const manager = this.authManager; + try { + await bindOrganizationAccessPolicy({ + settings, + manager, + engine: () => manager.getDataEngine(), + security: () => { + try { + return ctx.getService('security'); + } catch { + return undefined; + } + }, + logger: { + info: (m) => ctx.logger.info(m), + warn: (m) => ctx.logger.warn(m), + error: (m) => ctx.logger.error(m), + }, + }); + } catch (err: any) { + ctx.logger.error( + `[auth] organization access policies are NOT bound (${String(err?.message ?? err)}): each organization's own ` + + 'IP ranges, session limits and multi-factor level are not applied, while the deployment\'s values (the ' + + 'auth settings) still are. Remedy: restore the settings service and restart.', + ); + } + } + async destroy(): Promise { // Cleanup if needed this.authManager = null; diff --git a/packages/plugins/plugin-auth/src/auth-settings-declared.ts b/packages/plugins/plugin-auth/src/auth-settings-declared.ts index 83bd2012f0c..7019456c248 100644 --- a/packages/plugins/plugin-auth/src/auth-settings-declared.ts +++ b/packages/plugins/plugin-auth/src/auth-settings-declared.ts @@ -36,7 +36,7 @@ const DECLARED_FIELDS = [ 'passwordMinClasses', 'passwordHistoryCount', 'passwordExpiryDays', - 'mfaRequired', + 'mfaRequirement', 'mfaGracePeriodDays', 'apiKeyDefaultExpiryDays', 'apiKeyMaxExpiryDays', @@ -88,7 +88,7 @@ export type AuthSettingRestore = /** A field of `session`, merged over the declared `session` object. */ | { readonly via: 'session'; readonly field: 'expiresIn' | 'updateAge' } /** Enforced MFA: restored exactly as a save of the declared value would apply. */ - | { readonly via: 'mfaRequired' } + | { readonly via: 'mfaRequirement' } /** One runtime value ruled by several keys: restored whole (see the module note for when). */ | { readonly via: 'group'; readonly group: 'rateLimit' | 'google' | 'audience' }; @@ -109,7 +109,7 @@ export const AUTH_SETTING_RESTORE: Readonly> password_min_classes: { via: 'patch', field: 'passwordMinClasses' }, password_history_count: { via: 'patch', field: 'passwordHistoryCount' }, password_expiry_days: { via: 'patch', field: 'passwordExpiryDays' }, - mfa_required: { via: 'mfaRequired' }, + mfa_required: { via: 'mfaRequirement' }, mfa_grace_period_days: { via: 'patch', field: 'mfaGracePeriodDays' }, api_key_default_expiry_days: { via: 'patch', field: 'apiKeyDefaultExpiryDays' }, api_key_max_expiry_days: { via: 'patch', field: 'apiKeyMaxExpiryDays' }, diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts index 1d3b5650cd0..fa82c479c22 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -85,7 +85,12 @@ export const MANAGED_EXTENSION_FIELDS: Readonly boolean = twoFactorEnrollableWithSwitchOn, ): (input: MfaRequiredWriteInput) => MfaRequiredWriteRefusal | undefined { return ({ next, current, locale }) => { - if (!readToggle(next) || readToggle(current)) return undefined; + // A save that turns a requirement on — from `off` to either level. A move + // between the two levels turns nothing on that is not already on. + if (!namesRequirement(next) || namesRequirement(current)) return undefined; if (enrollable()) return undefined; return { message: mfaRequiredEnrollmentCopy(locale), diff --git a/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts b/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts new file mode 100644 index 00000000000..a7c46891460 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts @@ -0,0 +1,273 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0069 TL;DR 6] Wires each organization's own access policy + * (`organization-access-policy.ts` holds the rule) into a running deployment: + * + * 1. the one-off moves of the two toggle-era values onto the three levels + * (ruling 「可以,按三档做(推荐)」, objectstack-ai/cloud#3668), run before + * the settings pass so the pass reads levels: + * - a stored `auth.mfa_required` toggle becomes `everyone` (on) or `off`; + * - an organization whose `sys_organization.require_mfa` is on gets + * `access_policy.mfa_required = everyone`, unless it already set a level, + * and its column is cleared once that row has landed; + * each is convergent and idempotent: a second boot finds nothing to move, + * and a move that fails is retried at the next boot, its source untouched; + * 2. the reader the manager asks per request + * (`AuthManager.setOrganizationAccessPolicySource`); + * 3. the save checks of every `access_policy` key — the floor + * (`accessPolicyFloorRefusal`), the self-lock-out check of the + * organization's own IP list (the deployment list's check, per + * organization), and the multi-factor enrollment check; + * 4. a change listener that drops the manager's cached policies. + * + * A settings service without the `access_policy` namespace (a host that + * registers its own manifest set) has no organization policy to read or save: + * that is said once at `info`, and the deployment's values alone hold. + */ + +import type { AuthManager } from './auth-manager.js'; +import { allowedIpRangesWritePredicate } from './allowed-ip-ranges.js'; +import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js'; +import { + ACCESS_POLICY_KEYS, + ACCESS_POLICY_NAMESPACE, + accessPolicyWritePredicate, + isMfaRequirementLevel, + readMfaRequirementLevel, +} from './organization-access-policy.js'; +import { organizationMfaEnrollmentPredicate } from './organization-mfa-enrollment-guard.js'; + +/** The settings service as this binding reads it — structural, like `SettingsReadSurface` in `auth-plugin.ts`. */ +export interface AccessPolicySettingsSurface { + get(namespace: string, key: string, ctx?: Record): Promise<{ value?: unknown; source?: string } | undefined>; + getNamespace( + namespace: string, + ctx?: Record, + ): Promise<{ values: Record }>; + set?(namespace: string, key: string, value: unknown, ctx?: Record): Promise; + subscribe?(namespace: string | undefined, handler: (...args: any[]) => void): unknown; + registerWritePredicate?( + namespace: string, + key: string, + predicate: (input: unknown) => unknown, + options?: { requestHeaders?: readonly string[] }, + ): void; +} + +/** The `security` service member the delegated-administrator check asks (plugin-security). */ +interface SecuritySetResolver { + resolvePermissionSetsForContext(context: Record): Promise; +} + +export interface OrganizationAccessPolicyBindingDeps { + settings: AccessPolicySettingsSurface; + manager: AuthManager; + /** The data engine, for the cross-organization MFA probe and the column move. */ + engine: () => any; + /** The `security` service, when plugin-security is composed. */ + security: () => SecuritySetResolver | undefined; + logger: { info(message: string): void; warn(message: string): void; error(message: string): void }; +} + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +/** The legacy organization column the multi-factor level replaced. */ +export const LEGACY_REQUIRE_MFA_FIELD = 'require_mfa'; + +/** Does a permission set (as `resolvePermissionSetsForContext` answers it) carry an ADR-0090 D12 `adminScope`? */ +function carriesAdminScope(set: unknown): boolean { + const record = set as { adminScope?: unknown; admin_scope?: unknown } | null; + let raw = record?.adminScope ?? record?.admin_scope; + if (typeof raw === 'string') { + try { + raw = JSON.parse(raw); + } catch { + return false; + } + } + return !!raw && typeof raw === 'object' && typeof (raw as { businessUnit?: unknown }).businessUnit === 'string'; +} + +/** + * The deployment's stored `auth.mfa_required` toggle, rewritten as a level. + * An env-sourced value is not a stored row and is left to the settings pass, + * which reads its toggle spelling and says so. + */ +export async function migrateLegacyMfaRequiredSetting(deps: OrganizationAccessPolicyBindingDeps): Promise<'moved' | 'none'> { + const { settings, logger } = deps; + if (typeof settings.set !== 'function') return 'none'; + let resolved: { value?: unknown; source?: string } | undefined; + try { + resolved = await settings.get('auth', 'mfa_required'); + } catch { + return 'none'; + } + if (!resolved || resolved.source === 'env' || resolved.source === 'default') return 'none'; + const read = readMfaRequirementLevel(resolved.value); + if (!read || !read.legacy) return 'none'; + try { + await settings.set('auth', 'mfa_required', read.level, {}); + logger.info(`[auth] auth.mfa_required: the stored toggle (${String(resolved.value)}) is now the level '${read.level}'.`); + return 'moved'; + } catch (err: any) { + logger.error( + `[auth] auth.mfa_required: the stored toggle (${String(resolved.value)}) was NOT rewritten as the level ` + + `'${read.level}' (${String(err?.message ?? err)}). The settings pass still reads it as '${read.level}', so ` + + 'the requirement stays in force, but the Authentication settings page cannot show it as a level. Remedy: ' + + `save auth.mfa_required as '${read.level}'; the move is retried at the next boot.`, + ); + return 'none'; + } +} + +/** + * Every organization whose legacy `require_mfa` column is on gets + * `access_policy.mfa_required = everyone` — the requirement it had — unless it + * already set a level, and then has the column cleared. The column is cleared + * only after the settings row landed, so a failed move leaves the source for + * the next boot, and is reported at `error`: until it lands, that + * organization's members are held only by the deployment's level. + */ +export async function migrateLegacyOrganizationRequireMfa( + deps: OrganizationAccessPolicyBindingDeps, +): Promise<{ moved: number; failed: number }> { + const { settings, logger } = deps; + const out = { moved: 0, failed: 0 }; + const engine = deps.engine(); + if (!engine || typeof engine.find !== 'function' || typeof settings.set !== 'function') return out; + let rows: any[] = []; + try { + rows = await engine.find('sys_organization', { + where: { [LEGACY_REQUIRE_MFA_FIELD]: true }, + fields: ['id', LEGACY_REQUIRE_MFA_FIELD], + limit: 5000, + context: SYSTEM_CTX, + }); + } catch { + return out; // no organization table, or no such column: nothing to move + } + for (const row of Array.isArray(rows) ? rows : []) { + const organizationId = row?.id; + if (typeof organizationId !== 'string' || !organizationId) continue; + if (!(row[LEGACY_REQUIRE_MFA_FIELD] === true || row[LEGACY_REQUIRE_MFA_FIELD] === 1)) continue; + try { + const current = await settings.get(ACCESS_POLICY_NAMESPACE, ACCESS_POLICY_KEYS.mfaRequired, { tenantId: organizationId }); + const alreadySet = current?.source && current.source !== 'default' && isMfaRequirementLevel(current.value); + if (!alreadySet) { + await settings.set(ACCESS_POLICY_NAMESPACE, ACCESS_POLICY_KEYS.mfaRequired, 'everyone', { tenantId: organizationId }); + } + await engine.update( + 'sys_organization', + { id: organizationId, [LEGACY_REQUIRE_MFA_FIELD]: false }, + { context: SYSTEM_CTX }, + ); + out.moved += 1; + } catch (err: any) { + out.failed += 1; + logger.error( + `[auth] organization ${organizationId}: its "Require Multi-Factor Auth" setting was NOT moved to its access ` + + `policy (${String(err?.message ?? err)}). Until it is, its members are held only by the deployment's ` + + 'multi-factor level, and nothing else looks wrong. Remedy: set the organization\'s Access policy → ' + + 'Multi-factor requirement to "everyone"; the move is retried at the next boot.', + ); + } + } + if (out.moved > 0) { + logger.info( + `[auth] ${out.moved} organization(s) that required multi-factor authentication now carry it as their access ` + + 'policy\'s multi-factor requirement "everyone".', + ); + } + return out; +} + +/** Bind the organization access policy; see the module header. */ +export async function bindOrganizationAccessPolicy(deps: OrganizationAccessPolicyBindingDeps): Promise { + const { settings, manager, logger } = deps; + + // The deployment's own toggle moves even where organizations set nothing. + await migrateLegacyMfaRequiredSetting(deps); + + try { + await settings.getNamespace(ACCESS_POLICY_NAMESPACE); + } catch { + logger.info( + `[auth] the settings service registers no '${ACCESS_POLICY_NAMESPACE}' namespace, so organizations set no ` + + 'access policy of their own; the deployment\'s values (the auth settings) apply to every organization.', + ); + return; + } + + await migrateLegacyOrganizationRequireMfa(deps); + + manager.setOrganizationAccessPolicySource({ + read: async (organizationId) => { + const payload = await settings.getNamespace(ACCESS_POLICY_NAMESPACE, { tenantId: organizationId }); + const values: Record = {}; + for (const [key, entry] of Object.entries(payload.values ?? {})) values[key] = entry?.value; + return values; + }, + anyOrganizationRequiresMfa: async () => { + const engine = deps.engine(); + if (!engine || typeof engine.find !== 'function') return false; + const rows = await engine.find('sys_setting', { + where: { namespace: ACCESS_POLICY_NAMESPACE, key: ACCESS_POLICY_KEYS.mfaRequired }, + fields: ['value'], + limit: 10000, + context: SYSTEM_CTX, + }); + return (Array.isArray(rows) ? rows : []).some((row: any) => { + let value = row?.value; + if (typeof value === 'string' && value.startsWith('"')) { + try { + value = JSON.parse(value); + } catch { + /* the raw text is judged below */ + } + } + return isMfaRequirementLevel(value) && value !== 'off'; + }); + }, + isDelegatedAdministrator: async (userId, organizationId, grants) => { + const security = deps.security(); + if (typeof security?.resolvePermissionSetsForContext !== 'function') return false; + const sets = await security.resolvePermissionSetsForContext({ + userId, + ...(organizationId ? { tenantId: organizationId } : {}), + positions: [...grants.positions], + permissions: [...grants.permissions], + }); + return (Array.isArray(sets) ? sets : []).some(carriesAdminScope); + }, + }); + + if (typeof settings.registerWritePredicate === 'function') { + const floor = () => manager.getDeploymentAccessFloor(); + const selfLockOut = allowedIpRangesWritePredicate(() => manager, { recovery: false }); + const enrollment = organizationMfaEnrollmentPredicate(() => manager.isTwoFactorEnabled()); + for (const key of Object.values(ACCESS_POLICY_KEYS)) { + const base = accessPolicyWritePredicate(floor, key === ACCESS_POLICY_KEYS.allowedIpRanges ? selfLockOut : undefined); + const predicate = + key === ACCESS_POLICY_KEYS.mfaRequired ? (input: unknown) => base(input) ?? enrollment(input) : base; + try { + settings.registerWritePredicate( + ACCESS_POLICY_NAMESPACE, + key, + predicate, + key === ACCESS_POLICY_KEYS.allowedIpRanges ? { requestHeaders: FORWARDED_CLIENT_ADDRESS_HEADERS } : {}, + ); + } catch (err: any) { + logger.warn( + `[auth] ${ACCESS_POLICY_NAMESPACE}.${key}: the save-time check was NOT registered ` + + `(${String(err?.message ?? err)}), so an organization can save a value weaker than the deployment's. ` + + 'The deployment\'s value is still enforced: the weaker value is not honoured.', + ); + } + } + } + + if (typeof settings.subscribe === 'function') { + settings.subscribe(ACCESS_POLICY_NAMESPACE, () => manager.clearOrganizationAccessPolicyCache()); + } +} diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.ts b/packages/plugins/plugin-auth/src/organization-access-policy.ts new file mode 100644 index 00000000000..bb98d70d563 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-access-policy.ts @@ -0,0 +1,444 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0069 TL;DR 6, D3, D4, D5] The access policy, per organization: the + * allowed IP ranges, the idle, absolute and concurrent session limits, and the + * multi-factor requirement, each set by an organization in the + * `access_policy` settings namespace (scope `tenant`: one row per + * organization, ADR-0131) on top of the deployment's own value in the `auth` + * namespace (scope `global`). + * + * Rulings of record (objectstack-ai/cloud#3668): 「支持按组织(推荐)」, so the + * policy is set per organization; and 「可以,按三档做(推荐)」, so the + * multi-factor requirement has three levels, `off`, `administrators` and + * `everyone`, where "administrators" are ADR-0090 D12's three kinds. + * + * ## The deployment value is the default AND the floor + * + * An organization may tighten its policy and never loosen it. Each key's zero + * value — an empty list, `0`, `off` — means "nothing beyond the deployment's": + * an organization that sets nothing is held to the deployment's value, which is + * the default. Every other value is judged twice: + * + * - at the save, by {@link accessPolicyFloorRefusal}: a value weaker than the + * deployment's is refused, because honoured it would loosen the floor and + * ignored it would read as saved while doing nothing; + * - at enforcement, by the combining rules below, which hold the floor + * whatever is stored — a deployment that tightens later is in force at once + * for every organization, with no stored row to rewrite. + * + * The rule, per key: + * + * | key | organization may save | enforced | + * |---|---|---| + * | `allowed_ip_ranges` | an empty list; or ranges each inside a deployment range (any range when the deployment sets none), one of which admits the saving request's own address | an address must pass the deployment's list AND the organization's | + * | `session_idle_timeout_minutes`, `session_absolute_max_hours`, `max_concurrent_sessions_per_user` | `0`; or a positive value no larger than the deployment's (any positive value when the deployment's is `0`) | the smaller positive value of the two; `0` on both is no limit | + * | `mfa_required` | `off`; or a level at least as strong as the deployment's | the stronger level of the two (`off` < `administrators` < `everyone`) | + * + * ## Which organization's policy applies + * + * The organization the session is scoped to (`session.activeOrganizationId`). + * Sign-in happens before a session has one, so the sign-in request itself is + * judged by the deployment's policy alone (the floor, which binds every + * organization). The session the sign-in mints is scoped by `session.create` + * (ADR-0093 D9, owner-preferred, else the oldest membership), and from there + * the scoped organization's policy decides: the scoping itself is refused from + * an address the organization's list does not admit + * ({@link judgeSessionScopingAddress}), and every later request on the session + * is held to that organization's limits and multi-factor level. Switching the + * active organization re-scopes the session and is judged the same way. + * + * ## Break-glass + * + * An organization's network list never holds a tenant-level administrator + * (ADR-0090 D12 kind 1, the platform administrator): the deployment's list + * still does. An organization that locks out all of its own administrators can + * therefore be reopened from inside the product, by the deployment's + * administrator clearing the list — the per-organization reading of the + * last-administrator guard (cloud ADR-0024 D5.2). The save check + * ({@link accessPolicyFloorRefusal}) refuses a list that leaves out the saving + * request's own address, so the administrator who saves is never the one it + * locks out. + */ + +import { resolveLocale } from '@objectstack/core'; +import type { SettingsCopyLocale } from './settings-copy-locale.js'; +import { ipMatchesRange } from './auth-manager.js'; +import { parseAllowedIpRanges, type RequestTransport } from './allowed-ip-ranges.js'; + +/** The settings namespace an organization's access policy lives in (scope `tenant`). */ +export const ACCESS_POLICY_NAMESPACE = 'access_policy'; + +/** The keys of {@link ACCESS_POLICY_NAMESPACE}, each the same key as its deployment twin in `auth`. */ +export const ACCESS_POLICY_KEYS = { + allowedIpRanges: 'allowed_ip_ranges', + sessionIdleTimeoutMinutes: 'session_idle_timeout_minutes', + sessionAbsoluteMaxHours: 'session_absolute_max_hours', + maxConcurrentSessions: 'max_concurrent_sessions_per_user', + mfaRequired: 'mfa_required', +} as const; + +/** The three multi-factor requirement levels, weakest first (ruling 「可以,按三档做(推荐)」). */ +export const MFA_REQUIREMENT_LEVELS = ['off', 'administrators', 'everyone'] as const; +export type MfaRequirementLevel = (typeof MFA_REQUIREMENT_LEVELS)[number]; + +/** `off` < `administrators` < `everyone`. */ +export function mfaLevelRank(level: MfaRequirementLevel): number { + return MFA_REQUIREMENT_LEVELS.indexOf(level); +} + +/** The stronger of two levels. */ +export function strongerMfaLevel(a: MfaRequirementLevel, b: MfaRequirementLevel): MfaRequirementLevel { + return mfaLevelRank(a) >= mfaLevelRank(b) ? a : b; +} + +/** Is `value` one of the three level names? */ +export function isMfaRequirementLevel(value: unknown): value is MfaRequirementLevel { + return typeof value === 'string' && (MFA_REQUIREMENT_LEVELS as readonly string[]).includes(value); +} + +/** + * The deployment's `auth.mfa_required` as a level. + * + * The three level names are the contract. The setting was a toggle before it + * had levels, and its env override `OS_AUTH_MFA_REQUIRED` may still carry a + * toggle's spelling on a deployment upgraded without editing its environment; + * a stored toggle is rewritten to a level once, at boot + * (`migrateLegacyMfaRequiredSetting`). So a toggle spelling read here can only + * come from that variable, and it keeps its meaning — on is `everyone`, off is + * `off` — with `legacy: true`, so the caller says once that the variable needs + * a level name. Anything else is not a level: `undefined`, and the caller keeps + * the value in force. + */ +export function readMfaRequirementLevel( + raw: unknown, +): { level: MfaRequirementLevel; legacy: boolean } | undefined { + if (isMfaRequirementLevel(raw)) return { level: raw, legacy: false }; + if (raw === true || raw === 1) return { level: 'everyone', legacy: true }; + if (raw === false || raw === 0) return { level: 'off', legacy: true }; + if (typeof raw === 'string') { + const text = raw.trim().toLowerCase(); + if (isMfaRequirementLevel(text)) return { level: text, legacy: false }; + if (text === 'true' || text === '1') return { level: 'everyone', legacy: true }; + if (text === 'false' || text === '0') return { level: 'off', legacy: true }; + } + return undefined; +} + +/** The smaller positive limit of two, `0` meaning no limit on either side. */ +export function tighterLimit(deployment: number, organization: number): number { + const d = Math.max(0, Math.floor(Number(deployment) || 0)); + const o = Math.max(0, Math.floor(Number(organization) || 0)); + if (d <= 0) return o; + if (o <= 0) return d; + return Math.min(d, o); +} + +/** The base address and prefix length of a range entry, `bits` undefined for an exact address. */ +function splitRange(range: string): { base: string; bits: number | undefined; v6: boolean } { + const [rawBase, rawBits] = range.trim().split('/'); + const base = (rawBase ?? '').trim(); + const v6 = base.includes(':'); + if (rawBits === undefined) return { base, bits: undefined, v6 }; + const bits = /^\d{1,3}$/.test(rawBits.trim()) ? Number(rawBits.trim()) : Number.NaN; + return { base, bits, v6 }; +} + +/** + * Does every address `inner` names lie inside `outer`? Both are entries of an + * allow-list, as {@link ipMatchesRange} reads them: an exact address, or a + * CIDR range of the same family. An inner range is inside when its prefix is + * at least as long as the outer one's and its base address matches the outer + * range; an exact address, when the outer range matches it. + */ +export function rangeWithin(inner: string, outer: string): boolean { + const i = splitRange(inner); + const o = splitRange(outer); + if (!i.base || !o.base || i.v6 !== o.v6) return false; + const width = i.v6 ? 128 : 32; + const innerBits = i.bits === undefined ? width : i.bits; + const outerBits = o.bits === undefined ? width : o.bits; + if (!Number.isFinite(innerBits) || innerBits < 0 || innerBits > width) return false; + if (!Number.isFinite(outerBits) || outerBits < 0 || outerBits > width) return false; + if (innerBits < outerBits) return false; + return ipMatchesRange(i.base, outer); +} + +/** One organization's own access-policy values — each key's zero value is "nothing beyond the deployment's". */ +export interface OrganizationAccessPolicy { + allowedIpRanges: string[]; + sessionIdleTimeoutMinutes: number; + sessionAbsoluteMaxHours: number; + maxConcurrentSessions: number; + mfaRequired: MfaRequirementLevel; +} + +/** The policy of an organization that sets nothing, and of a session scoped to none. */ +export const NO_ORGANIZATION_POLICY: Readonly = Object.freeze({ + allowedIpRanges: Object.freeze([]) as unknown as string[], + sessionIdleTimeoutMinutes: 0, + sessionAbsoluteMaxHours: 0, + maxConcurrentSessions: 0, + mfaRequired: 'off', +}); + +function nonNegativeInt(raw: unknown): number { + const n = Math.floor(Number(raw)); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** + * An organization's resolved `access_policy` values (`key → value`, as the + * settings service resolves them for that organization) as a policy. A value + * that is not of its key's type reads as the key's zero value: the save door + * refuses such a value, so only a row written around it can carry one, and the + * deployment's value still holds then. + */ +export function readOrganizationAccessPolicy(values: Record | undefined): OrganizationAccessPolicy { + if (!values) return { ...NO_ORGANIZATION_POLICY, allowedIpRanges: [] }; + return { + allowedIpRanges: parseAllowedIpRanges(values[ACCESS_POLICY_KEYS.allowedIpRanges]), + sessionIdleTimeoutMinutes: nonNegativeInt(values[ACCESS_POLICY_KEYS.sessionIdleTimeoutMinutes]), + sessionAbsoluteMaxHours: nonNegativeInt(values[ACCESS_POLICY_KEYS.sessionAbsoluteMaxHours]), + maxConcurrentSessions: nonNegativeInt(values[ACCESS_POLICY_KEYS.maxConcurrentSessions]), + mfaRequired: isMfaRequirementLevel(values[ACCESS_POLICY_KEYS.mfaRequired]) + ? (values[ACCESS_POLICY_KEYS.mfaRequired] as MfaRequirementLevel) + : 'off', + }; +} + +/** The deployment's values for the same keys — the `auth` settings as the settings pass applied them. */ +export interface DeploymentAccessFloor { + allowedIpRanges?: string[]; + sessionIdleTimeoutMinutes?: number; + sessionAbsoluteMaxHours?: number; + maxConcurrentSessions?: number; + mfaRequirement?: MfaRequirementLevel; +} + +/** The session limits in force for a session scoped to an organization with `organization`'s policy. */ +export function effectiveSessionLimits( + floor: DeploymentAccessFloor, + organization: OrganizationAccessPolicy, +): { idleMinutes: number; absoluteHours: number; concurrent: number } { + return { + idleMinutes: tighterLimit(floor.sessionIdleTimeoutMinutes ?? 0, organization.sessionIdleTimeoutMinutes), + absoluteHours: tighterLimit(floor.sessionAbsoluteMaxHours ?? 0, organization.sessionAbsoluteMaxHours), + concurrent: tighterLimit(floor.maxConcurrentSessions ?? 0, organization.maxConcurrentSessions), + }; +} + +/** The multi-factor level in force for a session scoped to an organization with `organization`'s policy. */ +export function effectiveMfaLevel( + floor: DeploymentAccessFloor, + organization: OrganizationAccessPolicy, +): MfaRequirementLevel { + return strongerMfaLevel(floor.mfaRequirement ?? 'off', organization.mfaRequired); +} + +/** + * Does the organization's own list admit `address`? An empty list admits every + * address (the deployment's list, judged at the auth door, still applies); a + * set list admits only an address inside one of its ranges, and never an + * undetermined one — the deployment gate's reading (`isClientIpAllowed`). + */ +export function organizationListAdmits(address: string | undefined, organization: OrganizationAccessPolicy): boolean { + if (organization.allowedIpRanges.length === 0) return true; + if (!address) return false; + return organization.allowedIpRanges.some((range) => ipMatchesRange(address, range)); +} + +/** + * The refusal of a session scoped to an organization from an address its list + * does not admit — `403 IP_NOT_ALLOWED`, the deployment gate's code and + * message, so a client tells the two apart by nothing it could act on + * differently. + */ +export const SESSION_SCOPING_REFUSAL = Object.freeze({ + code: 'IP_NOT_ALLOWED', + message: 'Sign-in is not allowed from your network.', +}); + +/** + * Is a session for `userId` allowed to be scoped to an organization whose own + * policy is `organization`, from `address`? Allowed when the organization's + * list admits the address ({@link organizationListAdmits}), or when the user is + * a tenant-level administrator (break-glass; see the module header). + * `isPlatformAdmin` is asked only when the list refuses. + */ +export async function judgeSessionScopingAddress(input: { + address: string | undefined; + organization: OrganizationAccessPolicy; + isPlatformAdmin: () => Promise; +}): Promise<'admitted' | 'refused'> { + if (organizationListAdmits(input.address, input.organization)) return 'admitted'; + return (await input.isPlatformAdmin()) ? 'admitted' : 'refused'; +} + +// --------------------------------------------------------------------------- +// The save door — the floor, per key +// --------------------------------------------------------------------------- + +interface FloorCopy { + rangeOutside(range: string, deployment: string): string; + limitAbove(limit: number, deployment: number): string; + mfaBelow(level: string, deployment: string): string; +} + +/** + * The floor refusals, per language: one sentence each, naming the value that + * was refused and the deployment's value it may not loosen. Level names and + * ranges are named verbatim: they are what an administrator types. + */ +export const ACCESS_POLICY_FLOOR_COPY: Readonly> = { + en: { + rangeOutside: (r, d) => `${r} is not inside the deployment's allowed IP ranges (${d}). An organization can only narrow them: use ranges inside them.`, + limitAbove: (n, d) => `${n} is longer than the deployment's limit of ${d}. An organization can only shorten it: use ${d} or less, or 0 to keep the deployment's limit.`, + mfaBelow: (l, d) => `"${l}" is weaker than the deployment's multi-factor requirement "${d}". An organization can only strengthen it: choose "${d}" or stronger, or "off" to keep the deployment's requirement.`, + }, + 'zh-CN': { + rangeOutside: (r, d) => `${r} 不在部署允许的 IP 范围(${d})之内。组织只能收窄范围:请使用其中的网段。`, + limitAbove: (n, d) => `${n} 超过部署的上限 ${d}。组织只能缩短:请填写 ${d} 或更小,或填 0 沿用部署的上限。`, + mfaBelow: (l, d) => `“${l}”弱于部署的多因素认证要求“${d}”。组织只能加强:请选择“${d}”或更强,或选“off”沿用部署的要求。`, + }, + 'zh-Hant': { + rangeOutside: (r, d) => `${r} 不在部署允許的 IP 範圍(${d})之內。組織只能收窄範圍:請使用其中的網段。`, + limitAbove: (n, d) => `${n} 超過部署的上限 ${d}。組織只能縮短:請填寫 ${d} 或更小,或填 0 沿用部署的上限。`, + mfaBelow: (l, d) => `「${l}」弱於部署的多因素驗證要求「${d}」。組織只能加強:請選擇「${d}」或更強,或選「off」沿用部署的要求。`, + }, + 'ja-JP': { + rangeOutside: (r, d) => `${r} はデプロイで許可された IP 範囲(${d})に含まれていません。組織は範囲を狭めることしかできません。その内側の範囲を指定してください。`, + limitAbove: (n, d) => `${n} はデプロイの上限 ${d} より長くなっています。組織は短くすることしかできません。${d} 以下を指定するか、0 にしてデプロイの上限を使ってください。`, + mfaBelow: (l, d) => `「${l}」はデプロイの多要素認証要件「${d}」より弱くなっています。組織は強めることしかできません。「${d}」以上を選ぶか、「off」にしてデプロイの要件を使ってください。`, + }, + 'es-ES': { + rangeOutside: (r, d) => `${r} no está dentro de los rangos de IP permitidos de la implementación (${d}). Una organización solo puede reducirlos: usa rangos incluidos en ellos.`, + limitAbove: (n, d) => `${n} supera el límite de la implementación, ${d}. Una organización solo puede acortarlo: usa ${d} o menos, o 0 para mantener el límite de la implementación.`, + mfaBelow: (l, d) => `"${l}" es más débil que el requisito multifactor de la implementación, "${d}". Una organización solo puede reforzarlo: elige "${d}" o uno más fuerte, u "off" para mantener el de la implementación.`, + }, + ar: { + rangeOutside: (r, d) => `النطاق ${r} ليس ضمن نطاقات IP المسموح بها في النشر (${d}). لا يمكن للمؤسسة إلا تضييقها: استخدم نطاقات تقع داخلها.`, + limitAbove: (n, d) => `القيمة ${n} أطول من حد النشر ${d}. لا يمكن للمؤسسة إلا تقصيره: استخدم ${d} أو أقل، أو 0 للإبقاء على حد النشر.`, + mfaBelow: (l, d) => `المستوى "${l}" أضعف من متطلب المصادقة متعددة العوامل في النشر "${d}". لا يمكن للمؤسسة إلا تقويته: اختر "${d}" أو أقوى، أو "off" للإبقاء على متطلب النشر.`, + }, + fr: { + rangeOutside: (r, d) => `${r} n’est pas compris dans les plages d’adresses IP autorisées du déploiement (${d}). Une organisation peut seulement les restreindre : utilisez des plages incluses dedans.`, + limitAbove: (n, d) => `${n} dépasse la limite du déploiement, ${d}. Une organisation peut seulement la raccourcir : indiquez ${d} ou moins, ou 0 pour garder la limite du déploiement.`, + mfaBelow: (l, d) => `« ${l} » est plus faible que l’exigence multifacteur du déploiement, « ${d} ». Une organisation peut seulement la renforcer : choisissez « ${d} » ou plus strict, ou « off » pour garder celle du déploiement.`, + }, + 'pt-BR': { + rangeOutside: (r, d) => `${r} não está dentro das faixas de IP permitidas da implantação (${d}). Uma organização só pode restringi-las: use faixas contidas nelas.`, + limitAbove: (n, d) => `${n} é maior que o limite da implantação, ${d}. Uma organização só pode encurtá-lo: use ${d} ou menos, ou 0 para manter o limite da implantação.`, + mfaBelow: (l, d) => `"${l}" é mais fraco que o requisito multifator da implantação, "${d}". Uma organização só pode reforçá-lo: escolha "${d}" ou mais forte, ou "off" para manter o da implantação.`, + }, + ru: { + rangeOutside: (r, d) => `${r} не входит в разрешённые диапазоны IP развёртывания (${d}). Организация может только сузить их: укажите диапазоны внутри них.`, + limitAbove: (n, d) => `${n} больше лимита развёртывания ${d}. Организация может только сократить его: укажите ${d} или меньше либо 0, чтобы сохранить лимит развёртывания.`, + mfaBelow: (l, d) => `«${l}» слабее требования многофакторной аутентификации развёртывания «${d}». Организация может только усилить его: выберите «${d}» или строже либо «off», чтобы сохранить требование развёртывания.`, + }, + de: { + rangeOutside: (r, d) => `${r} liegt nicht innerhalb der erlaubten IP-Bereiche der Bereitstellung (${d}). Eine Organisation kann sie nur einschränken: Verwenden Sie Bereiche innerhalb davon.`, + limitAbove: (n, d) => `${n} ist länger als das Limit der Bereitstellung von ${d}. Eine Organisation kann es nur verkürzen: Geben Sie ${d} oder weniger an oder 0, um das Limit der Bereitstellung zu behalten.`, + mfaBelow: (l, d) => `„${l}“ ist schwächer als die Multi-Faktor-Anforderung der Bereitstellung „${d}“. Eine Organisation kann sie nur verschärfen: Wählen Sie „${d}“ oder strenger oder „off“, um die Anforderung der Bereitstellung zu behalten.`, + }, + ko: { + rangeOutside: (r, d) => `${r}은(는) 배포에서 허용한 IP 범위(${d}) 안에 있지 않습니다. 조직은 범위를 좁히기만 할 수 있으니 그 안의 범위를 사용하세요.`, + limitAbove: (n, d) => `${n}은(는) 배포의 한도 ${d}보다 깁니다. 조직은 줄이기만 할 수 있으니 ${d} 이하를 입력하거나 0으로 배포의 한도를 유지하세요.`, + mfaBelow: (l, d) => `"${l}"은(는) 배포의 다단계 인증 요구 수준 "${d}"보다 약합니다. 조직은 강화하기만 할 수 있으니 "${d}" 이상을 선택하거나 "off"로 배포의 요구 수준을 유지하세요.`, + }, +}; + +const FLOOR_COPY_LOCALES = Object.keys(ACCESS_POLICY_FLOOR_COPY); + +/** The floor copy for a caller's locale: through the platform locale table, else English. */ +export function accessPolicyFloorCopy(locale: string | undefined): FloorCopy { + const picked = locale ? resolveLocale(locale, FLOOR_COPY_LOCALES) : undefined; + return ACCESS_POLICY_FLOOR_COPY[(picked ?? 'en') as SettingsCopyLocale]; +} + +/** A refusal, in the settings write predicate's shape. */ +export interface AccessPolicyRefusal { + message: string; + constraint: Record; +} + +/** + * The floor check of one `access_policy` key: the refusal of a value weaker + * than the deployment's (`floor`), or `undefined` when the value may be saved. + * The table in the module header is the rule. A cleared value (`null`) is the + * key's zero value and always passes. + */ +export function accessPolicyFloorRefusal( + key: string, + next: unknown, + floor: DeploymentAccessFloor, + locale?: string, +): AccessPolicyRefusal | undefined { + if (next === null || next === undefined) return undefined; + const copy = accessPolicyFloorCopy(locale); + switch (key) { + case ACCESS_POLICY_KEYS.allowedIpRanges: { + const deployment = floor.allowedIpRanges ?? []; + if (deployment.length === 0) return undefined; + const outside = parseAllowedIpRanges(next).find((range) => !deployment.some((d) => rangeWithin(range, d))); + if (!outside) return undefined; + return { + message: copy.rangeOutside(outside, deployment.join(', ')), + constraint: { range: outside, deploymentRanges: [...deployment] }, + }; + } + case ACCESS_POLICY_KEYS.sessionIdleTimeoutMinutes: + case ACCESS_POLICY_KEYS.sessionAbsoluteMaxHours: + case ACCESS_POLICY_KEYS.maxConcurrentSessions: { + const deployment = nonNegativeInt( + key === ACCESS_POLICY_KEYS.sessionIdleTimeoutMinutes + ? floor.sessionIdleTimeoutMinutes + : key === ACCESS_POLICY_KEYS.sessionAbsoluteMaxHours + ? floor.sessionAbsoluteMaxHours + : floor.maxConcurrentSessions, + ); + const value = nonNegativeInt(next); + if (deployment === 0 || value === 0 || value <= deployment) return undefined; + return { message: copy.limitAbove(value, deployment), constraint: { max: deployment } }; + } + case ACCESS_POLICY_KEYS.mfaRequired: { + if (!isMfaRequirementLevel(next) || next === 'off') return undefined; + const deployment = floor.mfaRequirement ?? 'off'; + if (mfaLevelRank(next) >= mfaLevelRank(deployment)) return undefined; + return { message: copy.mfaBelow(next, deployment), constraint: { minimum: deployment } }; + } + default: + return undefined; + } +} + +/** What the `access_policy` write predicates read of the settings write predicate's input. */ +interface AccessPolicyWriteInput { + key: string; + next: unknown; + transport?: RequestTransport; + locale?: string; +} + +/** + * The write predicate of one `access_policy` key: the floor + * ({@link accessPolicyFloorRefusal}) for every key, and for + * `allowed_ip_ranges` the self-lock-out check the deployment list has + * (`allowedIpRangesWritePredicate`, judged against the same address). `floor` + * is read per write, so the rule is the one in force when the save happens. + */ +export function accessPolicyWritePredicate( + floor: () => DeploymentAccessFloor | undefined, + selfLockOut?: (input: unknown) => { message: string; constraint: { clientAddress: string | null } } | undefined, +): (input: unknown) => AccessPolicyRefusal | undefined { + return (input) => { + const { key, next, locale } = (input ?? {}) as AccessPolicyWriteInput; + const current = floor(); + if (current) { + const refusal = accessPolicyFloorRefusal(key, next, current, locale); + if (refusal) return refusal; + } + if (key === ACCESS_POLICY_KEYS.allowedIpRanges && selfLockOut) return selfLockOut(input); + return undefined; + }; +} diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts index 349de99ae93..e730be59d70 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts @@ -6,149 +6,43 @@ * * ## The dead end this closes * - * `sys_organization.require_mfa` is a generically editable extension column - * (`MANAGED_EXTENSION_EDITABLE_FIELDS`): an administrator ticks it on the - * organization's edit form and it saves. The per-user MFA gate then holds every - * unenrolled member once their grace period ends (`resolveUserAuthGates`, - * `MFA_REQUIRED`). The only way to enroll is better-auth's `twoFactor` plugin - * (`/two-factor/*`), which is off by default; on such a deployment the held - * member was sent to enroll and the enrollment endpoint answered 404. Every - * member, the owner included, was locked out, and nobody could undo it from - * inside the product, because Setup sits behind the same gate. + * An organization's multi-factor level is its `access_policy.mfa_required` + * setting (scope `tenant`, `organization-access-policy.ts`; it was the + * `sys_organization.require_mfa` column before the requirement had levels). + * The per-user MFA gate holds every unenrolled member the level names once + * their grace period ends (`resolveUserAuthGates`, `MFA_REQUIRED`). The only + * way to enroll is better-auth's `twoFactor` plugin (`/two-factor/*`), which + * is off by default; on such a deployment a held member is sent to enroll and + * the enrollment endpoint answers 404, and nobody can undo it from inside the + * product, because Setup sits behind the same gate. * - * The global switch never had this hole: the `auth.mfa_required` setting - * applies the requirement and the plugin as ONE piece (`auth-plugin.ts`, the - * settings binding), so the requirement is never in force without its - * enrollment path. The organization column had no such pairing. - * - * ## Why the write is refused rather than paired - * - * The plugin's switch is process configuration — the settings pass and the - * `OS_AUTH_TWO_FACTOR` env var, re-applied at every boot — and an organization - * row is data. Turning the plugin on from an organization write would hold in - * the process that took the write only: it would not survive a restart or - * reach another node, because nothing that builds the plugin list reads - * organization rows. And turning it off again when the last organization - * clears the column would silently drop the second factor of every member who - * enrolled. So the card's own fallback is taken: the save is refused, and says - * why and what to do. + * The deployment's switch never had this hole: the `auth.mfa_required` + * setting applies the requirement and the plugin as ONE piece (`auth-plugin.ts`, + * the settings binding). An organization's setting has no such pairing, and + * cannot have one: the plugin's switch is process configuration, re-applied at + * every boot from the `auth` settings and `OS_AUTH_TWO_FACTOR`, and nothing + * that builds the plugin list reads an organization's settings. So the save is + * refused, and says why and what to do. * * ## The rule * - * While {@link OrganizationMfaEnrollmentGuardOptions.isTwoFactorEnabled} - * answers false, a write that TURNS the requirement on is refused — - * `400 VALIDATION_FAILED` with one `fields[]` entry, `require_mfa` / - * `invalid_value` (the closed ADR-0114 catalog), built by `validationFailure`: - * - * - an insert whose payload sets `require_mfa` to anything but an explicit - * off value; - * - an update whose payload does so for a row whose stored value is off. - * - * Clearing the column, writing other columns, re-sending `require_mfa: true` - * for a row that already stores it (a whole-row SDK or MCP update, an import - * re-run, a save with no change), and every write while two-factor is on pass - * untouched. Refusing the unchanged value would block every other edit of - * that row through a door that sends the whole record, for a value the gate - * already does not enforce (below). - * - * The stored value is the update context's `previous`, which the engine binds - * before `beforeUpdate` on BOTH update paths: the by-id write binds the row it - * read, and a multi-row update fans out one context per matched row, each - * with that row's `previous` and the one shared payload, before anything is - * written. So a multi-row update is judged row by row, and one row turning - * the requirement on refuses the whole write, rows that already stored it - * included, with nothing written. An update context WITHOUT a `previous` is - * judged as a change (refused): no engine path produces one, and an unproven - * prior state is not evidence the value was already on. + * The settings service asks a write predicate only about a CHANGE to the key + * (`refuseByWritePredicates`). While + * {@link organizationMfaEnrollmentPredicate}'s `isTwoFactorEnabled` answers + * false, a change to a level other than `off` is refused — `SETTINGS_VALIDATION` + * with an `invalid_value` field error at `mfa_required`, in the caller's + * language. Clearing the key, choosing `off`, re-saving the level in force and + * every save while two-factor is on pass untouched. * - * Judged for EVERY write context, the system's included, like the last-admin - * guard: the lockout does not depend on who wrote the row. - * - * Rows stored before this guard are not touched. The gate itself does not - * enforce a requirement nobody can comply with (`resolveUserAuthGates`), so - * such a row locks no one out; it starts holding members, with a fresh grace - * window, once two-factor is turned on. - * - * ## What the saver is told (#30411) - * - * Both answers are in the caller's language: the locale the request asked for - * (`Accept-Language`, carried as `ExecutionContext.locale` and handed to every - * hook as `ctx.session.locale`), matched against the four the copy carries - * with core's `resolveLocale`, which is `resolveLocaleAmong`'s five steps - * (exact, case-insensitive, platform locale table language, so `zh` reaches - * `zh-CN`; base language; first registered variant), else English. The - * table step only adds answers for the table's aliases: the copy carries no - * `zh-Hant`, so `zh-TW` still reads `zh-CN`. A write with no caller - * locale (a system write, a - * seed) reads English. This is the same path the global switch's refusal takes - * (`mfa-required-enrollment-predicate.ts`). - * - * - The refusal above, with the field-level shape unchanged: the top-level - * `message` and the `require_mfa` entry's `message` are the same sentence. - * - A save the rule ADMITS that turns the requirement on (two-factor on, the - * stored value off, or an insert) answers a save notice - * (`ctx.notice(message)`, answered as the create / update answer's - * `notice`): who must now enroll, and the grace period in force, in days, - * read per write from - * {@link OrganizationMfaEnrollmentGuardOptions.mfaGracePeriodDays} — the - * number the gate's clock holds to. That is the one place an organization - * administrator reads the configured grace period: the `auth` settings it - * lives in are readable only with `manage_platform_settings`, which an - * organization administrator does not hold. A write that turns nothing on - * (a re-save of a stored `true`, a clear, another column) says nothing; a - * write no person waits on passes no listener, and the call sets nothing. + * A level stored before this check is not touched: the gate does not enforce + * a requirement nobody can comply with (`resolveUserAuthGates`), so such a row + * locks no one out; it starts holding members, with a fresh grace window, once + * two-factor is turned on. */ import { resolveLocale } from '@objectstack/core'; import type { SettingsCopyLocale } from './settings-copy-locale.js'; -import { SystemObjectName } from '@objectstack/spec/system'; -import { validationFailure } from '@objectstack/types'; - -/** After the ADR-0092 D2 identity write guard (10), before default-priority hooks (100). */ -export const ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY = 15; - -export const REQUIRE_MFA_FIELD = 'require_mfa'; - -export interface OrganizationMfaEnrollmentGuardOptions { - packageId: string; - /** - * Can a user enroll a second factor right now — is the twoFactor plugin - * wired? Read per write (`AuthManager.isTwoFactorEnabled`, the same answer - * the plugin list and `features.twoFactor` give), so a settings pass that - * turns the plugin on admits the next write. - */ - isTwoFactorEnabled: () => boolean; - /** - * The MFA grace period in force, in whole days — read per admitted write - * that turns the requirement on, for its save notice - * (`AuthManager.getMfaGracePeriodDays`, the same number the gate's clock - * reads), so a settings change is told at once. - */ - mfaGracePeriodDays: () => number; -} - -/** The engine surface this guard uses. */ -export interface OrganizationMfaEnrollmentGuardEngine { - registerHook( - event: string, - handler: (ctx: any) => Promise, - options?: { object?: string; priority?: number; packageId?: string }, - ): void; -} - -/** - * The values that leave the requirement off. Anything else a payload carries - * for the column — `true`, `1`, `'true'`, or a value the engine would refuse - * later anyway — is judged as turning it on, so the guard never admits a value - * the gate could read as a requirement. - */ -const OFF_VALUES: ReadonlySet = new Set([false, 0, '0', 'false', null, undefined]); - -/** True when `value`, written to `require_mfa`, turns the requirement on. */ -export function turnsMfaRequirementOn(value: unknown): boolean { - if (typeof value === 'string') return !OFF_VALUES.has(value.trim().toLowerCase()); - return !OFF_VALUES.has(value); -} +import { isMfaRequirementLevel } from './organization-access-policy.js'; /** * The locales the copy below carries: every language on the platform locale @@ -156,25 +50,6 @@ export function turnsMfaRequirementOn(value: unknown): boolean { */ type CopyLocale = SettingsCopyLocale; -/** `n` whole days in Arabic: its six plural forms, the dual and the singular spelled out. */ -function arabicDays(n: number): string { - const rest = n % 100; - if (n === 1) return 'يوم واحد'; - if (n === 2) return 'يومان'; - if (rest >= 3 && rest <= 10) return `${n} أيام`; - if (rest >= 11 && rest <= 99) return `${n} يومًا`; - return `${n} يوم`; -} - -/** `n` whole days in Russian: день / дня / дней by the last one or two digits. */ -function russianDays(n: number): string { - const rest10 = n % 10; - const rest100 = n % 100; - if (rest10 === 1 && rest100 !== 11) return `${n} день`; - if (rest10 >= 2 && rest10 <= 4 && (rest100 < 12 || rest100 > 14)) return `${n} дня`; - return `${n} дней`; -} - /** * The refusal, per language: what was refused, why, who it would have locked * out, and the ways forward. The second way is conditional because it is: the @@ -260,184 +135,32 @@ export const ORGANIZATION_MFA_REFUSAL_COPY: Readonly> '설정은 모든 조직에 다단계 인증을 요구하고 등록도 함께 켭니다.', }; -/** - * The save notice, per language, for a grace period of `days` whole days: - * who must now enroll, from when, and until when. `0` has its own sentence - * (the block starts at the member's next use), as do the plurals. - */ -export const ORGANIZATION_MFA_REQUIRED_NOTICE_COPY: Readonly string>> = { - en: (days) => - days === 0 - ? 'Multi-factor authentication is now required for this organization. Every member who has not set up an ' + - 'authenticator app is blocked from data the next time they use the app, until they enroll one: the MFA ' + - 'grace period is 0 days. Members who already use an authenticator app are not affected. The MFA grace ' + - 'period is set in Setup → Authentication.' - : 'Multi-factor authentication is now required for this organization. Every member who has not set up an ' + - `authenticator app has ${days === 1 ? '1 day' : `${days} days`} to enroll one, counted from the first ` + - 'time they use the app while it is required; after that they are blocked from data until they do. Members ' + - 'who already use an authenticator app are not affected. The MFA grace period is set in Setup → ' + - 'Authentication.', - 'zh-CN': (days) => - days === 0 - ? '本组织现已强制多因素认证。尚未绑定身份验证器应用的成员下次使用应用时即无法访问数据,直到完成绑定:' + - 'MFA 宽限期为 0 天。已使用身份验证器应用的成员不受影响。MFA 宽限期可在“设置 → 认证”中修改。' - : `本组织现已强制多因素认证。尚未绑定身份验证器应用的成员有 ${days} 天时间完成绑定,` + - '从要求生效后他们第一次使用应用时起算;期满后,未绑定的成员将无法访问数据,直到完成绑定。' + - '已使用身份验证器应用的成员不受影响。MFA 宽限期可在“设置 → 认证”中修改。', - 'ja-JP': (days) => - days === 0 - ? 'この組織では多要素認証が必須になりました。認証アプリをまだ登録していないメンバーは、次にアプリを' + - '利用した時点から登録するまでデータにアクセスできなくなります(MFA 猶予期間は 0 日です)。' + - 'すでに認証アプリを使っているメンバーには影響しません。MFA 猶予期間は「設定 → 認証」で変更できます。' - : 'この組織では多要素認証が必須になりました。認証アプリをまだ登録していないメンバーは、必須になってから' + - `初めてアプリを利用した時点から ${days} 日以内に登録する必要があり、期限を過ぎると登録するまで` + - 'データにアクセスできなくなります。すでに認証アプリを使っているメンバーには影響しません。' + - 'MFA 猶予期間は「設定 → 認証」で変更できます。', - 'es-ES': (days) => - days === 0 - ? 'La autenticación multifactor ahora es obligatoria en esta organización. A cada miembro que no haya ' + - 'configurado una app de autenticación se le bloquea el acceso a los datos la próxima vez que use la app, ' + - 'hasta que la registre: el periodo de gracia de MFA es de 0 días. Los miembros que ya usan un ' + - 'autenticador no se ven afectados. El periodo de gracia de MFA se configura en Configuración → ' + - 'Autenticación.' - : 'La autenticación multifactor ahora es obligatoria en esta organización. Cada miembro que no haya ' + - `configurado una app de autenticación tiene ${days === 1 ? '1 día' : `${days} días`} para registrarla, ` + - 'a contar desde la primera vez que usa la app mientras sea obligatoria; después se le bloquea el acceso ' + - 'a los datos hasta que lo haga. Los miembros que ya usan un autenticador no se ven afectados. El periodo ' + - 'de gracia de MFA se configura en Configuración → Autenticación.', - 'zh-Hant': (days) => - days === 0 - ? '本組織現已強制多因素驗證。尚未綁定驗證器應用程式的成員下次使用應用程式時即無法存取資料,直到完成綁定:' + - 'MFA 寬限期為 0 天。已使用驗證器應用程式的成員不受影響。MFA 寬限期可在「設定 → 驗證」中修改。' - : `本組織現已強制多因素驗證。尚未綁定驗證器應用程式的成員有 ${days} 天時間完成綁定,` + - '從要求生效後他們第一次使用應用程式時起算;期滿後,未綁定的成員將無法存取資料,直到完成綁定。' + - '已使用驗證器應用程式的成員不受影響。MFA 寬限期可在「設定 → 驗證」中修改。', - ar: (days) => - days === 0 - ? 'أصبحت المصادقة متعددة العوامل إلزامية لهذه المؤسسة. كل عضو لم يُعدّ تطبيق مصادقة يُحظر من الوصول إلى ' + - 'البيانات في المرة التالية التي يستخدم فيها التطبيق، إلى أن يسجّل تطبيقًا: مدة فترة السماح للمصادقة متعددة ' + - 'العوامل 0 يوم. لا يتأثر الأعضاء الذين يستخدمون تطبيق مصادقة بالفعل. تُضبط فترة السماح في الإعداد ← المصادقة.' - : `أصبحت المصادقة متعددة العوامل إلزامية لهذه المؤسسة. أمام كل عضو لم يُعدّ تطبيق مصادقة مهلة ${arabicDays(days)} ` + - 'لتسجيله، تُحسب من أول مرة يستخدم فيها التطبيق ما دام هذا الشرط قائمًا؛ وبعدها يُحظر من الوصول إلى البيانات ' + - 'إلى أن يسجّله. لا يتأثر الأعضاء الذين يستخدمون تطبيق مصادقة بالفعل. تُضبط فترة السماح في الإعداد ← المصادقة.', - fr: (days) => - days === 0 - ? 'L’authentification multifacteur est désormais obligatoire pour cette organisation. Chaque membre qui n’a ' + - 'pas configuré d’application d’authentification perd l’accès aux données dès sa prochaine utilisation de ' + - 'l’application, jusqu’à ce qu’il en enregistre une : le délai de grâce MFA est de 0 jour. Les membres qui ' + - 'utilisent déjà une application d’authentification ne sont pas concernés. Le délai de grâce MFA se règle ' + - 'dans Configuration → Authentification.' - : 'L’authentification multifacteur est désormais obligatoire pour cette organisation. Chaque membre qui n’a ' + - `pas configuré d’application d’authentification dispose de ${days === 1 ? '1 jour' : `${days} jours`} pour ` + - 'en enregistrer une, à compter de sa première utilisation de l’application depuis que l’exigence est ' + - 'active ; passé ce délai, il perd l’accès aux données jusqu’à ce qu’il le fasse. Les membres qui utilisent ' + - 'déjà une application d’authentification ne sont pas concernés. Le délai de grâce MFA se règle dans ' + - 'Configuration → Authentification.', - 'pt-BR': (days) => - days === 0 - ? 'A autenticação multifator agora é obrigatória nesta organização. Cada membro que não configurou um ' + - 'aplicativo autenticador perde o acesso aos dados na próxima vez que usar o aplicativo, até cadastrar um: o ' + - 'período de carência de MFA é de 0 dias. Os membros que já usam um aplicativo autenticador não são ' + - 'afetados. O período de carência de MFA é definido em Configuração → Autenticação.' - : 'A autenticação multifator agora é obrigatória nesta organização. Cada membro que não configurou um ' + - `aplicativo autenticador tem ${days === 1 ? '1 dia' : `${days} dias`} para cadastrar um, contados a partir ` + - 'da primeira vez que usar o aplicativo enquanto a exigência estiver ativa; depois disso, perde o acesso aos ' + - 'dados até cadastrar. Os membros que já usam um aplicativo autenticador não são afetados. O período de ' + - 'carência de MFA é definido em Configuração → Autenticação.', - ru: (days) => - days === 0 - ? 'Теперь в этой организации обязательна многофакторная аутентификация. Каждый участник, не настроивший ' + - 'приложение-аутентификатор, теряет доступ к данным при следующем использовании приложения, пока не ' + - 'подключит его: льготный период MFA — 0 дней. Участников, которые уже используют приложение-аутентификатор, ' + - 'это не затрагивает. Льготный период MFA задаётся в разделе «Настройка → Аутентификация».' - : 'Теперь в этой организации обязательна многофакторная аутентификация. У каждого участника, не настроившего ' + - `приложение-аутентификатор, есть ${russianDays(days)}, чтобы подключить его, считая с первого использования ` + - 'приложения после введения требования; после этого он теряет доступ к данным, пока не подключит приложение. ' + - 'Участников, которые уже используют приложение-аутентификатор, это не затрагивает. Льготный период MFA ' + - 'задаётся в разделе «Настройка → Аутентификация».', - de: (days) => - days === 0 - ? 'Die Multi-Faktor-Authentifizierung ist für diese Organisation jetzt Pflicht. Jedes Mitglied, das noch keine ' + - 'Authenticator-App eingerichtet hat, ist ab der nächsten Nutzung der App vom Zugriff auf Daten gesperrt, bis ' + - 'es eine einrichtet: Die MFA-Schonfrist beträgt 0 Tage. Mitglieder, die bereits eine Authenticator-App ' + - 'nutzen, sind nicht betroffen. Die MFA-Schonfrist wird unter Einrichtung → Authentifizierung festgelegt.' - : 'Die Multi-Faktor-Authentifizierung ist für diese Organisation jetzt Pflicht. Jedes Mitglied, das noch keine ' + - `Authenticator-App eingerichtet hat, hat ${days === 1 ? '1 Tag' : `${days} Tage`} Zeit, eine einzurichten, ` + - 'gerechnet ab der ersten Nutzung der App, seit sie Pflicht ist; danach ist es vom Zugriff auf Daten ' + - 'gesperrt, bis es das tut. Mitglieder, die bereits eine Authenticator-App nutzen, sind nicht betroffen. Die ' + - 'MFA-Schonfrist wird unter Einrichtung → Authentifizierung festgelegt.', - ko: (days) => - days === 0 - ? '이제 이 조직에서는 다단계 인증이 필수입니다. 인증 앱을 설정하지 않은 구성원은 다음에 앱을 사용할 때부터 ' + - '인증 앱을 등록할 때까지 데이터에 접근할 수 없습니다(MFA 유예 기간 0일). 이미 인증 앱을 사용하는 구성원은 ' + - '영향을 받지 않습니다. MFA 유예 기간은 설정 → 인증에서 변경할 수 있습니다.' - : '이제 이 조직에서는 다단계 인증이 필수입니다. 인증 앱을 설정하지 않은 구성원은 필수가 된 뒤 앱을 처음 ' + - `사용한 때부터 ${days}일 안에 인증 앱을 등록해야 하며, 그 이후에는 등록할 때까지 데이터에 접근할 수 ` + - '없습니다. 이미 인증 앱을 사용하는 구성원은 영향을 받지 않습니다. MFA 유예 기간은 설정 → 인증에서 변경할 수 ' + - '있습니다.', -}; - const COPY_LOCALES = Object.keys(ORGANIZATION_MFA_REFUSAL_COPY); -/** The copy language for a caller's locale: `resolveLocaleAmong`'s five steps, else English. */ -function copyLocaleFor(locale: string | undefined): CopyLocale { +/** The refusal in the caller's language: `resolveLocale` over the platform locale table, else English. */ +export function organizationMfaRefusalCopy(locale: string | undefined): string { const picked = typeof locale === 'string' && locale ? resolveLocale(locale, COPY_LOCALES) : undefined; - return (picked ?? 'en') as CopyLocale; + return ORGANIZATION_MFA_REFUSAL_COPY[(picked ?? 'en') as CopyLocale]; } -/** - * The refusal in the caller's language (`locale`, a BCP-47 tag as the request - * asked for it; English when absent or unmatched). The field-level shape is - * the same in every language: `VALIDATION_FAILED`, one `require_mfa` / - * `invalid_value` entry whose `message` is the top-level one. - */ -export function organizationMfaWithoutEnrollmentRefusal(locale?: string): Error { - const message = ORGANIZATION_MFA_REFUSAL_COPY[copyLocaleFor(locale)]; - return validationFailure(message, [{ field: REQUIRE_MFA_FIELD, code: 'invalid_value', message }]); +/** True when `value`, saved as an organization's `access_policy.mfa_required`, names a requirement. */ +export function namesMfaRequirement(value: unknown): boolean { + return isMfaRequirementLevel(value) && value !== 'off'; } -/** The save notice for an admitted write that turned the requirement on, in the caller's language. */ -export function organizationMfaRequiredNotice(graceDays: number, locale?: string): string { - return ORGANIZATION_MFA_REQUIRED_NOTICE_COPY[copyLocaleFor(locale)](graceDays); -} - -/** The caller's locale as the engine hands it to a hook (`HookContext.session.locale`). */ -function callerLocale(ctx: any): string | undefined { - const locale = ctx?.session?.locale; - return typeof locale === 'string' && locale.length > 0 ? locale : undefined; -} - -/** True when an update context proves the row already stores the requirement on. */ -function alreadyRequiresMfa(ctx: any): boolean { - const previous = ctx?.previous as Record | undefined; - return !!previous && typeof previous === 'object' && turnsMfaRequirementOn(previous[REQUIRE_MFA_FIELD]); -} - -/** Register the guard's insert and update hooks on `sys_organization`. */ -export function registerOrganizationMfaEnrollmentGuard( - engine: OrganizationMfaEnrollmentGuardEngine, - opts: OrganizationMfaEnrollmentGuardOptions, -): void { - const judge = (isUpdate: boolean) => async (ctx: any): Promise => { - const data = ctx?.input?.data as Record | undefined; - if (!data || typeof data !== 'object' || !(REQUIRE_MFA_FIELD in data)) return; - if (!turnsMfaRequirementOn(data[REQUIRE_MFA_FIELD])) return; - // Unchanged: the row already stores it on, so this write turns nothing on. - if (isUpdate && alreadyRequiresMfa(ctx)) return; - if (!opts.isTwoFactorEnabled()) throw organizationMfaWithoutEnrollmentRefusal(callerLocale(ctx)); - // Admitted, and it turns the requirement on: tell the saver who must - // enroll and the grace period in force. `ctx.notice` is bound on every - // insert / update dispatch and sets nothing when no one listens. - const graceDays = opts.mfaGracePeriodDays(); - if (typeof ctx?.notice === 'function' && Number.isFinite(graceDays)) { - ctx.notice(organizationMfaRequiredNotice(graceDays, callerLocale(ctx))); - } - }; - const options = { - object: SystemObjectName.ORGANIZATION, - priority: ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY, - packageId: opts.packageId, +/** + * `access_policy.mfa_required`'s enrollment check, as a settings write + * predicate. `isTwoFactorEnabled` is read per write (`AuthManager.isTwoFactorEnabled`, + * the answer the plugin list and `features.twoFactor` give), so a settings + * pass that turns the plugin on admits the next save. + */ +export function organizationMfaEnrollmentPredicate( + isTwoFactorEnabled: () => boolean, +): (input: unknown) => { message: string; constraint: { twoFactorEnabled: false } } | undefined { + return (input) => { + const { next, locale } = (input ?? {}) as { next?: unknown; locale?: string }; + if (!namesMfaRequirement(next)) return undefined; + if (isTwoFactorEnabled()) return undefined; + return { message: organizationMfaRefusalCopy(locale), constraint: { twoFactorEnabled: false } }; }; - engine.registerHook('beforeInsert', judge(false), options); - engine.registerHook('beforeUpdate', judge(true), options); } diff --git a/packages/services/service-settings/src/manifests/access-policy.manifest.ts b/packages/services/service-settings/src/manifests/access-policy.manifest.ts new file mode 100644 index 00000000000..ea6b7e4bae4 --- /dev/null +++ b/packages/services/service-settings/src/manifests/access-policy.manifest.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SettingsManifest } from '@objectstack/spec/system'; + +/** + * [ADR-0069 TL;DR 6] An organization's own access policy (ruling of record on + * objectstack-ai/cloud#3668, 「支持按组织(推荐)」): the allowed IP ranges, the + * idle, absolute and concurrent session limits and the multi-factor + * requirement, one row per organization (scope `tenant`, ADR-0131), written by + * the organization's administrators (`setup.write`, the tenant manifests' + * permission — `organization_admin` holds it). + * + * Each key is the twin of the `auth` key of the same name, which is the + * deployment's value: the default AND the floor. An organization may tighten + * and never loosen; each key's zero value (empty, `0`, `off`) means "nothing + * beyond the deployment's". plugin-auth enforces it — the save checks it + * registers on these keys refuse a weaker value, and enforcement combines the + * two values so the floor holds whatever is stored. The rule per key is the + * table in plugin-auth's `organization-access-policy.ts`. + */ +const manifest = { + namespace: 'access_policy', + version: 1, + label: 'Access policy', + icon: 'ShieldCheck', + description: + "This organization's sign-in restrictions. The deployment's authentication settings always apply; these can only add to them.", + scope: 'tenant', + readPermission: 'setup.access', + writePermission: 'setup.write', + category: 'Security', + order: 16, + specifiers: [ + { + type: 'group', + id: 'network', + label: 'Network', + required: false, + description: 'Where members of this organization may sign in from.', + }, + { + type: 'textarea', + key: 'allowed_ip_ranges', + label: 'Allowed IP ranges', + required: false, + description: + "CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, a member's session is not opened for this organization from outside these ranges, and so is any request whose client address the server cannot establish. Each range must lie inside the deployment's allowed IP ranges, when it sets any, and the list must include the address you save it from. Empty = only the deployment's ranges apply. Platform administrators are held to the deployment's ranges only.", + }, + { + type: 'group', + id: 'sessions', + label: 'Sessions', + required: false, + description: "How long a member's session stays valid. Each limit can only be shorter than the deployment's.", + }, + { + type: 'number', + key: 'session_idle_timeout_minutes', + label: 'Idle timeout (minutes)', + required: false, + default: 0, + min: 0, + max: 10080, + description: + "Sign a member out after this many minutes of inactivity. No longer than the deployment's idle timeout when it sets one. 0 = the deployment's idle timeout.", + }, + { + type: 'number', + key: 'session_absolute_max_hours', + label: 'Absolute session lifetime (hours)', + required: false, + default: 0, + min: 0, + max: 8760, + description: + "Force re-authentication this many hours after sign-in, regardless of activity. No longer than the deployment's lifetime when it sets one. 0 = the deployment's lifetime.", + }, + { + type: 'number', + key: 'max_concurrent_sessions_per_user', + label: 'Max concurrent sessions per user', + required: false, + default: 0, + min: 0, + max: 100, + description: + "Cap a member's simultaneous signed-in sessions; the oldest are signed out past the cap. No higher than the deployment's cap when it sets one. 0 = the deployment's cap.", + }, + { + type: 'group', + id: 'multi_factor', + label: 'Multi-factor', + required: false, + description: 'Who in this organization must protect their account with an authenticator app (TOTP).', + }, + { + type: 'select', + key: 'mfa_required', + label: 'Require multi-factor authentication', + required: false, + default: 'off', + options: [ + { value: 'off', label: 'Off' }, + { value: 'administrators', label: 'Administrators' }, + { value: 'everyone', label: 'Everyone' }, + ], + description: + "Who must use an authenticator app: administrators (platform, organization and delegated administrators) or everyone. Those required who have not enrolled are blocked from data once the deployment's grace period ends. A level weaker than the deployment's is refused; Off = the deployment's requirement. Needs two-factor authentication on for this deployment; while it is off, saving a requirement is refused.", + }, + ], +}; + +/** Access policy — one organization's sign-in restrictions, held to the deployment's `auth` values. */ +export const accessPolicySettingsManifest = manifest as unknown as SettingsManifest; diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index 920799900bc..08b6601506f 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -281,14 +281,25 @@ const manifest = { required: false, description: 'Require members to protect their account with an authenticator app (TOTP).', }, + // [ruling 「可以,按三档做(推荐)」, objectstack-ai/cloud#3668] Three levels. + // "Administrators" are ADR-0090 D12's three kinds — tenant-level, + // organization-level and delegated. This is the deployment's level: the + // default and the floor of every organization's own `access_policy` + // level, which can only be stronger. It was a toggle; plugin-auth rewrites + // a stored toggle as a level at boot (on → everyone). { - type: 'toggle', + type: 'select', key: 'mfa_required', label: 'Require multi-factor authentication', required: false, - default: false, + default: 'off', + options: [ + { value: 'off', label: 'Off' }, + { value: 'administrators', label: 'Administrators' }, + { value: 'everyone', label: 'Everyone' }, + ], description: - 'Users without an authenticator enrolled are blocked from data once their grace period ends. Enabling this also turns on the two-factor feature so users can enroll.', + 'Who must use an authenticator app: nobody, administrators (platform, organization and delegated administrators), or everyone. Those required who have not enrolled are blocked from data once their grace period ends. Any requirement also turns on the two-factor feature so people can enroll. Every organization is held to at least this level and may require more in its access policy.', visible: "${data.email_password_enabled !== false}", }, { @@ -300,7 +311,7 @@ const manifest = { min: 0, max: 90, description: 'How long users may defer enrollment before the hard block. 0 blocks immediately.', - visible: "${data.mfa_required === true}", + visible: "${data.mfa_required !== 'off'}", }, { type: 'group', @@ -337,7 +348,7 @@ const manifest = { default: 0, min: 0, max: 10080, - description: 'Sign a user out after this many minutes of inactivity. 0 disables.', + description: 'Sign a user out after this many minutes of inactivity. 0 disables. Every organization is held to at most this, and may set a shorter limit in its access policy.', }, { type: 'number', @@ -347,7 +358,7 @@ const manifest = { default: 0, min: 0, max: 8760, - description: 'Force re-authentication this many hours after sign-in, regardless of activity. 0 disables.', + description: 'Force re-authentication this many hours after sign-in, regardless of activity. 0 disables. Every organization is held to at most this, and may set a shorter limit in its access policy.', }, { type: 'number', @@ -357,7 +368,7 @@ const manifest = { default: 0, min: 0, max: 100, - description: 'Cap simultaneous signed-in sessions per user; the oldest are signed out past the cap. 0 = unlimited.', + description: 'Cap simultaneous signed-in sessions per user; the oldest are signed out past the cap. 0 = unlimited. Every organization is held to at most this, and may set a lower cap in its access policy.', }, { @@ -373,7 +384,7 @@ const manifest = { label: 'Allowed IP ranges', required: false, description: - 'CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, sign-in from outside these ranges is rejected, and so is any request whose client address the server cannot establish. Empty = no restriction. The address judged is the one the connection comes from; behind a reverse proxy, the server must be configured to trust that proxy so the original client address is judged instead.', + 'CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, sign-in from outside these ranges is rejected, and so is any request whose client address the server cannot establish. Empty = no restriction. The address judged is the one the connection comes from; behind a reverse proxy, the server must be configured to trust that proxy so the original client address is judged instead. Every organization is held to these ranges and may narrow them in its access policy.', }, // [#23367, ruling A] The API-key lifecycle policy. Two readers, one // source: `bindAuthSettings` binds these into the auth service diff --git a/packages/services/service-settings/src/manifests/index.ts b/packages/services/service-settings/src/manifests/index.ts index 44fb9522b60..ee80e77530a 100644 --- a/packages/services/service-settings/src/manifests/index.ts +++ b/packages/services/service-settings/src/manifests/index.ts @@ -2,6 +2,7 @@ /** Reference manifests bundled with service-settings. */ export { authSettingsManifest } from './auth.manifest.js'; +export { accessPolicySettingsManifest } from './access-policy.manifest.js'; export { mailSettingsManifest, mailTestActionHandler } from './mail.manifest.js'; export { smsSettingsManifest, smsTestActionHandler } from './sms.manifest.js'; export { brandingSettingsManifest } from './branding.manifest.js'; @@ -16,6 +17,7 @@ export { localizationSettingsManifest } from './localization.manifest.js'; export { companySettingsManifest } from './company.manifest.js'; import { authSettingsManifest } from './auth.manifest.js'; +import { accessPolicySettingsManifest } from './access-policy.manifest.js'; import { mailSettingsManifest } from './mail.manifest.js'; import { smsSettingsManifest } from './sms.manifest.js'; import { brandingSettingsManifest } from './branding.manifest.js'; @@ -31,6 +33,7 @@ export const builtinSettingsManifests = [ companySettingsManifest, localizationSettingsManifest, authSettingsManifest, + accessPolicySettingsManifest, mailSettingsManifest, smsSettingsManifest, storageSettingsManifest, diff --git a/packages/services/service-settings/src/translations/ar.ts b/packages/services/service-settings/src/translations/ar.ts index 5682fb761bd..b6ffeabbdfc 100644 --- a/packages/services/service-settings/src/translations/ar.ts +++ b/packages/services/service-settings/src/translations/ar.ts @@ -323,6 +323,7 @@ export const ar: PlatformTranslationData = { }, mfa_required: { label: 'طلب المصادقة متعددة العوامل', + options: { off: 'متوقف', administrators: 'المسؤولون', everyone: 'الجميع' }, help: 'يُمنع المستخدمون الذين لم يسجّلوا تطبيق مصادقة من الوصول إلى البيانات بعد انتهاء فترة السماح. تفعيل هذا الخيار يشغّل أيضًا ميزة التحقق الثنائي ليتمكن المستخدمون من التسجيل.', }, mfa_grace_period_days: { @@ -380,6 +381,26 @@ export const ar: PlatformTranslationData = { google_client_secret: { label: 'سر عميل Google', help: 'يُخزَّن مشفّرًا. ويمكن ضبطه أيضًا في إعدادات الخادم.' }, }, }, + access_policy: { + title: 'سياسة الوصول', + description: 'قيود تسجيل الدخول لهذه المؤسسة. تنطبق إعدادات المصادقة في النشر دائمًا؛ ولا يمكن لهذه الإعدادات إلا أن تضيف إليها.', + groups: { + network: { title: 'الشبكة', description: 'من أين يمكن لأعضاء هذه المؤسسة تسجيل الدخول.' }, + sessions: { title: 'الجلسات', description: 'مدة صلاحية جلسة العضو. لا يمكن لأي حد إلا أن يكون أقصر من حد النشر.' }, + multi_factor: { title: 'المصادقة متعددة العوامل', description: 'من في هذه المؤسسة يجب أن يحمي حسابه بتطبيق مصادقة (TOTP).' }, + }, + fields: { + allowed_ip_ranges: { label: 'نطاقات IP المسموح بها' }, + session_idle_timeout_minutes: { label: 'مهلة الخمول (بالدقائق)' }, + session_absolute_max_hours: { label: 'الحد الأقصى لعمر الجلسة (بالساعات)' }, + max_concurrent_sessions_per_user: { label: 'الحد الأقصى للجلسات المتزامنة لكل مستخدم' }, + mfa_required: { + label: 'اشتراط المصادقة متعددة العوامل', + options: { off: 'متوقف', administrators: 'المسؤولون', everyone: 'الجميع' }, + }, + }, + }, + storage: { title: 'تخزين الملفات', description: 'الواجهة الخلفية المستخدمة للمرفقات والتصديرات وملفات المستخدمين المرفوعة.', diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index 32cb83cb020..9f5a0a6bc4c 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -228,6 +228,26 @@ export const en: PlatformTranslationData = { }, }, + access_policy: { + title: 'Access policy', + description: 'This organization\'s sign-in restrictions. The deployment\'s authentication settings always apply; these can only add to them.', + groups: { + network: { title: 'Network', description: 'Where members of this organization may sign in from.' }, + sessions: { title: 'Sessions', description: 'How long a member\'s session stays valid. Each limit can only be shorter than the deployment\'s.' }, + multi_factor: { title: 'Multi-factor', description: 'Who in this organization must protect their account with an authenticator app (TOTP).' }, + }, + fields: { + allowed_ip_ranges: { label: 'Allowed IP ranges' }, + session_idle_timeout_minutes: { label: 'Idle timeout (minutes)' }, + session_absolute_max_hours: { label: 'Absolute session lifetime (hours)' }, + max_concurrent_sessions_per_user: { label: 'Max concurrent sessions per user' }, + mfa_required: { + label: 'Require multi-factor authentication', + options: { off: 'Off', administrators: 'Administrators', everyone: 'Everyone' }, + }, + }, + }, + storage: { title: 'File Storage', description: 'Backend used for attachments, exports, and user uploads.', diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 04d66e28827..2274e27cb57 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -212,6 +212,7 @@ export const esES: PlatformTranslationData = { }, mfa_required: { label: 'Requerir autenticación multifactor', + options: { off: 'Desactivado', administrators: 'Administradores', everyone: 'Todos' }, help: 'Los usuarios sin un autenticador registrado quedan bloqueados del acceso a los datos al terminar su periodo de gracia. Activarlo también habilita la función de dos factores para que los usuarios puedan registrarse.', }, mfa_grace_period_days: { @@ -352,6 +353,26 @@ export const esES: PlatformTranslationData = { }, }, + access_policy: { + title: 'Política de acceso', + description: 'Las restricciones de inicio de sesión de esta organización. Los ajustes de autenticación de la implementación siempre se aplican; estos solo pueden añadirles restricciones.', + groups: { + network: { title: 'Red', description: 'Desde dónde pueden iniciar sesión los miembros de esta organización.' }, + sessions: { title: 'Sesiones', description: 'Cuánto dura la sesión de un miembro. Cada límite solo puede ser más corto que el de la implementación.' }, + multi_factor: { title: 'Multifactor', description: 'Quién en esta organización debe proteger su cuenta con una app de autenticación (TOTP).' }, + }, + fields: { + allowed_ip_ranges: { label: 'Rangos de IP permitidos' }, + session_idle_timeout_minutes: { label: 'Tiempo de inactividad (minutos)' }, + session_absolute_max_hours: { label: 'Duración máxima de la sesión (horas)' }, + max_concurrent_sessions_per_user: { label: 'Máximo de sesiones simultáneas por usuario' }, + mfa_required: { + label: 'Exigir autenticación multifactor', + options: { off: 'Desactivado', administrators: 'Administradores', everyone: 'Todos' }, + }, + }, + }, + storage: { title: 'Almacenamiento de archivos', description: 'Backend usado para adjuntos, exportaciones y subidas de usuarios.', diff --git a/packages/services/service-settings/src/translations/fr.ts b/packages/services/service-settings/src/translations/fr.ts index f4aa79f7bda..b5805bdbccb 100644 --- a/packages/services/service-settings/src/translations/fr.ts +++ b/packages/services/service-settings/src/translations/fr.ts @@ -353,6 +353,7 @@ export const fr: PlatformTranslationData = { }, mfa_required: { label: 'Exiger l’authentification multifacteur', + options: { off: 'Désactivé', administrators: 'Administrateurs', everyone: 'Tout le monde' }, help: 'Les utilisateurs sans application d’authentification enregistrée perdent l’accès aux données à la fin de leur délai de grâce. L’activer active aussi la fonction à deux facteurs pour que les utilisateurs puissent s’enregistrer.', }, mfa_grace_period_days: { @@ -413,6 +414,26 @@ export const fr: PlatformTranslationData = { }, }, }, + access_policy: { + title: 'Politique d’accès', + description: 'Les restrictions de connexion de cette organisation. Les paramètres d’authentification du déploiement s’appliquent toujours ; ceux-ci ne peuvent qu’y ajouter.', + groups: { + network: { title: 'Réseau', description: 'D’où les membres de cette organisation peuvent se connecter.' }, + sessions: { title: 'Sessions', description: 'Combien de temps la session d’un membre reste valide. Chaque limite peut seulement être plus courte que celle du déploiement.' }, + multi_factor: { title: 'Multifacteur', description: 'Qui, dans cette organisation, doit protéger son compte avec une application d’authentification (TOTP).' }, + }, + fields: { + allowed_ip_ranges: { label: 'Plages d’adresses IP autorisées' }, + session_idle_timeout_minutes: { label: 'Délai d’inactivité (minutes)' }, + session_absolute_max_hours: { label: 'Durée de vie maximale de la session (heures)' }, + max_concurrent_sessions_per_user: { label: 'Sessions simultanées max. par utilisateur' }, + mfa_required: { + label: 'Exiger l’authentification multifacteur', + options: { off: 'Désactivé', administrators: 'Administrateurs', everyone: 'Tout le monde' }, + }, + }, + }, + storage: { title: 'Stockage des fichiers', description: 'Backend utilisé pour les pièces jointes, les exports et les fichiers envoyés par les utilisateurs.', diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 84891df9ae5..4338cd1994a 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -212,6 +212,7 @@ export const jaJP: PlatformTranslationData = { }, mfa_required: { label: '多要素認証を必須にする', + options: { off: 'オフ', administrators: '管理者', everyone: '全員' }, help: '認証アプリを登録していないユーザーは、猶予期間の終了後にデータへのアクセスをブロックされます。これを有効にすると、ユーザーが登録できるよう二要素機能もオンになります。', }, mfa_grace_period_days: { @@ -352,6 +353,26 @@ export const jaJP: PlatformTranslationData = { }, }, + access_policy: { + title: 'アクセスポリシー', + description: 'この組織のサインイン制限です。デプロイの認証設定は常に適用され、ここではそれに制限を追加することしかできません。', + groups: { + network: { title: 'ネットワーク', description: 'この組織のメンバーがサインインできる場所。' }, + sessions: { title: 'セッション', description: 'メンバーのセッションの有効期間。各上限はデプロイの上限より短くすることしかできません。' }, + multi_factor: { title: '多要素認証', description: 'この組織で認証アプリ (TOTP) によるアカウント保護が必須となる人。' }, + }, + fields: { + allowed_ip_ranges: { label: '許可する IP 範囲' }, + session_idle_timeout_minutes: { label: 'アイドルタイムアウト (分)' }, + session_absolute_max_hours: { label: 'セッションの最大有効期間 (時間)' }, + max_concurrent_sessions_per_user: { label: 'ユーザーあたりの最大同時セッション数' }, + mfa_required: { + label: '多要素認証を必須にする', + options: { off: 'オフ', administrators: '管理者', everyone: '全員' }, + }, + }, + }, + storage: { title: 'ファイルストレージ', description: '添付ファイル・エクスポート・ユーザーアップロードに使用するバックエンド。', diff --git a/packages/services/service-settings/src/translations/pt-BR.ts b/packages/services/service-settings/src/translations/pt-BR.ts index 34d4f36b1a3..7a2a6e24991 100644 --- a/packages/services/service-settings/src/translations/pt-BR.ts +++ b/packages/services/service-settings/src/translations/pt-BR.ts @@ -340,6 +340,7 @@ export const ptBR: PlatformTranslationData = { }, mfa_required: { label: 'Exigir autenticação multifator', + options: { off: 'Desativado', administrators: 'Administradores', everyone: 'Todos' }, help: 'Usuários sem um autenticador cadastrado perdem o acesso aos dados quando o período de carência termina. Ativar isto também liga o recurso de dois fatores para que os usuários possam se cadastrar.', }, mfa_grace_period_days: { @@ -400,6 +401,26 @@ export const ptBR: PlatformTranslationData = { }, }, }, + access_policy: { + title: 'Política de acesso', + description: 'As restrições de login desta organização. As configurações de autenticação da implantação sempre se aplicam; estas só podem acrescentar restrições.', + groups: { + network: { title: 'Rede', description: 'De onde os membros desta organização podem fazer login.' }, + sessions: { title: 'Sessões', description: 'Por quanto tempo a sessão de um membro permanece válida. Cada limite só pode ser menor que o da implantação.' }, + multi_factor: { title: 'Multifator', description: 'Quem nesta organização deve proteger a conta com um aplicativo autenticador (TOTP).' }, + }, + fields: { + allowed_ip_ranges: { label: 'Faixas de IP permitidas' }, + session_idle_timeout_minutes: { label: 'Tempo limite de inatividade (minutos)' }, + session_absolute_max_hours: { label: 'Duração máxima da sessão (horas)' }, + max_concurrent_sessions_per_user: { label: 'Máximo de sessões simultâneas por usuário' }, + mfa_required: { + label: 'Exigir autenticação multifator', + options: { off: 'Desativado', administrators: 'Administradores', everyone: 'Todos' }, + }, + }, + }, + storage: { title: 'Armazenamento de arquivos', description: 'Back-end usado para anexos, exportações e uploads dos usuários.', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 26ab043ab76..3e97e05c42b 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -352,6 +352,7 @@ export const zhCN: PlatformTranslationData = { }, mfa_required: { label: '要求多因素认证', + options: { off: '不强制', administrators: '管理员', everyone: '所有人' }, help: '未注册身份验证器的用户在宽限期结束后将被阻止访问数据。启用此项也会开启两步验证功能,以便用户注册。', }, mfa_grace_period_days: { @@ -407,6 +408,26 @@ export const zhCN: PlatformTranslationData = { }, }, + access_policy: { + title: '访问策略', + description: '本组织的登录限制。部署的认证设置始终生效;这里只能在其之上追加限制。', + groups: { + network: { title: '网络', description: '本组织成员可以从哪里登录。' }, + sessions: { title: '会话', description: '成员会话的有效时长。每项限制只能比部署的更短。' }, + multi_factor: { title: '多因素认证', description: '本组织中哪些人必须用身份验证器应用(TOTP)保护账户。' }, + }, + fields: { + allowed_ip_ranges: { label: '允许的 IP 范围' }, + session_idle_timeout_minutes: { label: '闲置超时(分钟)' }, + session_absolute_max_hours: { label: '会话最长有效期(小时)' }, + max_concurrent_sessions_per_user: { label: '每个用户的最大同时会话数' }, + mfa_required: { + label: '要求多因素认证', + options: { off: '不强制', administrators: '管理员', everyone: '所有人' }, + }, + }, + }, + storage: { title: '文件存储', description: '附件、导出文件与用户上传所使用的存储后端。', diff --git a/packages/services/service-settings/src/translations/zh-Hant.ts b/packages/services/service-settings/src/translations/zh-Hant.ts index 3420c43bb51..2cca6965641 100644 --- a/packages/services/service-settings/src/translations/zh-Hant.ts +++ b/packages/services/service-settings/src/translations/zh-Hant.ts @@ -251,7 +251,7 @@ export const zhHant: PlatformTranslationData = { lockout_duration_minutes: { label: '鎖定時間(分鐘)', help: '超過門檻後帳戶維持鎖定的時間,兩個登入階段皆適用。' }, rate_limit_max: { label: '驗證限流:最大請求數', help: '每個 IP 在每個時間窗內對登入/註冊/重設密碼端點的最大請求數。' }, rate_limit_window_seconds: { label: '驗證限流:時間窗(秒)', help: '計算上方請求上限所用的滑動時間窗。' }, - mfa_required: { label: '要求多重要素驗證', help: '尚未註冊驗證器的使用者,在寬限期結束後將無法存取資料。啟用此項也會開啟雙重驗證功能,讓使用者可以註冊。' }, + mfa_required: { label: '要求多重要素驗證', help: '尚未註冊驗證器的使用者,在寬限期結束後將無法存取資料。啟用此項也會開啟雙重驗證功能,讓使用者可以註冊。', options: { off: '不強制', administrators: '管理員', everyone: '所有人' } }, mfa_grace_period_days: { label: 'MFA 寬限期(天)', help: '使用者在被強制封鎖前可延後註冊的時間。0 表示立即封鎖。' }, session_expiry_days: { label: '工作階段有效期(天)', help: '工作階段在登入後這麼多天到期。' }, session_refresh_days: { label: '更新門檻(天)', help: '使用中的工作階段超過此時間後會自動延長。' }, @@ -280,6 +280,26 @@ export const zhHant: PlatformTranslationData = { google_client_secret: { label: 'Google 用戶端密鑰', help: '加密儲存。也可在伺服器設定中設定。' }, }, }, + access_policy: { + title: '存取原則', + description: '本組織的登入限制。部署的驗證設定一律生效;這裡只能在其之上追加限制。', + groups: { + network: { title: '網路', description: '本組織成員可以從哪裡登入。' }, + sessions: { title: '工作階段', description: '成員工作階段的有效時長。每項限制只能比部署的更短。' }, + multi_factor: { title: '多因素驗證', description: '本組織中哪些人必須以驗證器應用程式(TOTP)保護帳戶。' }, + }, + fields: { + allowed_ip_ranges: { label: '允許的 IP 範圍' }, + session_idle_timeout_minutes: { label: '閒置逾時(分鐘)' }, + session_absolute_max_hours: { label: '工作階段最長有效期(小時)' }, + max_concurrent_sessions_per_user: { label: '每位使用者的最大同時工作階段數' }, + mfa_required: { + label: '要求多因素驗證', + options: { off: '不強制', administrators: '管理員', everyone: '所有人' }, + }, + }, + }, + storage: { title: '檔案儲存', description: '附件、匯出檔案與使用者上傳所使用的儲存後端。', From e2e21140fad8486fa500f70bc00d8baf094e6ea3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:55:59 +0000 Subject: [PATCH 02/17] wip: tests for per-organization access policy Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../sys-organization-update-door.test.ts | 15 +- .../plugin-auth/src/auth-manager.test.ts | 59 +- .../plugin-auth/src/auth-plugin.test.ts | 4 +- .../src/auth-settings-clear.pin.test.ts | 14 +- .../auth-settings-refusal-isolation.test.ts | 8 +- .../src/managed-extension-fields.test.ts | 2 +- .../mfa-required-enrollment-predicate.test.ts | 30 +- ...ganization-access-policy-mfa-level.test.ts | 126 +++ .../src/organization-access-policy.test.ts | 300 +++++++ .../organization-mfa-enrollment-guard.test.ts | 793 ++++-------------- .../src/settings-copy-locale.test.ts | 33 +- .../src/sys-organization-update-door.test.ts | 10 +- .../organization-update-door.dogfood.test.ts | 23 +- .../src/manifests/auth.manifest.test.ts | 12 +- .../service-settings/src/translations/ar.ts | 2 +- .../service-settings/src/translations/en.ts | 2 +- .../src/translations/es-ES.ts | 2 +- .../service-settings/src/translations/fr.ts | 2 +- .../src/translations/ja-JP.ts | 2 +- .../src/translations/pt-BR.ts | 2 +- .../src/translations/zh-CN.ts | 2 +- .../src/translations/zh-Hant.ts | 2 +- ...eld-session-vendor-admin-doors.pin.test.ts | 12 +- 23 files changed, 706 insertions(+), 751 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts create mode 100644 packages/plugins/plugin-auth/src/organization-access-policy.test.ts diff --git a/packages/platform-objects/src/identity/sys-organization-update-door.test.ts b/packages/platform-objects/src/identity/sys-organization-update-door.test.ts index 33a9ee0e027..6232df46466 100644 --- a/packages/platform-objects/src/identity/sys-organization-update-door.test.ts +++ b/packages/platform-objects/src/identity/sys-organization-update-door.test.ts @@ -94,19 +94,16 @@ describe('#15873 — better-auth keeps its own door for its own columns', () => }); }); -describe('#30411 — require_mfa\'s help says it needs two-factor on, in the source and in every locale', () => { +describe('require_mfa is retired: its help sends the reader to the access policy, 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('the column is readonly, and its description names where the requirement lives now', () => { + expect(SysOrganization.fields.require_mfa?.readonly).toBe(true); + expect(SysOrganization.fields.require_mfa?.description).toMatch(/Access policy/); }); - 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.each(Object.entries(bundles))('%s: the generated help is present', (_locale, bundle) => { + expect((bundle as any).sys_organization?.fields?.require_mfa?.help).toEqual(expect.any(String)); }); it('the en bundle is the source, verbatim (the generator writes it)', () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 9f9c69815f2..9323f0545ce 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3879,7 +3879,7 @@ describe('AuthManager', () => { // Every posture computable at once: enforced MFA with no grace, and a // one-day expiry. Each row below decides which postures its user holds. // [#30201] Two-factor on: the gate holds a user for MFA only while they can enroll. - const POLICY = { mfaRequired: true, mfaGracePeriodDays: 0, passwordExpiryDays: 1, plugins: { twoFactor: true } }; + const POLICY = { mfaRequirement: 'everyone', mfaGracePeriodDays: 0, passwordExpiryDays: 1, plugins: { twoFactor: true } }; const sessionGateFor = async (row: Record, session: Record) => { const engine = makeDataEngine({ platformAdmin: false }); // The block's double, its `findOne` answering the posture read of the user row. @@ -4709,15 +4709,15 @@ describe('AuthManager', () => { return m; }; - it('isAuthGateActive is true when mfaRequired (even with expiry off)', () => { - expect(mgr(makeEngine(null), { mfaRequired: true }).isAuthGateActive()).toBe(true); + it('isAuthGateActive is true when mfaRequirement is set (even with expiry off)', () => { + expect(mgr(makeEngine(null), { mfaRequirement: 'everyone' }).isAuthGateActive()).toBe(true); expect(mgr(makeEngine(null), {}).isAuthGateActive()).toBe(false); }); it('blocks MFA_REQUIRED for an un-enrolled user past the grace window', async () => { const old = new Date(Date.now() - 30 * 86_400_000).toISOString(); const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: old }), { - mfaRequired: true, mfaGracePeriodDays: 7, + mfaRequirement: 'everyone', mfaGracePeriodDays: 7, }); const g = await (m as any).computeAuthGate('u1', undefined, false); expect(g?.code).toBe('MFA_REQUIRED'); @@ -4726,14 +4726,14 @@ describe('AuthManager', () => { it('does NOT block within the grace window', async () => { const recent = new Date(Date.now() - 1 * 86_400_000).toISOString(); const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: recent }), { - mfaRequired: true, mfaGracePeriodDays: 7, + mfaRequirement: 'everyone', mfaGracePeriodDays: 7, }); await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); }); it('stamps mfa_required_at the first time (null) and does not block yet', async () => { const engine = makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: null }); - const m = mgr(engine, { mfaRequired: true, mfaGracePeriodDays: 7 }); + const m = mgr(engine, { mfaRequirement: 'everyone', mfaGracePeriodDays: 7 }); await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); const wrote = engine.update.mock.calls.find((c: any[]) => 'mfa_required_at' in (c[1] ?? {})); expect(wrote).toBeTruthy(); @@ -4743,7 +4743,7 @@ describe('AuthManager', () => { it('does NOT block an enrolled user (two_factor_enabled)', async () => { const old = new Date(Date.now() - 30 * 86_400_000).toISOString(); const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: true, mfa_required_at: old }), { - mfaRequired: true, mfaGracePeriodDays: 7, + mfaRequirement: 'everyone', mfaGracePeriodDays: 7, }); await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); }); @@ -4751,7 +4751,7 @@ describe('AuthManager', () => { it('grace 0 blocks an un-enrolled user immediately (after the clock is set)', async () => { const past = new Date(Date.now() - 1000).toISOString(); const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: past }), { - mfaRequired: true, mfaGracePeriodDays: 0, + mfaRequirement: 'everyone', mfaGracePeriodDays: 0, }); const g = await (m as any).computeAuthGate('u1', undefined, false); expect(g?.code).toBe('MFA_REQUIRED'); @@ -4767,12 +4767,12 @@ describe('AuthManager', () => { it('two-factor off: no hold and no grace clock started, said once with the remedy', async () => { const log = logger(); const lapsed = makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: OLD() }); - const m = mgr(lapsed, { mfaRequired: true, mfaGracePeriodDays: 0, plugins: {}, logger: log }); + const m = mgr(lapsed, { mfaRequirement: 'everyone', mfaGracePeriodDays: 0, plugins: {}, logger: log }); expect(m.isTwoFactorEnabled()).toBe(false); await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); expect(await m.resolveUserAuthGates('u1')).toEqual([]); const fresh = makeEngine({ id: 'u2', two_factor_enabled: false, mfa_required_at: null }); - const m2 = mgr(fresh, { mfaRequired: true, plugins: {}, logger: log }); + const m2 = mgr(fresh, { mfaRequirement: 'everyone', plugins: {}, logger: log }); expect(await m2.resolveUserAuthGates('u2')).toEqual([]); // The window opens when enrollment does, not before. expect(fresh.update).not.toHaveBeenCalled(); @@ -4789,7 +4789,7 @@ describe('AuthManager', () => { process.env.OS_AUTH_TWO_FACTOR = 'false'; try { const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: OLD() }), { - mfaRequired: true, mfaGracePeriodDays: 0, logger: logger(), + mfaRequirement: 'everyone', mfaGracePeriodDays: 0, logger: logger(), }); expect(m.isTwoFactorEnabled()).toBe(false); // The one answer: what is wired, what is advertised, what the gate asks. @@ -4803,12 +4803,12 @@ describe('AuthManager', () => { it('control: the same user is held once two-factor is on, and an enrolled user never is', async () => { const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: OLD() }), { - mfaRequired: true, mfaGracePeriodDays: 0, + mfaRequirement: 'everyone', mfaGracePeriodDays: 0, }); expect(m.isTwoFactorEnabled()).toBe(true); expect((await m.resolveUserAuthGates('u1')).map((g) => g.code)).toEqual(['MFA_REQUIRED']); const enrolled = mgr(makeEngine({ id: 'u1', two_factor_enabled: true, mfa_required_at: OLD() }), { - mfaRequired: true, mfaGracePeriodDays: 0, plugins: {}, + mfaRequirement: 'everyone', mfaGracePeriodDays: 0, plugins: {}, }); expect(await enrolled.resolveUserAuthGates('u1')).toEqual([]); }); @@ -4816,10 +4816,13 @@ describe('AuthManager', () => { }); // ADR-0069 D3 — per-org MFA tightening (an org may require MFA above the - // global floor). Uses an object-aware engine mock (sys_user + sys_organization). + // global floor). The organization's level is its `access_policy.mfa_required` + // setting, served here by an organization access-policy source built from the + // second argument (`{ id, mfa_required }`); the engine mock answers sys_user. describe('per-org enforced MFA (ADR-0069 D3)', () => { const SECRET = 'test-secret-at-least-32-chars-long'; const makeEngine = (user: any, org: any) => ({ + org, findOne: vi.fn(async (obj: string, q: any) => { assertEngineFindOnePredicate(obj, q); const row = obj === 'sys_organization' ? org : user; @@ -4837,6 +4840,10 @@ describe('AuthManager', () => { secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, plugins: { twoFactor: true }, ...extra, }); warn.mockRestore(); + m.setOrganizationAccessPolicySource({ + read: async (id: string) => (engine.org?.id === id ? { mfa_required: engine.org.mfa_required } : {}), + anyOrganizationRequiresMfa: async () => !!engine.org && engine.org.mfa_required !== 'off', + }); return m; }; @@ -4851,9 +4858,9 @@ describe('AuthManager', () => { const old = new Date(Date.now() - 30 * 86_400_000).toISOString(); const engine = makeEngine( { id: 'u1', two_factor_enabled: false, mfa_required_at: old }, - { id: 'org1', require_mfa: true }, + { id: 'org1', mfa_required: 'everyone' }, ); - const m = mgr(engine, { mfaRequired: false, mfaGracePeriodDays: 7 }); + const m = mgr(engine, { mfaRequirement: 'off', mfaGracePeriodDays: 7 }); (m as any)._orgMfaCache = { value: true, at: Date.now() }; // org-requires cache primed const g = await (m as any).computeAuthGate('u1', 'org1', false); expect(g?.code).toBe('MFA_REQUIRED'); @@ -4863,19 +4870,19 @@ describe('AuthManager', () => { const old = new Date(Date.now() - 30 * 86_400_000).toISOString(); const engine = makeEngine( { id: 'u1', two_factor_enabled: false, mfa_required_at: old }, - { id: 'org1', require_mfa: false }, + { id: 'org1', mfa_required: 'off' }, ); - const m = mgr(engine, { mfaRequired: false }); + const m = mgr(engine, { mfaRequirement: 'off' }); (m as any)._orgMfaCache = { value: true, at: Date.now() }; await expect((m as any).computeAuthGate('u1', 'org1', false)).resolves.toBeUndefined(); }); - it('[#30201] does NOT block for the active org\'s require_mfa while two-factor is off, nor start the clock', async () => { + it('[#30201] does NOT block for the active org\'s MFA level while two-factor is off, nor start the clock', async () => { const engine = makeEngine( { id: 'u1', two_factor_enabled: false, mfa_required_at: null }, - { id: 'org1', require_mfa: true }, + { id: 'org1', mfa_required: 'everyone' }, ); - const m = mgr(engine, { mfaRequired: false, mfaGracePeriodDays: 0, plugins: {}, logger: { warn: vi.fn() } }); + const m = mgr(engine, { mfaRequirement: 'off', mfaGracePeriodDays: 0, plugins: {}, logger: { warn: vi.fn() } }); (m as any)._orgMfaCache = { value: true, at: Date.now() }; await expect((m as any).computeAuthGate('u1', 'org1', false)).resolves.toBeUndefined(); expect(engine.update).not.toHaveBeenCalled(); @@ -4883,7 +4890,7 @@ describe('AuthManager', () => { it('is a no-op when no org requires MFA and there is no active org', async () => { const engine = makeEngine({ id: 'u1', two_factor_enabled: false }, null); - const m = mgr(engine, { mfaRequired: false }); + const m = mgr(engine, { mfaRequirement: 'off' }); // cache says no org requires MFA (m as any)._orgMfaCache = { value: false, at: Date.now() }; await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); @@ -4904,7 +4911,7 @@ describe('AuthManager', () => { { id: 'u1', must_change_password: true, password_changed_at: OLD(), two_factor_enabled: false, mfa_required_at: OLD() }, null, ), - { passwordExpiryDays: 90, mfaRequired: true, mfaGracePeriodDays: 7 }, + { passwordExpiryDays: 90, mfaRequirement: 'everyone', mfaGracePeriodDays: 7 }, ); m.noteMustChangePasswordIssued(); const gates = await m.resolveUserAuthGates('u1'); @@ -4915,7 +4922,7 @@ describe('AuthManager', () => { it('answers [] for a user no policy holds, and reads nothing when no policy is enabled', async () => { const enrolled = makeEngine({ id: 'u1', two_factor_enabled: true }, null); - expect(await mgr(enrolled, { mfaRequired: true }).resolveUserAuthGates('u1')).toEqual([]); + expect(await mgr(enrolled, { mfaRequirement: 'everyone' }).resolveUserAuthGates('u1')).toEqual([]); const idle = makeEngine({ id: 'u1' }, null); const m = mgr(idle, {}); (m as any)._orgMfaCache = { value: false, at: Date.now() }; @@ -4927,7 +4934,7 @@ describe('AuthManager', () => { it('asks the organization the credential operates in for a per-org MFA requirement', async () => { const engine = makeEngine( { id: 'u1', two_factor_enabled: false, mfa_required_at: OLD() }, - { id: 'org_key', require_mfa: true }, + { id: 'org_key', mfa_required: 'everyone' }, ); const m = mgr(engine, { mfaGracePeriodDays: 7 }); (m as any)._orgMfaCache = { value: true, at: Date.now() }; @@ -4940,7 +4947,7 @@ describe('AuthManager', () => { it('fails open on a lookup error, as the session gate does', async () => { const engine = makeEngine(null, null); engine.findOne.mockImplementation(async () => { throw new Error('db down'); }); - const m = mgr(engine, { mfaRequired: true }); + const m = mgr(engine, { mfaRequirement: 'everyone' }); expect(await m.resolveUserAuthGates('u1')).toEqual([]); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 2de41076ee2..002ceb78f87 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1030,9 +1030,9 @@ describe('AuthPlugin', () => { }); it('binds mfa_required and force-enables the twoFactor plugin (ADR-0069 D3)', async () => { - const { manager } = await bootWithAuthSettings({ mfa_required: { value: true, source: 'global' } }); + const { manager } = await bootWithAuthSettings({ mfa_required: { value: 'everyone', source: 'global' } }); const cfg = (manager as any).config; - expect(cfg.mfaRequired).toBe(true); + expect(cfg.mfaRequirement).toBe('everyone'); expect(cfg.plugins?.twoFactor).toBe(true); expect((manager as any).isAuthGateActive()).toBe(true); }); diff --git a/packages/plugins/plugin-auth/src/auth-settings-clear.pin.test.ts b/packages/plugins/plugin-auth/src/auth-settings-clear.pin.test.ts index 79652ab0156..797a7207962 100644 --- a/packages/plugins/plugin-auth/src/auth-settings-clear.pin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-settings-clear.pin.test.ts @@ -198,23 +198,23 @@ describe('#30312 refusable pieces restore through the piece a save travels', () it('mfa_required cleared returns to a declared off, and enrolled factors stay verifiable', async () => { const { svc, manager } = await boot(); - await svc.set('auth', 'mfa_required', true, {}); - await vi.waitFor(() => expect(running(manager).mfaRequired).toBe(true)); + await svc.set('auth', 'mfa_required', 'everyone', {}); + await vi.waitFor(() => expect(running(manager).mfaRequirement).toBe('everyone')); expect((running(manager).plugins as any)?.twoFactor).toBe(true); await svc.set('auth', 'mfa_required', null, {}); - await vi.waitFor(() => expect(running(manager).mfaRequired).toBeUndefined()); + await vi.waitFor(() => expect(running(manager).mfaRequirement).toBeUndefined()); // As a save of off: the requirement goes, the enrollment path stays. expect((running(manager).plugins as any)?.twoFactor).toBe(true); }); it('mfa_required cleared returns to a declared on as one piece with its enrollment path', async () => { - const { svc, manager } = await boot({ plugin: { mfaRequired: true } }); - await svc.set('auth', 'mfa_required', false, {}); - await vi.waitFor(() => expect(running(manager).mfaRequired).toBe(false)); + const { svc, manager } = await boot({ plugin: { mfaRequirement: 'everyone' } }); + await svc.set('auth', 'mfa_required', 'off', {}); + await vi.waitFor(() => expect(running(manager).mfaRequirement).toBe('off')); await svc.set('auth', 'mfa_required', null, {}); - await vi.waitFor(() => expect(running(manager).mfaRequired).toBe(true)); + await vi.waitFor(() => expect(running(manager).mfaRequirement).toBe('everyone')); expect((running(manager).plugins as any)?.twoFactor).toBe(true); }); diff --git a/packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts b/packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts index 5a9c8cd2df2..edffee841bb 100644 --- a/packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts +++ b/packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts @@ -59,7 +59,7 @@ function makeEngine() { const SIBLINGS: Record = { password_min_length: { value: 12, source: 'global' }, // password policy — same emailAndPassword block password_require_complexity: { value: true, source: 'global' }, - mfa_required: { value: true, source: 'global' }, // MFA (with its twoFactor plugin) + mfa_required: { value: 'everyone', source: 'global' }, // MFA (with its twoFactor plugin) rate_limit_max: { value: 5, source: 'global' }, // rate limits session_expiry_days: { value: 3, source: 'global' }, // session lifetime google_enabled: { value: true, source: 'global' }, // social providers @@ -166,7 +166,7 @@ describe('auth settings pass — a refused key does not take its siblings with i expect(cfg.session?.expiresIn).toBe(sessionDays * 86_400); expect(cfg.emailAndPassword?.minPasswordLength).toBe(12); expect(cfg.passwordRequireComplexity).toBe(true); - expect(cfg.mfaRequired).toBe(true); + expect(cfg.mfaRequirement).toBe('everyone'); expect(cfg.plugins?.twoFactor).toBe(true); expect(cfg.rateLimit?.max).toBe(5); expect(cfg.socialProviders?.google?.clientId).toBe('google-settings-client-id'); @@ -258,7 +258,7 @@ describe('auth settings pass — a refused key does not take its siblings with i await saveAndReapply({ password_reject_breached: { value: true, source: 'global' }, - mfa_required: { value: true, source: 'global' }, + mfa_required: { value: 'everyone', source: 'global' }, session_expiry_days: { value: 7, source: 'global' }, password_min_length: { value: 12, source: 'global' }, rate_limit_max: { value: 5, source: 'global' }, @@ -272,7 +272,7 @@ describe('auth settings pass — a refused key does not take its siblings with i // the twoFactor plugin that lets a gated user enroll. expect(cfg.plugins?.passwordRejectBreached).toBeUndefined(); expect(cfg.plugins?.twoFactor).toBeUndefined(); - expect(cfg.mfaRequired).toBeUndefined(); + expect(cfg.mfaRequirement).toBeUndefined(); const refused = refusalLines(); expect(refused).toHaveLength(2); diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts index 2d515d895bc..86ae5429476 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -5,7 +5,7 @@ * objects. * * Extending a better-auth table is established practice (`sys_user.manager_id`, - * `sys_organization.require_mfa`, and now the D6 group-structure fields). The + * `sys_organization.timezone`, and now the D6 group-structure fields). The * hazard is a NAME COLLISION: if a better-auth upgrade introduces a field with * the same name, ownership of that column silently changes hands — our writes * start clobbering better-auth state, or better-auth's start clobbering ours, 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 index 5303f5e5353..e92c4036b82 100644 --- a/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts +++ b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts @@ -102,7 +102,7 @@ describe('the global mfa_required switch needs two-factor enrollment', () => { return { settings, manager, warn }; }; - const required = (manager: AuthManager) => (manager as any).config.mfaRequired === true; + const required = (manager: AuthManager) => ((manager as any).config.mfaRequirement ?? 'off') !== 'off'; // ── 1. The refusal ────────────────────────────────────────────────────────── @@ -110,7 +110,7 @@ describe('the global mfa_required switch needs two-factor enrollment', () => { process.env[ENV] = 'false'; const { settings, manager } = await boot(); - const refusal = await refusalOf(settings.setMany('auth', { mfa_required: true, mfa_grace_period_days: 3 })); + const refusal = await refusalOf(settings.setMany('auth', { mfa_required: 'everyone', mfa_grace_period_days: 3 })); expect(refusal.code).toBe('SETTINGS_VALIDATION'); expect(refusal.fields).toEqual([ expect.objectContaining({ @@ -120,13 +120,13 @@ describe('the global mfa_required switch needs two-factor enrollment', () => { }), ]); - expect(await settings.get('auth', 'mfa_required')).toMatchObject({ value: false, source: 'default' }); + expect(await settings.get('auth', 'mfa_required')).toMatchObject({ value: 'off', 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(); + await expect(settings.setMany('auth', { mfa_required: 'off' })).resolves.toBeDefined(); }); it('the settings PUT door answers 400 SETTINGS_VALIDATION at the key, in the caller language', async () => { @@ -167,7 +167,7 @@ describe('the global mfa_required switch needs two-factor enrollment', () => { const req = { params: { namespace: 'auth' }, query: {}, - body: { mfa_required: true }, + body: { mfa_required: 'everyone' }, headers: { 'accept-language': 'zh-CN,zh;q=0.9' }, method: 'PUT', path: '/api/settings/auth', @@ -190,8 +190,8 @@ describe('the global mfa_required switch needs two-factor enrollment', () => { 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 settings.setMany('auth', { mfa_required: 'administrators' }); + expect(await settings.get('auth', 'mfa_required')).toMatchObject({ value: 'administrators' }); await vi.waitFor(() => expect(required(manager)).toBe(true)); expect(manager.isTwoFactorEnabled()).toBe(true); expect(manager.getPublicConfig().features.twoFactor).toBe(true); @@ -201,12 +201,12 @@ describe('the global mfa_required switch needs two-factor enrollment', () => { 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 }); + await settings.setMany('auth', { mfa_required: 'administrators' }); 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(); + await expect(settings.setMany('auth', { mfa_required: 'administrators' })).resolves.toBeDefined(); // Read as on, enforced by nobody: `features.twoFactor` is what says so. expect(required(manager)).toBe(true); expect(manager.isTwoFactorEnabled()).toBe(false); @@ -217,17 +217,21 @@ describe('the global mfa_required switch needs two-factor enrollment', () => { 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' }); + expect(off({ next: 'everyone', current: 'off' })?.constraint).toEqual({ twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' }); + expect(off({ next: 'administrators', current: 'off' })).toBeDefined(); + // A move between the two levels turns nothing on that is not already on. + expect(off({ next: 'everyone', current: 'administrators' })).toBeUndefined(); + expect(off({ next: 'off', current: 'everyone' })).toBeUndefined(); // 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(); + expect(mfaRequiredEnrollmentPredicate(() => true)({ next: 'everyone', current: 'off' })).toBeUndefined(); // A language off the platform locale table falls back to English; French is on it. - expect(off({ next: true, current: false, locale: 'sw-KE' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.en); - expect(off({ next: true, current: false, locale: 'fr-FR' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.fr); + expect(off({ next: 'everyone', current: 'off', locale: 'sw-KE' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.en); + expect(off({ next: 'everyone', current: 'off', locale: 'fr-FR' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.fr); }); it('a settings service without write predicates is said at warn, only while the pin makes it matter', () => { diff --git a/packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts b/packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts new file mode 100644 index 00000000000..1cb5e46ea14 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ruling 「可以,按三档做(推荐)」] The multi-factor requirement's three + * levels, per organization: `administrators` holds ADR-0090 D12's three kinds + * — read through the existing predicates (the `PLATFORM_ADMIN` and + * `TENANT_ADMIN` posture rungs of `resolveUserAuthzGrants`, and a delegated + * set's `adminScope`) — and lets a plain member through; `everyone` holds + * both; two organizations in one deployment are each held to their own level. + * + * `resolveUserAuthzGrants` is replaced here by a table of postures, so the + * test reads the level's decision rather than the grant resolver's. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const POSTURES: Record = {}; + +vi.mock('@objectstack/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveUserAuthzGrants: vi.fn(async (_engine: unknown, userId: string, opts: { tenantId?: string } = {}) => { + const row = POSTURES[`${userId}@${opts.tenantId ?? ''}`] ?? {}; + return { positions: row.positions ?? [], permissions: row.permissions ?? [], posture: row.posture ?? 'MEMBER' }; + }), + }; +}); + +import { AuthManager } from './auth-manager'; + +const SECRET = 'test-secret-at-least-32-chars-long'; +const LAPSED = () => new Date(Date.now() - 30 * 86_400_000).toISOString(); +const ORG_A = 'org_alpha'; +const ORG_B = 'org_beta'; + +/** An engine answering an unenrolled user whose grace period has lapsed. */ +const unenrolledUsers = () => ({ + findOne: vi.fn(async (_obj: string, q: any) => ({ id: q?.where?.id, two_factor_enabled: false, mfa_required_at: LAPSED() })), + update: vi.fn(async () => ({})), + count: vi.fn(async () => 0), +}); + +function manager(levels: Record, opts: { deployment?: string; delegated?: Set } = {}) { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: unenrolledUsers(), + plugins: { twoFactor: true }, + mfaGracePeriodDays: 7, + ...(opts.deployment ? { mfaRequirement: opts.deployment } : {}), + } as any); + warn.mockRestore(); + m.setOrganizationAccessPolicySource({ + read: async (id) => ({ mfa_required: levels[id] ?? 'off' }), + anyOrganizationRequiresMfa: async () => Object.values(levels).some((l) => l !== 'off'), + isDelegatedAdministrator: async (userId) => opts.delegated?.has(userId) ?? false, + }); + (m as any)._orgMfaCache = { value: true, at: Date.now() }; + return m; +} + +const held = async (m: AuthManager, userId: string, organizationId: string) => + (await m.resolveUserAuthGates(userId, { organizationId })).some((g) => g.code === 'MFA_REQUIRED'); + +beforeEach(() => { + for (const key of Object.keys(POSTURES)) delete POSTURES[key]; +}); + +describe('the administrators level', () => { + it('holds an organization administrator and lets a plain member through', async () => { + POSTURES[`org_admin@${ORG_A}`] = { posture: 'TENANT_ADMIN' }; + POSTURES[`plain@${ORG_A}`] = { posture: 'MEMBER' }; + const m = manager({ [ORG_A]: 'administrators' }); + expect(await held(m, 'org_admin', ORG_A)).toBe(true); + expect(await held(m, 'plain', ORG_A)).toBe(false); + }); + + it('holds a tenant-level administrator and a delegated administrator too', async () => { + POSTURES[`platform@${ORG_A}`] = { posture: 'PLATFORM_ADMIN' }; + POSTURES[`delegate@${ORG_A}`] = { posture: 'MEMBER', permissions: ['bu_admin'] }; + const m = manager({ [ORG_A]: 'administrators' }, { delegated: new Set(['delegate']) }); + expect(await held(m, 'platform', ORG_A)).toBe(true); + expect(await held(m, 'delegate', ORG_A)).toBe(true); + }); + + it('is judged in the organization the session is scoped to', async () => { + // An administrator of Beta is a plain member in Alpha. + POSTURES[`carol@${ORG_B}`] = { posture: 'TENANT_ADMIN' }; + POSTURES[`carol@${ORG_A}`] = { posture: 'MEMBER' }; + const m = manager({ [ORG_A]: 'administrators', [ORG_B]: 'administrators' }); + expect(await held(m, 'carol', ORG_B)).toBe(true); + expect(await held(m, 'carol', ORG_A)).toBe(false); + }); + + it('a delegated check that fails holds the user rather than letting the requirement lapse', async () => { + const m = manager({ [ORG_A]: 'administrators' }); + m.setOrganizationAccessPolicySource({ + read: async () => ({ mfa_required: 'administrators' }), + anyOrganizationRequiresMfa: async () => true, + isDelegatedAdministrator: async () => { + throw new Error('catalog unreadable'); + }, + }); + (m as any)._orgMfaCache = { value: true, at: Date.now() }; + expect(await held(m, 'plain', ORG_A)).toBe(true); + }); +}); + +describe('two organizations, each held to its own level', () => { + it('everyone in one, off in the other', async () => { + const m = manager({ [ORG_A]: 'everyone', [ORG_B]: 'off' }); + expect(await held(m, 'plain', ORG_A)).toBe(true); + expect(await held(m, 'plain', ORG_B)).toBe(false); + }); + + it('an organization cannot go below the deployment\'s level', async () => { + POSTURES[`org_admin@${ORG_B}`] = { posture: 'TENANT_ADMIN' }; + // Beta stores `off`; the deployment requires administrators. + const m = manager({ [ORG_A]: 'everyone', [ORG_B]: 'off' }, { deployment: 'administrators' }); + expect(await held(m, 'org_admin', ORG_B)).toBe(true); + expect(await held(m, 'plain', ORG_B)).toBe(false); + expect(await held(m, 'plain', ORG_A)).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts new file mode 100644 index 00000000000..fa0dea92f17 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0069 TL;DR 6] The access policy, per organization + * (`organization-access-policy.ts`): the per-key floor rule, and two + * organizations in one deployment, each with its own allowed IP ranges and + * session limits, enforced on its own members through the real better-auth + * pipeline (`handleRequest`). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { AuthManager } from './auth-manager'; +import { + accessPolicyFloorRefusal, + accessPolicyWritePredicate, + effectiveMfaLevel, + effectiveSessionLimits, + rangeWithin, + readOrganizationAccessPolicy, + tighterLimit, +} from './organization-access-policy'; +import { allowedIpRangesWritePredicate } from './allowed-ip-ranges'; + +describe('the floor, per key', () => { + it('limits: the smaller positive value holds, 0 is no limit', () => { + expect(tighterLimit(60, 0)).toBe(60); + expect(tighterLimit(0, 15)).toBe(15); + expect(tighterLimit(60, 15)).toBe(15); + expect(tighterLimit(15, 60)).toBe(15); + expect(tighterLimit(0, 0)).toBe(0); + }); + + it('ranges: a range is inside another only when it is narrower and matched', () => { + expect(rangeWithin('10.1.0.0/16', '10.0.0.0/8')).toBe(true); + expect(rangeWithin('10.1.2.3', '10.0.0.0/8')).toBe(true); + expect(rangeWithin('10.0.0.0/8', '10.1.0.0/16')).toBe(false); + expect(rangeWithin('192.168.0.0/16', '10.0.0.0/8')).toBe(false); + expect(rangeWithin('2001:db8:1::/48', '2001:db8::/32')).toBe(true); + expect(rangeWithin('2001:db8::/32', '10.0.0.0/8')).toBe(false); + }); + + it('enforcement holds the floor whatever an organization stores', () => { + const floor = { sessionIdleTimeoutMinutes: 30, sessionAbsoluteMaxHours: 8, maxConcurrentSessions: 3, mfaRequirement: 'administrators' as const }; + // A looser stored row (written around the save door) changes nothing. + const loose = readOrganizationAccessPolicy({ + session_idle_timeout_minutes: 600, session_absolute_max_hours: 100, max_concurrent_sessions_per_user: 50, mfa_required: 'off', + }); + expect(effectiveSessionLimits(floor, loose)).toEqual({ idleMinutes: 30, absoluteHours: 8, concurrent: 3 }); + expect(effectiveMfaLevel(floor, loose)).toBe('administrators'); + const tight = readOrganizationAccessPolicy({ + session_idle_timeout_minutes: 10, session_absolute_max_hours: 2, max_concurrent_sessions_per_user: 1, mfa_required: 'everyone', + }); + expect(effectiveSessionLimits(floor, tight)).toEqual({ idleMinutes: 10, absoluteHours: 2, concurrent: 1 }); + expect(effectiveMfaLevel(floor, tight)).toBe('everyone'); + }); + + it('an organization cannot save a value below the deployment floor', () => { + const floor = { + allowedIpRanges: ['10.0.0.0/8'], + sessionIdleTimeoutMinutes: 30, + sessionAbsoluteMaxHours: 8, + maxConcurrentSessions: 3, + mfaRequirement: 'administrators' as const, + }; + // Loosening: refused, naming the deployment's value. + expect(accessPolicyFloorRefusal('allowed_ip_ranges', '10.1.0.0/16\n192.168.0.0/16', floor)?.constraint) + .toEqual({ range: '192.168.0.0/16', deploymentRanges: ['10.0.0.0/8'] }); + expect(accessPolicyFloorRefusal('session_idle_timeout_minutes', 31, floor)?.constraint).toEqual({ max: 30 }); + expect(accessPolicyFloorRefusal('session_absolute_max_hours', 9, floor)?.constraint).toEqual({ max: 8 }); + expect(accessPolicyFloorRefusal('max_concurrent_sessions_per_user', 4, floor)?.constraint).toEqual({ max: 3 }); + // Tightening, the deployment's own value, and each key's zero value: saved. + expect(accessPolicyFloorRefusal('allowed_ip_ranges', '10.1.0.0/16', floor)).toBeUndefined(); + expect(accessPolicyFloorRefusal('allowed_ip_ranges', '', floor)).toBeUndefined(); + expect(accessPolicyFloorRefusal('session_idle_timeout_minutes', 30, floor)).toBeUndefined(); + expect(accessPolicyFloorRefusal('session_idle_timeout_minutes', 0, floor)).toBeUndefined(); + expect(accessPolicyFloorRefusal('mfa_required', 'everyone', floor)).toBeUndefined(); + expect(accessPolicyFloorRefusal('mfa_required', 'administrators', floor)).toBeUndefined(); + expect(accessPolicyFloorRefusal('mfa_required', 'off', floor)).toBeUndefined(); + expect(accessPolicyFloorRefusal('mfa_required', null, floor)).toBeUndefined(); + // Against an `everyone` deployment, `administrators` is weaker. + expect(accessPolicyFloorRefusal('mfa_required', 'administrators', { mfaRequirement: 'everyone' })?.constraint) + .toEqual({ minimum: 'everyone' }); + // A deployment that sets nothing bounds nothing. + expect(accessPolicyFloorRefusal('allowed_ip_ranges', '192.168.0.0/16', {})).toBeUndefined(); + expect(accessPolicyFloorRefusal('session_idle_timeout_minutes', 9999, {})).toBeUndefined(); + }); + + it('the refusal is in the caller\'s language', () => { + const refusal = accessPolicyFloorRefusal('session_idle_timeout_minutes', 31, { sessionIdleTimeoutMinutes: 30 }, 'zh-CN'); + expect(refusal?.message).toContain('30'); + expect(refusal?.message).not.toMatch(/deployment/); + }); + + it('the save check refuses an organization list that leaves out the saving address', () => { + const manager = { resolveClientAddress: (t: { remoteAddress?: string }) => t.remoteAddress }; + const predicate = accessPolicyWritePredicate( + () => ({ allowedIpRanges: [] }), + allowedIpRangesWritePredicate(() => manager, { recovery: false }), + ); + const refusal = predicate({ key: 'allowed_ip_ranges', next: '10.1.0.0/16', transport: { remoteAddress: '192.0.2.7' } }); + expect(refusal?.constraint).toEqual({ clientAddress: '192.0.2.7' }); + // The env-override way back reopens only the deployment's list, so an + // organization's refusal does not name it. + expect(refusal?.message).not.toContain('OS_AUTH_ALLOWED_IP_RANGES'); + expect(predicate({ key: 'allowed_ip_ranges', next: '10.1.0.0/16', transport: { remoteAddress: '10.1.2.3' } })) + .toBeUndefined(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Two organizations in one deployment, through the real better-auth pipeline +// ─────────────────────────────────────────────────────────────────────────── + +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date ? new Date(a).getTime() === new Date(b).getTime() : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + if ('$gt' in v) return actual > v.$gt; + if ('$gte' in v) return actual >= v.$gte; + if ('$lt' in v) return actual < v.$lt; + if ('$lte' in v) return actual <= v.$lte; + } + return eq(actual, v); + }); + const project = (row: any, fields?: string[]) => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out: any = {}; + for (const f of ['id', ...fields]) if (f in row) out[f] = row[f]; + return out; + }; + let seq = 0; + return { + tables, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + assertEngineFindOnePredicate(name, q); + const row = rows(name).find((r) => matches(r, q.where)); + return row ? project(row, q.fields) : null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + if (q.limit) out = out.slice(0, q.limit); + return out.map((r) => project(r, q.fields)); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any) { + const row = rows(name).find((r) => r.id === patch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-30922'; +const ORG_A = 'org_alpha'; +const ORG_B = 'org_beta'; +const IN_A = '10.1.0.7'; +const IN_B = '10.2.0.7'; + +const post = (manager: AuthManager, path: string, body: unknown, remoteAddress: string, cookie?: string) => + manager.handleRequest( + new Request(`http://localhost:3000/api/v1/auth${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + }), + { remoteAddress }, + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +/** + * A deployment with two organizations, Alpha (members may come only from + * 10.1.0.0/16, idle timeout 5 minutes) and Beta (only from 10.2.0.0/16, no + * idle timeout), and one plain member of each. The deployment sets no list + * and no idle timeout of its own. + */ +async function twoOrganizationDeployment() { + const engine = createMemoryEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + membershipPolicy: 'invite-only', + } as any); + const policies: Record> = { + [ORG_A]: { allowed_ip_ranges: '10.1.0.0/16', session_idle_timeout_minutes: 5 }, + [ORG_B]: { allowed_ip_ranges: '10.2.0.0/16' }, + }; + manager.setOrganizationAccessPolicySource({ + read: async (id) => policies[id] ?? {}, + anyOrganizationRequiresMfa: async () => false, + }); + for (const [email, org] of [['alice@alpha.test', ORG_A], ['bob@beta.test', ORG_B]] as const) { + const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name: email }, '127.0.0.1'); + expect(res.status).toBe(200); + const user = (engine.tables.get('sys_user') ?? []).find((u: any) => u.email === email); + await engine.insert('sys_member', { user_id: user.id, organization_id: org, role: 'member', created_at: new Date() }); + } + return { engine, manager }; +} + +const signIn = (manager: AuthManager, email: string, remoteAddress: string) => + post(manager, '/sign-in/email', { email, password: PASSWORD }, remoteAddress); + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('two organizations, each policy enforced on its own members', () => { + it('each organization\'s allowed IP ranges admit its own members from its own ranges only', async () => { + const { manager } = await twoOrganizationDeployment(); + + const aliceHome = await signIn(manager, 'alice@alpha.test', IN_A); + expect(aliceHome.status).toBe(200); + const aliceAway = await signIn(manager, 'alice@alpha.test', IN_B); + expect(aliceAway.status).toBe(403); + expect(((await aliceAway.json()) as any).code).toBe('IP_NOT_ALLOWED'); + + const bobHome = await signIn(manager, 'bob@beta.test', IN_B); + expect(bobHome.status).toBe(200); + const bobAway = await signIn(manager, 'bob@beta.test', IN_A); + expect(bobAway.status).toBe(403); + expect(((await bobAway.json()) as any).code).toBe('IP_NOT_ALLOWED'); + }); + + it('a refused scoping leaves no session behind', async () => { + const { engine, manager } = await twoOrganizationDeployment(); + const before = (engine.tables.get('sys_session') ?? []).length; + expect((await signIn(manager, 'alice@alpha.test', IN_B)).status).toBe(403); + expect((engine.tables.get('sys_session') ?? []).length).toBe(before); + }); + + it('a tenant-level administrator is held by the deployment\'s list only (break-glass)', async () => { + const { manager } = await twoOrganizationDeployment(); + vi.spyOn(manager as any, 'isPlatformAdminUserId').mockResolvedValue(true); + expect((await signIn(manager, 'alice@alpha.test', IN_B)).status).toBe(200); + }); + + it('each organization\'s idle timeout ends its own members\' sessions only', async () => { + const { engine, manager } = await twoOrganizationDeployment(); + const alice = cookieFrom(await signIn(manager, 'alice@alpha.test', IN_A)); + const bob = cookieFrom(await signIn(manager, 'bob@beta.test', IN_B)); + // Both sessions were scoped at creation (ADR-0093 D9) and last active an hour ago. + const sessions = (engine.tables.get('sys_session') ?? []) as any[]; + const hourAgo = new Date(Date.now() - 60 * 60_000); + for (const row of sessions) { + expect([ORG_A, ORG_B]).toContain(row.active_organization_id ?? row.activeOrganizationId); + Object.assign(row, { last_activity_at: hourAgo }); + } + const getSession = (cookie: string, remoteAddress: string) => + manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/get-session', { headers: { cookie } }), + { remoteAddress }, + ); + // The detecting request revokes in place (ADR-0069 D4); the next one is anonymous. + await getSession(alice, IN_A); + await getSession(bob, IN_B); + const alive = async (cookie: string, address: string) => { + const res = await getSession(cookie, address); + if (res.status !== 200) return false; + return Boolean(((await res.json().catch(() => null)) as any)?.user?.id); + }; + expect(await alive(alice, IN_A)).toBe(false); + expect(await alive(bob, IN_B)).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts index 4c98c964c1d..161cee094f5 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts @@ -1,666 +1,195 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * An organization's MFA requirement never strands its members (ADR-0069 D3). + * An organization's access policy at its save door, on a real + * `SettingsService` (`organization-access-policy-binding.ts`): * - * Before this guard an administrator could tick `require_mfa` on an - * organization while the two-factor feature was off. The save went through, - * and once the grace period ended every member, the owner included, was held - * with `MFA_REQUIRED` and sent to an enrollment endpoint that answered 404. - * Three blocks hold the two halves of the fix and their conjunction: - * - * 1. the guard's rule over its own hook (which payloads it refuses, with - * what envelope, on which object); - * 2. the guard on a real ObjectQL engine over the real `sys_organization` - * object, composed behind the ADR-0092 D2 identity write guard the way - * `auth-plugin.ts` composes it: the by-id door an administrator's edit - * form uses, a multi-row update, a system insert, and the row left as it - * was after a refusal; - * 3. end to end on the real better-auth pipeline: for a member of an - * organization that requires MFA, past the grace window, the session - * never carries `MFA_REQUIRED` while `/two-factor/enable` answers 404 — - * with two-factor off the session is not held, and with it on the - * session is held AND the enrollment endpoint answers. + * 1. an organization's MFA requirement never strands its members + * (ADR-0069 D3, `organization-mfa-enrollment-guard.ts`): a requirement is + * refused while two-factor enrollment is off, and admitted once it is on; + * 2. an organization cannot save a value below the deployment's floor, in + * the settings service's own refusal envelope; + * 3. the toggle-era values move onto the three levels once, at boot: a + * stored `auth.mfa_required` toggle, and an organization's + * `sys_organization.require_mfa`. */ -import { describe, it, expect, afterAll, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import type { PluginContext } from '@objectstack/core'; -import { SysOrganization } from '@objectstack/platform-objects/identity'; +import { describe, it, expect, vi } from 'vitest'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { SettingsService, SettingsValidationError, builtinSettingsManifests } from '@objectstack/service-settings'; import { AuthManager } from './auth-manager'; -import { AuthPlugin } from './auth-plugin.js'; import { - ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY, - ORGANIZATION_MFA_REFUSAL_COPY, - ORGANIZATION_MFA_REQUIRED_NOTICE_COPY, - organizationMfaRequiredNotice, - organizationMfaWithoutEnrollmentRefusal, - registerOrganizationMfaEnrollmentGuard, - turnsMfaRequirementOn, -} from './organization-mfa-enrollment-guard.js'; -import { resolveMfaGracePeriodDays } from './auth-manager-password-session-policy.js'; -import { registerIdentityWriteGuard, registerManagedUpdateWhitelist } from './identity-write-guard.js'; -import { managedExtensionEditableFields } from './managed-extension-fields.js'; -// The SAME in-memory engine the sibling two-factor suites drive: a new fake -// would be a new `check:engine-double-contract` ledger entry for no fidelity. -import { createMemoryEngine } from './impersonation-bearer-rotation.test'; - -/** ADR-0112 / ADR-0114: the refusal is the per-field `VALIDATION_FAILED` envelope on `require_mfa`. */ -function expectRequireMfaRefusal(err: any): void { - expect(err, 'the write was admitted').toBeTruthy(); - // `mapDataError` in `@objectstack/rest` keys the 400 on exactly these two - // discriminators, so pinning both is what pins the status. - expect(err.code).toBe('VALIDATION_FAILED'); - expect(err.name).toBe('ValidationError'); - expect(err.fields).toEqual([ - expect.objectContaining({ field: 'require_mfa', code: 'invalid_value' }), - ]); + bindOrganizationAccessPolicy, + migrateLegacyMfaRequiredSetting, + migrateLegacyOrganizationRequireMfa, +} from './organization-access-policy-binding.js'; +import { ORGANIZATION_MFA_REFUSAL_COPY, organizationMfaEnrollmentPredicate } from './organization-mfa-enrollment-guard.js'; + +const SECRET = 'test-secret-at-least-32-chars-long'; +const ORG = 'org_alpha'; + +const manifest = (namespace: string) => builtinSettingsManifests.find((m) => m.namespace === namespace)!; + +function settingsService(): SettingsService { + const settings = new SettingsService({ env: {} }); + settings.registerManifest(manifest('auth')); + settings.registerManifest(manifest('access_policy')); + return settings; } -// ── 1. The rule ───────────────────────────────────────────────────────────── +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); -/** Fake engine capturing registrations, the shape the real engine builds. */ -function captureEngine() { - const registrations: Array<{ event: string; handler: (ctx: any) => Promise; options: any }> = []; - return { - registrations, - registerHook: (event: string, handler: (ctx: any) => Promise, options?: any) => { - registrations.push({ event, handler, options }); - }, - }; +function authManager(extra: Record = {}): AuthManager { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', ...extra } as any); + warn.mockRestore(); + return m; } -/** - * Run every registered handler for `event` over a payload, with the stored row - * as `previous` when given (the engine binds it on every update path); returns - * the thrown error, or null. - */ -async function judge( - enrollable: boolean, - event: 'beforeInsert' | 'beforeUpdate', - data: Record, - previous?: Record, -): Promise { - const engine = captureEngine(); - registerOrganizationMfaEnrollmentGuard(engine, { - packageId: 'test.organization-mfa-enrollment-guard', - isTwoFactorEnabled: () => enrollable, - mfaGracePeriodDays: () => 7, - }); - try { - for (const r of engine.registrations.filter((x) => x.event === event)) { - await r.handler({ - object: 'sys_organization', event, input: { id: 'org_1', data }, - ...(previous ? { previous } : {}), - }); - } - return null; - } catch (e) { - return e; - } +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('organization MFA enrollment guard — the rule', () => { - it('registers insert and update hooks on sys_organization alone, after the D2 identity write guard', () => { - const engine = captureEngine(); - registerOrganizationMfaEnrollmentGuard(engine, { - packageId: 'p', - isTwoFactorEnabled: () => false, - mfaGracePeriodDays: () => 7, - }); - expect(engine.registrations.map((r) => r.event).sort()).toEqual(['beforeInsert', 'beforeUpdate']); - for (const r of engine.registrations) { - expect(r.options).toEqual({ object: 'sys_organization', priority: 15, packageId: 'p' }); - } - // D2 runs at 10: a write it refuses is refused there first. - expect(ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY).toBeGreaterThan(10); - }); - - it.each([true, 1, 'true', 'TRUE', '1', 'yes'])('judges %j as turning the requirement on', (value) => { - expect(turnsMfaRequirementOn(value)).toBe(true); - }); - - it.each([false, 0, '0', 'false', ' False ', null, undefined])('judges %j as leaving it off', (value) => { - expect(turnsMfaRequirementOn(value)).toBe(false); - }); - - it('refuses require_mfa turned on, on insert and on update, while two-factor is off', async () => { - for (const event of ['beforeInsert', 'beforeUpdate'] as const) { - expectRequireMfaRefusal(await judge(false, event, { require_mfa: true })); - expectRequireMfaRefusal(await judge(false, event, { require_mfa: true, timezone: 'Asia/Shanghai' })); - } - }); - - it('admits the same write once two-factor is on (control: the refusal is the enrollment state\'s)', async () => { - for (const event of ['beforeInsert', 'beforeUpdate'] as const) { - expect(await judge(true, event, { require_mfa: true })).toBeNull(); - } - }); - - it('refuses an update that turns it on for a row stored off, and one with no stored row to prove otherwise', async () => { - expectRequireMfaRefusal(await judge(false, 'beforeUpdate', { require_mfa: true }, { require_mfa: false })); - expectRequireMfaRefusal(await judge(false, 'beforeUpdate', { require_mfa: true }, { require_mfa: null })); - expectRequireMfaRefusal(await judge(false, 'beforeUpdate', { require_mfa: true }, { name: 'Acme' })); - // No `previous` at all: an unproven prior state is judged as a change. - expectRequireMfaRefusal(await judge(false, 'beforeUpdate', { require_mfa: true })); - }); - - it('admits re-sending it for a row that already stores it on: the write turns nothing on', async () => { - // A whole-row SDK/MCP update, an import re-run, a save with no change. - expect(await judge(false, 'beforeUpdate', { require_mfa: true }, { require_mfa: true })).toBeNull(); - expect(await judge(false, 'beforeUpdate', { require_mfa: 1, name: 'Acme' }, { require_mfa: 1 })).toBeNull(); - // An insert has no stored value to keep: a `previous` there changes nothing. - expectRequireMfaRefusal(await judge(false, 'beforeInsert', { require_mfa: true }, { require_mfa: true })); - }); - - it('never judges a write that clears the requirement or leaves it alone', async () => { - for (const event of ['beforeInsert', 'beforeUpdate'] as const) { - expect(await judge(false, event, { require_mfa: false })).toBeNull(); - expect(await judge(false, event, { timezone: 'Asia/Shanghai' })).toBeNull(); - expect(await judge(false, event, { name: 'Acme' })).toBeNull(); - } - }); - - it('says why and what to do, with no tracker number', () => { - const err: any = organizationMfaWithoutEnrollmentRefusal(); - expectRequireMfaRefusal(err); - // The wording is not a parsed contract; these are the facts the administrator - // needs, asserted by their named subjects rather than by the sentence. - expect(err.message).toMatch(/two-factor enrollment is off/); - expect(err.message).toMatch(/owner included/); - expect(err.message).toMatch(/OS_AUTH_TWO_FACTOR=true/); - expect(err.message).toMatch(/auth\.mfa_required/); - // The env var outranks the settings: the settings remedy is offered only where it can work. - expect(err.message).toMatch(/Unless OS_AUTH_TWO_FACTOR is set to false/); - expect(err.message).not.toMatch(/#\d/); - expect(err.fields[0].message).toBe(err.message); - }); -}); - -describe('auth-plugin registers the guard on `kernel:ready` and asks the live manager', () => { - it('both hooks on sys_organization, after the D2 guard, judging the manager\'s two-factor state per write', async () => { - const registered: Array<{ event: string; handler: (ctx: any) => Promise; options: any }> = []; - const engine = { - registerHook: (event: string, handler: (ctx: any) => Promise, options: any) => { - registered.push({ event, handler, options }); - }, - getSchema: () => undefined, - }; - const ready: Array<() => Promise> = []; - const ctx = { - registerService: vi.fn(), - getService: vi.fn((name: string): any => { - if (name === 'manifest') return { register: vi.fn() }; - if (name === 'objectql') return engine; - return undefined; - }), - getServices: vi.fn(() => new Map()), - hook: vi.fn((name: string, handler: () => Promise) => { if (name === 'kernel:ready') ready.push(handler); }), - trigger: vi.fn(), - logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, - getKernel: vi.fn(), - } as unknown as PluginContext; - const plugin = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }); - await plugin.init(ctx); - await plugin.start(ctx); - for (const handler of ready) { - try { await handler(); } catch { /* other kernel:ready work needs services this double does not offer */ } - } - - const ours = registered.filter( - (r) => r.options?.packageId === 'com.objectstack.plugin-auth.organization-mfa-enrollment-guard', - ); - expect(ours.map((r) => [r.event, r.options.object, r.options.priority]).sort()).toEqual([ - ['beforeInsert', 'sys_organization', ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY], - ['beforeUpdate', 'sys_organization', ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY], - ]); - const d2 = registered.find( - (r) => r.event === 'beforeUpdate' && r.options?.packageId === 'com.objectstack.plugin-auth.identity-write-guard', - ); - expect(ORGANIZATION_MFA_ENROLLMENT_GUARD_PRIORITY).toBeGreaterThan(d2!.options.priority); - - const update = ours.find((r) => r.event === 'beforeUpdate')!; - const turnOn = () => update.handler({ object: 'sys_organization', input: { id: 'org_1', data: { require_mfa: true } } }); - // The default deployment: two-factor off. - expectRequireMfaRefusal(await turnOn().then(() => null, (e) => e)); - // The settings pass turns the plugin on (as `auth.mfa_required` does): the next write is admitted. - (plugin as any).authManager.applyConfigPatch({ plugins: { twoFactor: true } }); - await expect(turnOn()).resolves.toBeUndefined(); - - // [#30411] …and its save notice names the grace period the live manager - // holds, read per write: the settings pass changes it, the next write says so. - const told: string[] = []; - const turnOnTold = () => update.handler({ - object: 'sys_organization', input: { id: 'org_1', data: { require_mfa: true } }, - session: { userId: 'usr_admin', locale: 'en-US' }, notice: (m: string) => told.push(m), - }); - await turnOnTold(); - expect(told).toEqual([organizationMfaRequiredNotice(7, 'en')]); - (plugin as any).authManager.applyConfigPatch({ mfaGracePeriodDays: 14 }); - await turnOnTold(); - expect(told[1]).toBe(organizationMfaRequiredNotice(14, 'en')); - expect(told[1]).toMatch(/has 14 days to enroll/); - }); -}); - -// ── 2. On a real engine ───────────────────────────────────────────────────── - -const engines: ObjectQL[] = []; -afterAll(async () => { - for (const engine of engines) await (engine as any).destroy?.().catch?.(() => undefined); -}); - -const SYSTEM = { context: { isSystem: true, positions: [] as string[], permissions: [] as string[] } }; -/** A user-context write: the administrator saving the organization's edit form. */ -const ADMIN = { context: { userId: 'usr_admin', positions: [] as string[], permissions: [] as string[] } }; - -/** A real sqlite engine with the real organization object and the production hook composition. */ -async function bootOrganizationEngine( - enrollable: { value: boolean }, - graceDays: { value: number } = { value: 7 }, -): Promise { - const engine = new ObjectQL(); - engines.push(engine); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registry.registerObject(SysOrganization as never); - await engine.syncSchemas(); - registerManagedUpdateWhitelist('sys_organization', managedExtensionEditableFields('sys_organization')); - registerIdentityWriteGuard(engine, { packageId: 'test.identity-write-guard' }); - registerOrganizationMfaEnrollmentGuard(engine, { - packageId: 'test.organization-mfa-enrollment-guard', - isTwoFactorEnabled: () => enrollable.value, - mfaGracePeriodDays: () => graceDays.value, - }); - await engine.insert('sys_organization', { id: 'org_a', name: 'Acme', slug: 'acme' }, SYSTEM); - await engine.insert('sys_organization', { id: 'org_b', name: 'Bolt', slug: 'bolt' }, SYSTEM); - return engine; -} - -async function storedRequireMfa(engine: ObjectQL, id: string): Promise { - const row: any = await engine.findOne('sys_organization', { where: { id }, fields: ['require_mfa'], ...SYSTEM }); - return row?.require_mfa; -} - -async function caught(p: Promise): Promise { - try { - await p; - return null; - } catch (e) { - return e; - } +async function bound(extra: Record = {}) { + const settings = settingsService(); + const manager = authManager(extra); + await bindOrganizationAccessPolicy({ + settings: settings as any, + manager, + engine: () => undefined, + security: () => undefined, + logger: logger(), + }); + return { settings, manager }; } -describe('organization MFA enrollment guard — on a real engine', () => { - it('refuses the administrator\'s save while two-factor is off, and the row keeps its value', async () => { - const engine = await bootOrganizationEngine({ value: false }); - expectRequireMfaRefusal(await caught(engine.update('sys_organization', { id: 'org_a', require_mfa: true }, ADMIN))); - expect(await storedRequireMfa(engine, 'org_a')).toBe(false); - }); - - it('admits the same save once two-factor is on, and the row stores it', async () => { - const enrollable = { value: false }; - const engine = await bootOrganizationEngine(enrollable); - expectRequireMfaRefusal(await caught(engine.update('sys_organization', { id: 'org_a', require_mfa: true }, ADMIN))); - // Read per write: turning the plugin on admits the next write, no rebind. - enrollable.value = true; - await engine.update('sys_organization', { id: 'org_a', require_mfa: true }, ADMIN); - expect(await storedRequireMfa(engine, 'org_a')).toBe(true); +describe('an organization MFA requirement needs two-factor enrollment', () => { + it('the predicate judges a requirement only, and only while enrollment is off', () => { + const off = organizationMfaEnrollmentPredicate(() => false); + expect(off({ next: 'everyone' })?.message).toBe(ORGANIZATION_MFA_REFUSAL_COPY.en); + expect(off({ next: 'administrators', locale: 'zh-CN' })?.message).toBe(ORGANIZATION_MFA_REFUSAL_COPY['zh-CN']); + expect(off({ next: 'off' })).toBeUndefined(); + expect(off({ next: null })).toBeUndefined(); + expect(organizationMfaEnrollmentPredicate(() => true)({ next: 'everyone' })).toBeUndefined(); }); - it('refuses it for a system write and for a multi-row update too', async () => { - const engine = await bootOrganizationEngine({ value: false }); - expectRequireMfaRefusal(await caught(engine.update('sys_organization', { id: 'org_a', require_mfa: true }, SYSTEM))); - expectRequireMfaRefusal(await caught( - engine.update('sys_organization', { require_mfa: true }, { ...SYSTEM, where: { slug: 'bolt' }, multi: true } as any), - )); - expectRequireMfaRefusal(await caught( - engine.insert('sys_organization', { id: 'org_c', name: 'Cove', slug: 'cove', require_mfa: true }, SYSTEM), - )); - expect(await storedRequireMfa(engine, 'org_a')).toBe(false); - expect(await storedRequireMfa(engine, 'org_b')).toBe(false); - expect(await engine.findOne('sys_organization', { where: { id: 'org_c' }, ...SYSTEM })).toBeNull(); - }); + it('refuses the save at the settings door while two-factor is off, and admits it once on', async () => { + const { settings } = await bound({ plugins: {} }); + const refusal = await refusalOf(settings.setMany('access_policy', { mfa_required: 'everyone' }, { tenantId: ORG })); + expect(refusal.fields?.[0]).toMatchObject({ field: 'mfa_required', code: 'invalid_value' }); + expect((await settings.get('access_policy', 'mfa_required', { tenantId: ORG })).value).toBe('off'); - it('admits re-saving a row that already requires MFA, so its other edits are not blocked', async () => { - const enrollable = { value: true }; - const engine = await bootOrganizationEngine(enrollable); - await engine.update('sys_organization', { id: 'org_a', require_mfa: true }, ADMIN); - // Two-factor goes off afterwards (an env pin, a settings change). - enrollable.value = false; - // A door that sends the whole editable record re-sends the stored `true`. - await engine.update('sys_organization', { id: 'org_a', require_mfa: true, timezone: 'Asia/Shanghai' }, ADMIN); - await engine.update('sys_organization', { id: 'org_a', require_mfa: true }, SYSTEM); - const row: any = await engine.findOne('sys_organization', { where: { id: 'org_a' }, ...SYSTEM }); - expect(row.timezone).toBe('Asia/Shanghai'); - expect(row.require_mfa).toBe(true); - // Control: the other row, stored off, is still refused. - expectRequireMfaRefusal(await caught(engine.update('sys_organization', { id: 'org_b', require_mfa: true }, ADMIN))); - }); - - it('judges a multi-row update row by row: one row turning it on refuses the whole write, with nothing written', async () => { - const enrollable = { value: true }; - const engine = await bootOrganizationEngine(enrollable); - await engine.update('sys_organization', { id: 'org_a', require_mfa: true }, ADMIN); - enrollable.value = false; - // org_a already requires it, org_b does not: the batch turns it on for org_b. - expectRequireMfaRefusal(await caught( - engine.update('sys_organization', { require_mfa: true, timezone: 'UTC' }, { ...SYSTEM, where: {}, multi: true } as any), - )); - expect(await storedRequireMfa(engine, 'org_b')).toBe(false); - const a: any = await engine.findOne('sys_organization', { where: { id: 'org_a' }, ...SYSTEM }); - expect(a.timezone).not.toBe('UTC'); - // Every matched row already requires it: the batch turns nothing on and lands. - await engine.update('sys_organization', { require_mfa: true, timezone: 'UTC' }, { ...SYSTEM, where: { slug: 'acme' }, multi: true } as any); - const a2: any = await engine.findOne('sys_organization', { where: { id: 'org_a' }, ...SYSTEM }); - expect(a2.timezone).toBe('UTC'); - }); - - it('leaves every other organization write alone while two-factor is off', async () => { - const engine = await bootOrganizationEngine({ value: false }); - await engine.update('sys_organization', { id: 'org_a', timezone: 'Asia/Shanghai' }, ADMIN); - await engine.update('sys_organization', { id: 'org_a', require_mfa: false }, ADMIN); - const row: any = await engine.findOne('sys_organization', { where: { id: 'org_a' }, ...SYSTEM }); - expect(row.timezone).toBe('Asia/Shanghai'); - expect(row.require_mfa).toBe(false); + const on = await bound({ plugins: { twoFactor: true } }); + await on.settings.setMany('access_policy', { mfa_required: 'everyone' }, { tenantId: ORG }); + expect((await on.settings.get('access_policy', 'mfa_required', { tenantId: ORG })).value).toBe('everyone'); + // Each organization's row is its own. + expect((await on.settings.get('access_policy', 'mfa_required', { tenantId: 'org_beta' })).value).toBe('off'); }); }); -// ── #30411. What the saver is told, in the saver's language ───────────────── - -/** The first sentence of each copy: the language is the contract, so the sentence that carries it is pinned. */ -const REFUSAL_FIRST = { - en: 'This organization cannot require multi-factor authentication yet:', - 'zh-CN': '本组织暂时无法强制多因素认证:', -} as const; -const NOTICE_FIRST = { - en: 'Multi-factor authentication is now required for this organization.', - 'zh-CN': '本组织现已强制多因素认证。', -} as const; - -/** Run the guard's hook for `event` with a session locale and a notice listener; the outcome and what it told. */ -async function judgeTold(opts: { - enrollable: boolean; - event: 'beforeInsert' | 'beforeUpdate'; - data: Record; - previous?: Record; - locale?: string; - graceDays?: number; - noNoticeMember?: boolean; -}): Promise<{ error: any; told: string[] }> { - const engine = captureEngine(); - registerOrganizationMfaEnrollmentGuard(engine, { - packageId: 'test.organization-mfa-enrollment-guard', - isTwoFactorEnabled: () => opts.enrollable, - mfaGracePeriodDays: () => opts.graceDays ?? 7, - }); - const told: string[] = []; - try { - for (const r of engine.registrations.filter((x) => x.event === opts.event)) { - await r.handler({ - object: 'sys_organization', event: opts.event, input: { id: 'org_1', data: opts.data }, - ...(opts.previous ? { previous: opts.previous } : {}), - ...(opts.locale ? { session: { userId: 'usr_admin', locale: opts.locale } } : {}), - ...(opts.noNoticeMember ? {} : { notice: (m: string) => told.push(m) }), - }); - } - return { error: null, told }; - } catch (e) { - return { error: e, told }; - } -} - -describe('#30411 the refusal is in the caller\'s language, with the same field-level shape', () => { - it('zh-CN: the refusal reads in Chinese and still names the env var and the setting key', () => { - const err: any = organizationMfaWithoutEnrollmentRefusal('zh-CN'); - expectRequireMfaRefusal(err); - expect(err.message.startsWith(REFUSAL_FIRST['zh-CN'])).toBe(true); - expect(err.message).toMatch(/OS_AUTH_TWO_FACTOR=true/); - expect(err.message).toMatch(/auth\.mfa_required/); - expect(err.fields[0].message).toBe(err.message); - }); - - it('en: the refusal is the English sentence it was, top level and on the field', () => { - for (const locale of ['en', 'en-US', undefined]) { - const err: any = organizationMfaWithoutEnrollmentRefusal(locale); - expectRequireMfaRefusal(err); - expect(err.message.startsWith(REFUSAL_FIRST.en)).toBe(true); - expect(err.message).toBe(ORGANIZATION_MFA_REFUSAL_COPY.en); - expect(err.fields[0].message).toBe(err.message); - } - }); - - it.each([ - ['zh', 'zh-CN'], ['zh-cn', 'zh-CN'], ['zh-Hans-CN', 'zh-CN'], ['ja', 'ja-JP'], ['es-MX', 'es-ES'], - ['fr-FR', 'fr'], ['zh-TW', 'zh-Hant'], ['sw-KE', 'en'], ['', 'en'], - ] as const)('the request locale %j reads the %s copy (exact, then by language, else English)', (asked, served) => { - expect((organizationMfaWithoutEnrollmentRefusal(asked) as any).message).toBe(ORGANIZATION_MFA_REFUSAL_COPY[served]); - }); - - it('every language names what an operator types, and carries no tracker number', () => { - for (const [locale, message] of Object.entries(ORGANIZATION_MFA_REFUSAL_COPY)) { - expect(message, locale).toMatch(/OS_AUTH_TWO_FACTOR=true/); - expect(message, locale).toMatch(/OS_AUTH_TWO_FACTOR .*false|false.*OS_AUTH_TWO_FACTOR/); - expect(message, locale).toMatch(/auth\.mfa_required/); - expect(message, locale).not.toMatch(/#\d/); - expectRequireMfaRefusal(organizationMfaWithoutEnrollmentRefusal(locale)); - } - }); - - it('the hook answers in the session\'s locale, and in English for a write with no caller locale', async () => { - for (const event of ['beforeInsert', 'beforeUpdate'] as const) { - const zh = await judgeTold({ enrollable: false, event, data: { require_mfa: true }, locale: 'zh-CN' }); - expectRequireMfaRefusal(zh.error); - expect(zh.error.message.startsWith(REFUSAL_FIRST['zh-CN'])).toBe(true); - expect(zh.told, 'a refused write tells no notice').toEqual([]); - const none = await judgeTold({ enrollable: false, event, data: { require_mfa: true } }); - expectRequireMfaRefusal(none.error); - expect(none.error.message.startsWith(REFUSAL_FIRST.en)).toBe(true); - } - }); -}); - -describe('#30411 an admitted turn-on answers the grace period in force as its save notice', () => { - it('turning it on (insert, or update of a row stored off) tells who must enroll and the grace period, once', async () => { - for (const [event, previous] of [ - ['beforeInsert', undefined], - ['beforeUpdate', { require_mfa: false }], - ['beforeUpdate', undefined], - ] as const) { - const out = await judgeTold({ enrollable: true, event, data: { require_mfa: true }, previous, graceDays: 30 }); - expect(out.error).toBeNull(); - expect(out.told).toEqual([organizationMfaRequiredNotice(30, 'en')]); - expect(out.told[0].startsWith(NOTICE_FIRST.en)).toBe(true); - expect(out.told[0]).toMatch(/has 30 days to enroll/); - } - }); - - it('zh-CN: the notice reads in Chinese and names the configured days', async () => { - const out = await judgeTold({ - enrollable: true, event: 'beforeUpdate', data: { require_mfa: true }, previous: { require_mfa: false }, - locale: 'zh-CN', graceDays: 14, - }); - expect(out.told).toHaveLength(1); - expect(out.told[0].startsWith(NOTICE_FIRST['zh-CN'])).toBe(true); - expect(out.told[0]).toMatch(/有 14 天时间完成绑定/); - }); - - it('a grace period of 0 and of 1 day get their own sentence in every language', () => { - for (const [locale, copy] of Object.entries(ORGANIZATION_MFA_REQUIRED_NOTICE_COPY)) { - expect(copy(0), locale).toMatch(/\b0\b/); - expect(copy(0), locale).not.toBe(copy(1)); - // Arabic writes one day as a word, as its grammar does ("يوم واحد"); every other language as a digit. - if (locale === 'ar') expect(copy(1), locale).toContain('يوم واحد'); - else expect(copy(1), locale).toMatch(/\b1\b/); - expect(copy(45), locale).toMatch(/\b45\b/); - expect(copy(45), locale).not.toMatch(/#\d/); - } - expect(ORGANIZATION_MFA_REQUIRED_NOTICE_COPY.en(1)).toMatch(/has 1 day to enroll/); - expect(ORGANIZATION_MFA_REQUIRED_NOTICE_COPY.en(0)).toMatch(/blocked from data the next time they use the app/); - }); - - it('says nothing for a write that turns nothing on', async () => { - for (const event of ['beforeInsert', 'beforeUpdate'] as const) { - for (const data of [{ require_mfa: false }, { timezone: 'Asia/Shanghai' }, { name: 'Acme' }]) { - const out = await judgeTold({ enrollable: true, event, data }); - expect(out, `${event} ${JSON.stringify(data)}`).toEqual({ error: null, told: [] }); - } - } - // A re-save of a stored `true` (a whole-row door, an import re-run). - const resave = await judgeTold({ - enrollable: true, event: 'beforeUpdate', data: { require_mfa: true }, previous: { require_mfa: true }, +describe('an organization cannot save below the deployment floor', () => { + it('refuses a weaker value with the settings refusal envelope, and admits a tighter one', async () => { + const { settings } = await bound({ + plugins: { twoFactor: true }, + sessionIdleTimeoutMinutes: 30, + maxConcurrentSessions: 3, + allowedIpRanges: ['10.0.0.0/8'], + mfaRequirement: 'everyone', }); - expect(resave).toEqual({ error: null, told: [] }); - }); - - it('a dispatch with no notice member is admitted all the same', async () => { - const out = await judgeTold({ enrollable: true, event: 'beforeInsert', data: { require_mfa: true }, noNoticeMember: true }); - expect(out).toEqual({ error: null, told: [] }); - }); -}); - -describe('#30411 on a real engine: the caller\'s locale reaches the refusal, the notice reaches the write\'s answer', () => { - const zhAdmin = { context: { ...ADMIN.context, locale: 'zh-CN' } }; - - it('refuses the zh-CN administrator\'s save in Chinese, envelope unchanged, and the row keeps its value', async () => { - const engine = await bootOrganizationEngine({ value: false }); - const err = await caught(engine.update('sys_organization', { id: 'org_a', require_mfa: true }, zhAdmin)); - expectRequireMfaRefusal(err); - expect(err.message.startsWith(REFUSAL_FIRST['zh-CN'])).toBe(true); - expect(await storedRequireMfa(engine, 'org_a')).toBe(false); - // Control: the same write with no caller locale reads English. - const en = await caught(engine.update('sys_organization', { id: 'org_a', require_mfa: true }, ADMIN)); - expect(en.message.startsWith(REFUSAL_FIRST.en)).toBe(true); + const idle = await refusalOf(settings.setMany('access_policy', { session_idle_timeout_minutes: 45 }, { tenantId: ORG })); + expect(idle.fields?.[0]).toMatchObject({ field: 'session_idle_timeout_minutes', code: 'invalid_value', constraint: { max: 30 } }); + const cap = await refusalOf(settings.setMany('access_policy', { max_concurrent_sessions_per_user: 9 }, { tenantId: ORG })); + expect(cap.fields?.[0]).toMatchObject({ field: 'max_concurrent_sessions_per_user', constraint: { max: 3 } }); + const ranges = await refusalOf(settings.setMany('access_policy', { allowed_ip_ranges: '192.168.0.0/16' }, { tenantId: ORG })); + expect(ranges.fields?.[0]).toMatchObject({ field: 'allowed_ip_ranges', constraint: { range: '192.168.0.0/16' } }); + const mfa = await refusalOf(settings.setMany('access_policy', { mfa_required: 'administrators' }, { tenantId: ORG })); + expect(mfa.fields?.[0]).toMatchObject({ field: 'mfa_required', constraint: { minimum: 'everyone' } }); + + await settings.setMany( + 'access_policy', + { session_idle_timeout_minutes: 10, max_concurrent_sessions_per_user: 1, allowed_ip_ranges: '10.1.0.0/16' }, + { tenantId: ORG }, + ); + expect((await settings.get('access_policy', 'session_idle_timeout_minutes', { tenantId: ORG })).value).toBe(10); }); - it('the admitted save hands its listener the notice, with the grace period read per write', async () => { - const grace = { value: 21 }; - const engine = await bootOrganizationEngine({ value: true }, grace); - const told: string[] = []; - await engine.update('sys_organization', { id: 'org_a', require_mfa: true }, { - ...zhAdmin, onNotice: (m: string) => told.push(m), - } as any); - expect(await storedRequireMfa(engine, 'org_a')).toBe(true); - expect(told).toEqual([organizationMfaRequiredNotice(21, 'zh-CN')]); - // The setting changes; the next write that turns it on names the new number. - // (An insert: user-context creates are the D2 guard's to refuse, so the system writes it.) - grace.value = 3; - await engine.insert('sys_organization', { id: 'org_c', name: 'Cove', slug: 'cove', require_mfa: true }, { - ...SYSTEM, onNotice: (m: string) => told.push(m), - } as any); - expect(told[1]).toBe(organizationMfaRequiredNotice(3, 'en')); - // A re-save of the stored `true` answers no notice. - await engine.update('sys_organization', { id: 'org_a', require_mfa: true, timezone: 'UTC' }, { - ...ADMIN, onNotice: (m: string) => told.push(m), - } as any); - expect(told).toHaveLength(2); + it('the manager reads each organization\'s saved policy, and drops its cache on a change', async () => { + const { settings, manager } = await bound({ plugins: { twoFactor: true } }); + await settings.setMany('access_policy', { session_idle_timeout_minutes: 10 }, { tenantId: ORG }); + expect((await manager.getOrganizationAccessPolicy(ORG)).sessionIdleTimeoutMinutes).toBe(10); + expect((await manager.getOrganizationAccessPolicy('org_beta')).sessionIdleTimeoutMinutes).toBe(0); + await settings.setMany('access_policy', { session_idle_timeout_minutes: 5 }, { tenantId: ORG }); + await vi.waitFor(async () => expect((await manager.getOrganizationAccessPolicy(ORG)).sessionIdleTimeoutMinutes).toBe(5)); }); }); -// ── 3. End to end ─────────────────────────────────────────────────────────── - -const SECRET = 'test-secret-at-least-32-chars-long!!'; -const PASSWORD = 'S3cure!Passw0rd-org-mfa'; -const EMAIL = 'member@example.com'; -const BASE = 'http://localhost:3000/api/v1/auth'; -const ORG = 'org_requires_mfa'; - -const cookieHeader = (res: Response): string => - (res.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).join('; '); - -const post = (manager: AuthManager, path: string, body: unknown, headers: Record = {}) => - manager.handleRequest( - new Request(`${BASE}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify(body ?? {}), - }), - ); - -/** - * A member of an organization that requires MFA, unenrolled, whose grace clock - * started 30 days ago, in a session whose active organization is that one — - * the card's measured state, on a deployment with two-factor on or off, and a - * grace period of `graceDays` (7 unless a case says otherwise; `null` configures - * none, so the manager's own default applies). - */ -async function arrangeHeldMember(twoFactor: boolean, graceDays: number | null = 7) { - const engine: any = createMemoryEngine(); - const manager = new AuthManager({ - secret: SECRET, - baseUrl: 'http://localhost:3000', - dataEngine: engine, - ...(graceDays === null ? {} : { mfaGracePeriodDays: graceDays }), - ...(twoFactor ? { plugins: { twoFactor: true } } : {}), - } as any); - const signedUp = await post(manager, '/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Member' }); - expect(signedUp.status, `sign-up: ${await signedUp.clone().text()}`).toBe(200); - const cookie = cookieHeader(signedUp); - const user = (engine.tables.get('sys_user') as any[]).find((r) => r.email === EMAIL); - // Stored before the guard existed: the guard judges writes, the gate judges this row. - await engine.insert('sys_organization', { id: ORG, name: 'Acme', slug: 'acme', require_mfa: true }); - user.mfa_required_at = new Date(Date.now() - 30 * 86_400_000).toISOString(); - for (const s of engine.tables.get('sys_session') as any[]) { - if (s.user_id === user.id) s.active_organization_id = ORG; - } - // The "any organization requires MFA" cache is refreshed in the background - // on the session path; let it land so the gate reads the organization row. - manager.isAuthGateActive(); - await vi.waitFor(() => expect(manager.isAuthGateActive()).toBe(true)); - return { engine, manager, cookie, userId: String(user.id) }; -} - -async function sessionAuthGate(manager: AuthManager, cookie: string): Promise<{ code?: string } | undefined> { - const auth: any = await manager.getAuthInstance(); - const session = await auth.api.getSession({ headers: new Headers({ cookie }) }); - expect(session?.user, 'no session for the member').toBeTruthy(); - return session.user.authGate; -} - -describe('end to end — a member is never held for MFA while the enrollment endpoint answers 404', () => { - it('two-factor off: /two-factor/enable answers 404, so the session is not held and no grace clock is started', async () => { - const { engine, manager, cookie, userId } = await arrangeHeldMember(false); - const warn = vi.fn(); - (manager as any).config.logger = { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() }; - const enable = await post(manager, '/two-factor/enable', { password: PASSWORD }, { cookie }); - // The card's measurement: the enrollment path does not exist here. - expect(enable.status).toBe(404); - expect((await sessionAuthGate(manager, cookie))?.code).not.toBe('MFA_REQUIRED'); - expect(await manager.resolveUserAuthGates(userId, { organizationId: ORG })).toEqual([]); - // Said once, with the remedy — not once per request. - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).toMatch(/NOT enforced/); - // The stored clock is left as it was (no restamp while unenrollable). - const user = (engine.tables.get('sys_user') as any[]).find((r) => r.id === userId); - expect(new Date(user.mfa_required_at).getTime()).toBeLessThan(Date.now() - 29 * 86_400_000); - }); - - it('two-factor on: the session is held MFA_REQUIRED, and /two-factor/enable answers (the way forward exists)', async () => { - const { manager, cookie } = await arrangeHeldMember(true); - expect((await sessionAuthGate(manager, cookie))?.code).toBe('MFA_REQUIRED'); - const enable = await post(manager, '/two-factor/enable', { password: PASSWORD }, { cookie }); - expect(enable.status, `two-factor/enable: ${await enable.clone().text()}`).toBe(200); - expect(((await enable.clone().json()) as { totpURI?: string }).totpURI).toMatch(/^otpauth:\/\/totp\//); - }); -}); +describe('the toggle-era values move onto the levels', () => { + /** The `auth` manifest as it stood when `mfa_required` was a toggle. */ + const toggleEraAuth = (): SettingsManifest => { + const current = manifest('auth') as any; + return { + ...current, + specifiers: current.specifiers.map((s: any) => + s.key === 'mfa_required' ? { type: 'toggle', key: 'mfa_required', label: s.label, required: false, default: false } : s, + ), + } as SettingsManifest; + }; -describe('#30411 the grace period the notice names is the one the gate holds to', () => { - it.each([ - [undefined, 7], [0, 0], [14, 14], [2.9, 2], [-3, 0], [120, 120], - ] as const)('resolveMfaGracePeriodDays(%j) = %j, the reading the gate\'s clock always took', (configured, days) => { - expect(resolveMfaGracePeriodDays({ mfaGracePeriodDays: configured as number | undefined })).toBe(days); + it('a stored auth.mfa_required toggle becomes a level, once', async () => { + for (const [stored, level] of [[true, 'everyone'], [false, 'off']] as const) { + const settings = new SettingsService({ env: {} }); + settings.registerManifest(toggleEraAuth()); + await settings.set('auth', 'mfa_required', stored, {}); + settings.registerManifest(manifest('auth')); + const deps = { settings: settings as any, manager: authManager(), engine: () => undefined, security: () => undefined, logger: logger() }; + expect(await migrateLegacyMfaRequiredSetting(deps)).toBe('moved'); + expect((await settings.get('auth', 'mfa_required')).value).toBe(level); + expect(await migrateLegacyMfaRequiredSetting(deps)).toBe('none'); + } }); - it.each([ - [40, false], [20, true], [null, true], - ] as const)('grace %j days (null: unset), clock started 30 days ago: held = %j, and the manager answers the same number', async (graceDays, held) => { - const { manager, userId } = await arrangeHeldMember(true, graceDays); - expect(manager.getMfaGracePeriodDays()).toBe(graceDays ?? 7); - const gates = await manager.resolveUserAuthGates(userId, { organizationId: ORG }); - expect(gates.some((g) => g.code === 'MFA_REQUIRED')).toBe(held); + it('an organization that required MFA carries it as "everyone", and its column is cleared after the row lands', async () => { + const settings = settingsService(); + const rows: any[] = [ + { id: ORG, require_mfa: true }, + { id: 'org_set', require_mfa: true }, + { id: 'org_off', require_mfa: false }, + ]; + const engine = { + find: vi.fn(async (_obj: string, q: any) => rows.filter((r) => r.require_mfa === q.where.require_mfa)), + update: vi.fn(async (_obj: string, patch: any) => Object.assign(rows.find((r) => r.id === patch.id), patch)), + }; + // An organization that already chose a level keeps it. + await settings.set('access_policy', 'mfa_required', 'administrators', { tenantId: 'org_set' }); + const deps = { settings: settings as any, manager: authManager(), engine: () => engine, security: () => undefined, logger: logger() }; + + expect(await migrateLegacyOrganizationRequireMfa(deps)).toEqual({ moved: 2, failed: 0 }); + expect((await settings.get('access_policy', 'mfa_required', { tenantId: ORG })).value).toBe('everyone'); + expect((await settings.get('access_policy', 'mfa_required', { tenantId: 'org_set' })).value).toBe('administrators'); + expect((await settings.get('access_policy', 'mfa_required', { tenantId: 'org_off' })).value).toBe('off'); + expect(rows.map((r) => r.require_mfa)).toEqual([false, false, false]); + // Convergent: a second boot finds nothing to move. + expect(await migrateLegacyOrganizationRequireMfa(deps)).toEqual({ moved: 0, failed: 0 }); + }); + + it('a move whose settings write fails leaves the column for the next boot and says so at error', async () => { + const rows: any[] = [{ id: ORG, require_mfa: true }]; + const engine = { find: vi.fn(async () => rows), update: vi.fn() }; + const settings = { + get: vi.fn(async () => ({ value: 'off', source: 'default' })), + getNamespace: vi.fn(), + set: vi.fn(async () => { + throw new Error('store down'); + }), + }; + const log = logger(); + expect( + await migrateLegacyOrganizationRequireMfa({ settings, manager: authManager(), engine: () => engine, security: () => undefined, logger: log }), + ).toEqual({ moved: 0, failed: 1 }); + expect(engine.update).not.toHaveBeenCalled(); + expect(rows[0].require_mfa).toBe(true); + expect(log.error).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/plugins/plugin-auth/src/settings-copy-locale.test.ts b/packages/plugins/plugin-auth/src/settings-copy-locale.test.ts index d6e87077816..4a15fb221d2 100644 --- a/packages/plugins/plugin-auth/src/settings-copy-locale.test.ts +++ b/packages/plugins/plugin-auth/src/settings-copy-locale.test.ts @@ -11,12 +11,8 @@ import type { SettingsCopyLocale } from './settings-copy-locale.js'; import { ALLOWED_IP_RANGES_REFUSAL_COPY, allowedIpRangesRefusalCopy } from './allowed-ip-ranges.js'; import { SIGN_IN_FLOOR_COPY, signInFloorCopy } from './sign-in-method-floor.js'; import { MFA_REQUIRED_ENROLLMENT_COPY, mfaRequiredEnrollmentCopy } from './mfa-required-enrollment-predicate.js'; -import { - ORGANIZATION_MFA_REFUSAL_COPY, - ORGANIZATION_MFA_REQUIRED_NOTICE_COPY, - organizationMfaRequiredNotice, - organizationMfaWithoutEnrollmentRefusal, -} from './organization-mfa-enrollment-guard.js'; +import { ORGANIZATION_MFA_REFUSAL_COPY, organizationMfaRefusalCopy } from './organization-mfa-enrollment-guard.js'; +import { ACCESS_POLICY_FLOOR_COPY, accessPolicyFloorCopy } from './organization-access-policy.js'; /** The key each platform table tag is served by. */ const KEY_OF_TAG: Record = { @@ -47,16 +43,8 @@ const TABLES: Table[] = [ { name: 'allowed IP ranges refusal', table: ALLOWED_IP_RANGES_REFUSAL_COPY, lookup: allowedIpRangesRefusalCopy }, { name: 'sign-in method floor', table: SIGN_IN_FLOOR_COPY, lookup: signInFloorCopy }, { name: 'auth.mfa_required refusal', table: MFA_REQUIRED_ENROLLMENT_COPY, lookup: mfaRequiredEnrollmentCopy }, - { - name: 'organization MFA refusal', - table: ORGANIZATION_MFA_REFUSAL_COPY, - lookup: (locale) => organizationMfaWithoutEnrollmentRefusal(locale).message, - }, - { - name: 'organization MFA notice', - table: ORGANIZATION_MFA_REQUIRED_NOTICE_COPY, - lookup: (locale) => organizationMfaRequiredNotice(7, locale), - }, + { name: 'organization MFA refusal', table: ORGANIZATION_MFA_REFUSAL_COPY, lookup: organizationMfaRefusalCopy }, + { name: 'organization access policy floor', table: ACCESS_POLICY_FLOOR_COPY, lookup: accessPolicyFloorCopy }, ]; /** What a lookup answered, as comparable text. */ @@ -68,7 +56,6 @@ function served(t: Table, locale: string | undefined): string { /** The text a lookup must answer when it serves `key`. */ function expected(t: Table, key: SettingsCopyLocale): string { const entry = t.table[key]; - if (t.name === 'organization MFA notice') return JSON.stringify((entry as (d: number) => string)(7)); return JSON.stringify(typeof entry === 'string' ? entry : sentences(entry)); } @@ -112,15 +99,3 @@ describe.each(TABLES)('$name copy carries the eleven platform languages', (t) => } }); }); - -describe('organization MFA notice plurals', () => { - it('Russian and Arabic count days in the right form', () => { - expect(organizationMfaRequiredNotice(1, 'ru')).toContain('1 день'); - expect(organizationMfaRequiredNotice(3, 'ru')).toContain('3 дня'); - expect(organizationMfaRequiredNotice(11, 'ru')).toContain('11 дней'); - expect(organizationMfaRequiredNotice(21, 'ru')).toContain('21 день'); - expect(organizationMfaRequiredNotice(2, 'ar')).toContain('يومان'); - expect(organizationMfaRequiredNotice(7, 'ar')).toContain('7 أيام'); - expect(organizationMfaRequiredNotice(14, 'ar')).toContain('14 يومًا'); - }); -}); diff --git a/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts b/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts index 4b1a84e6264..5566ef75397 100644 --- a/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts +++ b/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts @@ -11,7 +11,7 @@ * `sys-organization-update-door.test.ts` there). The whole safety of that * widening rests on this package: the identity write guard's per-object update * whitelist — `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization`, registered - * at `kernel:ready` by `auth-plugin.ts` — must keep admitting exactly the four + * at `kernel:ready` by `auth-plugin.ts` — must keep admitting exactly the ruled * platform-owned extension columns and keep stripping everything else, * better-auth's own `name` / `slug` / `logo` / `metadata` included. * @@ -47,8 +47,8 @@ import { } from './identity-write-guard.js'; import { managedExtensionEditableFields } from './managed-extension-fields.js'; -/** The four columns the ruling names, in the whitelist's own order. */ -const RULED_COLUMNS = ['require_mfa', 'parent_organization_id', 'sort_order', 'timezone']; +/** The columns the ruling names, in the whitelist's own order — less `require_mfa`, retired with the multi-factor levels (`organization-access-policy.ts`). */ +const RULED_COLUMNS = ['parent_organization_id', 'sort_order', 'timezone']; /** better-auth's own organization columns on this object — protocol fields. */ const BETTER_AUTH_COLUMNS = ['name', 'slug', 'logo', 'metadata']; @@ -89,7 +89,7 @@ async function guardedUpdate(data: Record): Promise { } } -describe('#15873 — the whitelist is the four ruled columns, every one a real column on the object', () => { +describe('#15873 — the whitelist is the ruled columns (less the retired require_mfa), every one a real column on the object', () => { it('MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization is exactly the ruled set', () => { expect([...managedExtensionEditableFields('sys_organization')].sort()).toEqual([...RULED_COLUMNS].sort()); }); @@ -117,7 +117,7 @@ describe('#15873 — the guard holds the column gate on a user-context update', }); it.each(RULED_COLUMNS)('%s alone passes the guard un-stripped', async (column) => { - const value = column === 'require_mfa' ? true : column === 'sort_order' ? 7 : column === 'timezone' ? 'Asia/Shanghai' : 'org_parent'; + const value = column === 'sort_order' ? 7 : column === 'timezone' ? 'Asia/Shanghai' : 'org_parent'; const data: Record = { id: 'org_1', [column]: value }; expect(await guardedUpdate(data)).toBeNull(); expect(data, `the guard must not strip ${column}`).toEqual({ id: 'org_1', [column]: value }); diff --git a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts index 45ba784178b..8f567241ce4 100644 --- a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts @@ -5,7 +5,8 @@ * the product actually uses. * * `sys_organization` carries four columns ObjectStack owns and better-auth - * never reads or writes — `require_mfa` (ADR-0069 D3), `parent_organization_id` + * never reads or writes — `require_mfa` (ADR-0069 D3; retired since, and no + * longer editable: the multi-factor level is the access policy's), `parent_organization_id` * / `sort_order` (ADR-0105 D6) and `timezone` (#14238). plugin-auth declares * them generically editable (`MANAGED_EXTENSION_EDITABLE_FIELDS`, the ADR-0092 * D2 identity write guard's per-object update whitelist), while the object set @@ -158,18 +159,16 @@ describe('#15873: sys_organization platform-owned columns through PATCH /data/sy expect(row.sort_order).toBe(7); }); - it('require_mfa is admitted on the same door (written at its current value — flipping it would lock this session out)', async () => { - // `require_mfa: true` on the caller's own organization is enforced at the - // session-validation gate, so the assertions after this one would start - // answering the MFA challenge instead of the door. Writing the column at - // its present value still proves the point: were it NOT whitelisted, a - // payload carrying it alone is refused 403 by the guard ("none of the - // submitted fields are editable"), exactly as `name` is above. + it('require_mfa is retired: a payload carrying it alone is refused by the column guard (403), and nothing changes', async () => { + // An organization's multi-factor requirement is its access policy now + // (`access_policy.mfa_required`, three levels); the column left the + // whitelist with it, so a write to it would be read by nothing. const before = await readOrg(orgId); - const current = before.require_mfa === true; - const res = await patchOrg(orgId, { require_mfa: current }); - expect(res.status).toBe(200); - expect((await readOrg(orgId)).require_mfa === true).toBe(current); + const res = await patchOrg(orgId, { require_mfa: !(before.require_mfa === true) }); + const body: any = await res.json(); + expect(res.status).toBe(403); + expect(body.code).toBe('PERMISSION_DENIED'); + expect((await readOrg(orgId)).require_mfa === true).toBe(before.require_mfa === true); }); it('opens the columns, not the table: better-auth columns smuggled beside a legal one are stripped, the legal one lands', async () => { diff --git a/packages/services/service-settings/src/manifests/auth.manifest.test.ts b/packages/services/service-settings/src/manifests/auth.manifest.test.ts index fdff75028b4..52222475c33 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -28,7 +28,6 @@ describe('authSettingsManifest', () => { expect(keys).toEqual([ 'email_password_enabled', 'google_enabled', - 'mfa_required', 'password_reject_breached', 'password_require_complexity', 'require_email_verification', @@ -36,6 +35,17 @@ describe('authSettingsManifest', () => { ]); }); + // [ruling 「可以,按三档做(推荐)」] The deployment's multi-factor requirement + // has three levels — the default and the floor of every organization's own. + it('mfa_required is a three-level select, off by default', () => { + const spec = (authSettingsManifest.specifiers as any[]).find((s) => s.key === 'mfa_required'); + expect(spec.type).toBe('select'); + expect(spec.default).toBe('off'); + expect(spec.options.map((o: any) => o.value)).toEqual(['off', 'administrators', 'everyone']); + const grace = (authSettingsManifest.specifiers as any[]).find((s) => s.key === 'mfa_grace_period_days'); + expect(grace.visible).toBe("${data.mfa_required !== 'off'}"); + }); + // #5152 / ADR-0093 D1 — `signup_enabled` says whether people may // self-register; this says what they join when they do. Without it a // self-hosted stack could not express `invite-only` at all: the policy was diff --git a/packages/services/service-settings/src/translations/ar.ts b/packages/services/service-settings/src/translations/ar.ts index b6ffeabbdfc..cb94fb764f1 100644 --- a/packages/services/service-settings/src/translations/ar.ts +++ b/packages/services/service-settings/src/translations/ar.ts @@ -389,7 +389,7 @@ export const ar: PlatformTranslationData = { sessions: { title: 'الجلسات', description: 'مدة صلاحية جلسة العضو. لا يمكن لأي حد إلا أن يكون أقصر من حد النشر.' }, multi_factor: { title: 'المصادقة متعددة العوامل', description: 'من في هذه المؤسسة يجب أن يحمي حسابه بتطبيق مصادقة (TOTP).' }, }, - fields: { + keys: { allowed_ip_ranges: { label: 'نطاقات IP المسموح بها' }, session_idle_timeout_minutes: { label: 'مهلة الخمول (بالدقائق)' }, session_absolute_max_hours: { label: 'الحد الأقصى لعمر الجلسة (بالساعات)' }, diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index 9f5a0a6bc4c..800277c4981 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -236,7 +236,7 @@ export const en: PlatformTranslationData = { sessions: { title: 'Sessions', description: 'How long a member\'s session stays valid. Each limit can only be shorter than the deployment\'s.' }, multi_factor: { title: 'Multi-factor', description: 'Who in this organization must protect their account with an authenticator app (TOTP).' }, }, - fields: { + keys: { allowed_ip_ranges: { label: 'Allowed IP ranges' }, session_idle_timeout_minutes: { label: 'Idle timeout (minutes)' }, session_absolute_max_hours: { label: 'Absolute session lifetime (hours)' }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 2274e27cb57..fa494ff9f82 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -361,7 +361,7 @@ export const esES: PlatformTranslationData = { sessions: { title: 'Sesiones', description: 'Cuánto dura la sesión de un miembro. Cada límite solo puede ser más corto que el de la implementación.' }, multi_factor: { title: 'Multifactor', description: 'Quién en esta organización debe proteger su cuenta con una app de autenticación (TOTP).' }, }, - fields: { + keys: { allowed_ip_ranges: { label: 'Rangos de IP permitidos' }, session_idle_timeout_minutes: { label: 'Tiempo de inactividad (minutos)' }, session_absolute_max_hours: { label: 'Duración máxima de la sesión (horas)' }, diff --git a/packages/services/service-settings/src/translations/fr.ts b/packages/services/service-settings/src/translations/fr.ts index b5805bdbccb..b9e2f6bf42c 100644 --- a/packages/services/service-settings/src/translations/fr.ts +++ b/packages/services/service-settings/src/translations/fr.ts @@ -422,7 +422,7 @@ export const fr: PlatformTranslationData = { sessions: { title: 'Sessions', description: 'Combien de temps la session d’un membre reste valide. Chaque limite peut seulement être plus courte que celle du déploiement.' }, multi_factor: { title: 'Multifacteur', description: 'Qui, dans cette organisation, doit protéger son compte avec une application d’authentification (TOTP).' }, }, - fields: { + keys: { allowed_ip_ranges: { label: 'Plages d’adresses IP autorisées' }, session_idle_timeout_minutes: { label: 'Délai d’inactivité (minutes)' }, session_absolute_max_hours: { label: 'Durée de vie maximale de la session (heures)' }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 4338cd1994a..c73462187bb 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -361,7 +361,7 @@ export const jaJP: PlatformTranslationData = { sessions: { title: 'セッション', description: 'メンバーのセッションの有効期間。各上限はデプロイの上限より短くすることしかできません。' }, multi_factor: { title: '多要素認証', description: 'この組織で認証アプリ (TOTP) によるアカウント保護が必須となる人。' }, }, - fields: { + keys: { allowed_ip_ranges: { label: '許可する IP 範囲' }, session_idle_timeout_minutes: { label: 'アイドルタイムアウト (分)' }, session_absolute_max_hours: { label: 'セッションの最大有効期間 (時間)' }, diff --git a/packages/services/service-settings/src/translations/pt-BR.ts b/packages/services/service-settings/src/translations/pt-BR.ts index 7a2a6e24991..a30e1b8cd0d 100644 --- a/packages/services/service-settings/src/translations/pt-BR.ts +++ b/packages/services/service-settings/src/translations/pt-BR.ts @@ -409,7 +409,7 @@ export const ptBR: PlatformTranslationData = { sessions: { title: 'Sessões', description: 'Por quanto tempo a sessão de um membro permanece válida. Cada limite só pode ser menor que o da implantação.' }, multi_factor: { title: 'Multifator', description: 'Quem nesta organização deve proteger a conta com um aplicativo autenticador (TOTP).' }, }, - fields: { + keys: { allowed_ip_ranges: { label: 'Faixas de IP permitidas' }, session_idle_timeout_minutes: { label: 'Tempo limite de inatividade (minutos)' }, session_absolute_max_hours: { label: 'Duração máxima da sessão (horas)' }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 3e97e05c42b..49f73bc46f4 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -416,7 +416,7 @@ export const zhCN: PlatformTranslationData = { sessions: { title: '会话', description: '成员会话的有效时长。每项限制只能比部署的更短。' }, multi_factor: { title: '多因素认证', description: '本组织中哪些人必须用身份验证器应用(TOTP)保护账户。' }, }, - fields: { + keys: { allowed_ip_ranges: { label: '允许的 IP 范围' }, session_idle_timeout_minutes: { label: '闲置超时(分钟)' }, session_absolute_max_hours: { label: '会话最长有效期(小时)' }, diff --git a/packages/services/service-settings/src/translations/zh-Hant.ts b/packages/services/service-settings/src/translations/zh-Hant.ts index 2cca6965641..d9282d75918 100644 --- a/packages/services/service-settings/src/translations/zh-Hant.ts +++ b/packages/services/service-settings/src/translations/zh-Hant.ts @@ -288,7 +288,7 @@ export const zhHant: PlatformTranslationData = { sessions: { title: '工作階段', description: '成員工作階段的有效時長。每項限制只能比部署的更短。' }, multi_factor: { title: '多因素驗證', description: '本組織中哪些人必須以驗證器應用程式(TOTP)保護帳戶。' }, }, - fields: { + keys: { allowed_ip_ranges: { label: '允許的 IP 範圍' }, session_idle_timeout_minutes: { label: '閒置逾時(分鐘)' }, session_absolute_max_hours: { label: '工作階段最長有效期(小時)' }, diff --git a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts index 138c8ec7743..555362708c1 100644 --- a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts +++ b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts @@ -333,6 +333,7 @@ for (const state of HELD_STATES) { describe("a hold only the session's organization declares (MFA required by the organization)", () => { let organizationId: string | undefined; let twoFactorBefore: boolean | undefined; + let previousPolicySource: unknown; beforeAll(async () => { organizationId = (await sessionOf(await stack.signIn())).organizationId; @@ -342,7 +343,14 @@ describe("a hold only the session's organization declares (MFA required by the o // then. Not inherited from whichever held state ran before. twoFactorBefore = auth.isTwoFactorEnabled(); auth.applyConfigPatch({ plugins: { twoFactor: true } }); - await ql.update('sys_organization', { id: organizationId, require_mfa: true }, SYS); + // The organization's multi-factor level is its access policy + // (`access_policy.mfa_required`): served here for this one organization. + previousPolicySource = auth.orgAccessPolicySource; + const required = organizationId; + auth.setOrganizationAccessPolicySource({ + read: async (id: string) => (id === required ? { mfa_required: 'everyone' } : {}), + anyOrganizationRequiresMfa: async () => true, + }); // The 60-second "does any organization require MFA" cache, primed as the // background refresh would after this write. auth._orgMfaCache = { value: true, at: Date.now() }; @@ -352,7 +360,7 @@ describe("a hold only the session's organization declares (MFA required by the o }, 60_000); afterAll(async () => { - if (organizationId) await ql.update('sys_organization', { id: organizationId, require_mfa: false }, SYS); + auth.setOrganizationAccessPolicySource(previousPolicySource); auth._orgMfaCache = { value: false, at: Date.now() }; if (twoFactorBefore === false) auth.applyConfigPatch({ plugins: { twoFactor: false } }); }); From 304afe57f1da364c21a5d0e3c3de93fcf277391b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:15:30 +0000 Subject: [PATCH 03/17] wip: changeset Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../30922-per-organization-access-policy.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/30922-per-organization-access-policy.md diff --git a/.changeset/30922-per-organization-access-policy.md b/.changeset/30922-per-organization-access-policy.md new file mode 100644 index 00000000000..20396fc6987 --- /dev/null +++ b/.changeset/30922-per-organization-access-policy.md @@ -0,0 +1,28 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/service-settings': patch +'@objectstack/platform-objects': patch +--- + +feat(plugin-auth, service-settings)!: the access policy is set per organization, and the multi-factor requirement has three levels + +Clause-②: yes (narrowing) — a new `access_policy` settings namespace (scope `tenant`) carries each organization's allowed IP ranges, idle, absolute and concurrent session limits and multi-factor requirement; `auth.mfa_required` changes from a toggle to the levels `off` / `administrators` / `everyone`, `sys_organization.require_mfa` is no longer writable, and `AuthManagerOptions.mfaRequired` becomes `mfaRequirement`. +Metadata-protocol: none + + + +**BREAKING.** The access policy is set per organization (rulings of record on objectstack-ai/cloud#3668: 「支持按组织(推荐)」, 「可以,按三档做(推荐)」). Each organization sets its own allowed IP ranges, session idle timeout, absolute session lifetime, concurrent-session cap and multi-factor requirement in **Settings → Access policy** (the `access_policy` namespace, one row per organization, written by its administrators). The deployment's values in **Settings → Authentication** are the default and the floor: an organization may tighten and never loosen. + +The rule per key — a value weaker than the deployment's is refused at the save, and enforcement holds the floor whatever is stored: + +- `allowed_ip_ranges`: each range must lie inside a deployment range (any range when the deployment sets none), and the list must admit the address it is saved from. A request must pass both lists. A session is scoped to an organization only from an address its list admits: the sign-in is refused with `403 IP_NOT_ALLOWED` when the organization the session would be scoped to does not admit the address, and so is a switch of the active organization. Sign-in itself, before any organization is chosen, is judged by the deployment's list alone. A platform administrator is held by the deployment's list only, so an organization that locks out its own administrators can be reopened. +- `session_idle_timeout_minutes`, `session_absolute_max_hours`, `max_concurrent_sessions_per_user`: `0` keeps the deployment's limit; a positive value may not exceed the deployment's. The shorter of the two applies to every session scoped to the organization. +- `mfa_required`: `off` keeps the deployment's level; a level may not be weaker than the deployment's. The stronger of the two applies. `administrators` holds platform administrators, the organization's own administrators (owner or admin membership) and delegated administrators (a permission set with an `adminScope`), and lets everyone else through. + +What to do on upgrade: + +- FROM `auth.mfa_required: true` / `false` TO `auth.mfa_required: 'everyone'` / `'off'`. A stored toggle is rewritten at the first boot; the `OS_AUTH_MFA_REQUIRED` env override still reads `true`/`false` with a warning for one major release — set it to `off`, `administrators` or `everyone`. +- FROM `sys_organization.require_mfa = true` TO the organization's `access_policy.mfa_required = 'everyone'`. Moved at the first boot (unless the organization already chose a level), and the column is cleared once the row lands; a move that fails is logged at `error` and retried at the next boot. The column is now readonly: a data-API write to it is refused like any other non-editable column. +- FROM `new AuthManager({ mfaRequired: true })` / `AuthPlugin({ mfaRequired: true })` TO `mfaRequirement: 'everyone'` (or `'administrators'`). + +What does not change: with no organization policy saved, every deployment enforces exactly what it enforced before, and the deployment's own keys keep their names. From 81c7b0d61cfc6c1b5c28f8bb108bbf74426cf4e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:16:32 +0000 Subject: [PATCH 04/17] wip: changeset disposition Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .changeset/30922-per-organization-access-policy.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/30922-per-organization-access-policy.md b/.changeset/30922-per-organization-access-policy.md index 20396fc6987..5ed0fb79f04 100644 --- a/.changeset/30922-per-organization-access-policy.md +++ b/.changeset/30922-per-organization-access-policy.md @@ -9,7 +9,7 @@ feat(plugin-auth, service-settings)!: the access policy is set per organization, Clause-②: yes (narrowing) — a new `access_policy` settings namespace (scope `tenant`) carries each organization's allowed IP ranges, idle, absolute and concurrent session limits and multi-factor requirement; `auth.mfa_required` changes from a toggle to the levels `off` / `administrators` / `everyone`, `sys_organization.require_mfa` is no longer writable, and `AuthManagerOptions.mfaRequired` becomes `mfaRequirement`. Metadata-protocol: none - + **BREAKING.** The access policy is set per organization (rulings of record on objectstack-ai/cloud#3668: 「支持按组织(推荐)」, 「可以,按三档做(推荐)」). Each organization sets its own allowed IP ranges, session idle timeout, absolute session lifetime, concurrent-session cap and multi-factor requirement in **Settings → Access policy** (the `access_policy` namespace, one row per organization, written by its administrators). The deployment's values in **Settings → Authentication** are the default and the floor: an organization may tighten and never loosen. @@ -21,8 +21,8 @@ The rule per key — a value weaker than the deployment's is refused at the save What to do on upgrade: -- FROM `auth.mfa_required: true` / `false` TO `auth.mfa_required: 'everyone'` / `'off'`. A stored toggle is rewritten at the first boot; the `OS_AUTH_MFA_REQUIRED` env override still reads `true`/`false` with a warning for one major release — set it to `off`, `administrators` or `everyone`. -- FROM `sys_organization.require_mfa = true` TO the organization's `access_policy.mfa_required = 'everyone'`. Moved at the first boot (unless the organization already chose a level), and the column is cleared once the row lands; a move that fails is logged at `error` and retried at the next boot. The column is now readonly: a data-API write to it is refused like any other non-editable column. -- FROM `new AuthManager({ mfaRequired: true })` / `AuthPlugin({ mfaRequired: true })` TO `mfaRequirement: 'everyone'` (or `'administrators'`). +- `auth.mfa_required` saved as on now reads `everyone`, and as off `off`. A stored toggle is rewritten at the first boot; the `OS_AUTH_MFA_REQUIRED` env override still reads `true`/`false` with a warning for one major release — set it to `off`, `administrators` or `everyone`. +- An organization whose `sys_organization.require_mfa` was on now carries `access_policy.mfa_required` set to `everyone`. Moved at the first boot (unless the organization already chose a level), and the column is cleared once the row lands; a move that fails is logged at `error` and retried at the next boot. The column is now readonly: a data-API write to it is refused like any other non-editable column. +- A host that passed the auth manager or plugin option `mfaRequired: true` passes `mfaRequirement: 'everyone'` (or `'administrators'`) instead. What does not change: with no organization policy saved, every deployment enforces exactly what it enforced before, and the deployment's own keys keep their names. From ce387937c5081310e5f4ec90f651f5de34f34c95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:16:22 +0000 Subject: [PATCH 05/17] wip: fix tests, regen bundles, engine double ledger Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../apps/translations/en.objects.generated.ts | 4 +-- .../translations/es-ES.objects.generated.ts | 4 +-- .../translations/ja-JP.objects.generated.ts | 4 +-- .../translations/zh-CN.objects.generated.ts | 4 +-- .../src/auth-manager-database-hooks.ts | 2 +- ...ganization-access-policy-mfa-level.test.ts | 3 ++- .../src/organization-access-policy.test.ts | 27 ++++++++++++------- .../src/sys-organization-update-door.test.ts | 13 +++++---- ...ettings-visibility-declaration.pin.test.ts | 8 ++++-- scripts/engine-double-contract.pinned.json | 20 ++++++++++++++ 10 files changed, 63 insertions(+), 26 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index d6c4a4fde83..72c1c0add67 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -556,8 +556,8 @@ export const enObjects: NonNullable = { help: "JSON-serialized organization metadata" }, require_mfa: { - label: "Require Multi-Factor Auth", - help: "Turn on to require every member of this organization to set up an authenticator app before they can access data. Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, saving this as on is refused." + label: "Require Multi-Factor Auth (retired)", + help: "Retired. Set this organization's multi-factor requirement in Settings → Access policy. A value that was on here was moved there as \"everyone\" and cleared." }, timezone: { label: "Timezone", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 362cb7c7ff0..28a379d968d 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -556,8 +556,8 @@ export const esESObjects: NonNullable = { help: "Metadatos de la organización serializados en JSON." }, 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. 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." + label: "Exigir autenticación multifactor (retirado)", + help: "Retirado. Define el requisito multifactor de esta organización en Ajustes → Política de acceso. Un valor que estaba activado aquí se trasladó allí como «Todos» y se borró." }, timezone: { label: "Zona horaria", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 39fb7fec74d..d12ef38bfbf 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -556,8 +556,8 @@ export const jaJPObjects: NonNullable = { help: "JSON シリアライズされた組織メタデータ" }, require_mfa: { - label: "多要素認証を必須化", - help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。このデプロイで二要素認証がオンになっている必要があります(OS_AUTH_TWO_FACTOR=true)。オフの間は true で保存すると拒否されます。" + label: "多要素認証を必須化(廃止)", + help: "廃止されました。この組織の多要素認証の要件は「設定 → アクセスポリシー」で設定してください。ここでオンだった値は「全員」として移行され、クリアされました。" }, timezone: { label: "タイムゾーン", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index ff5ab7c0cb6..21b7563d9e9 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -556,8 +556,8 @@ export const zhCNObjects: NonNullable = { help: "JSON 序列化的组织元数据" }, require_mfa: { - label: "强制多因素认证", - help: "开启后,该组织的每位成员都必须先绑定身份验证器 App,才能访问数据。需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true);双重验证关闭时,保存为开启会被拒绝。" + label: "强制多因素认证(已停用)", + help: "已停用。请在“设置 → 访问策略”中设置本组织的多因素认证要求。此处原为开启的值已迁移为“所有人”并清除。" }, timezone: { label: "时区", diff --git a/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts b/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts index 7308ce60836..a8a3f14b28e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts @@ -316,7 +316,7 @@ export function composeDatabaseHooks( const verdict = await this.judgeSessionScoping(userId, organizationId, await readRequestClientAddress()); if (verdict === 'admitted') return; const { APIError } = await import('better-auth/api'); - throw new APIError('FORBIDDEN', { ...SESSION_SCOPING_REFUSAL }); + throw new APIError('FORBIDDEN', { code: SESSION_SCOPING_REFUSAL.code, message: SESSION_SCOPING_REFUSAL.message }); }; const sessionBefore = async (session: any, ctx: any) => { const captured = await captureSessionAddress(session); diff --git a/packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts b/packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts index 1cb5e46ea14..fcbda1ca670 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts @@ -27,6 +27,7 @@ vi.mock('@objectstack/core', async (importOriginal) => { }; }); +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; import { AuthManager } from './auth-manager'; const SECRET = 'test-secret-at-least-32-chars-long'; @@ -36,7 +37,7 @@ const ORG_B = 'org_beta'; /** An engine answering an unenrolled user whose grace period has lapsed. */ const unenrolledUsers = () => ({ - findOne: vi.fn(async (_obj: string, q: any) => ({ id: q?.where?.id, two_factor_enabled: false, mfa_required_at: LAPSED() })), + findOne: vi.fn(async (obj: string, q: any) => (assertEngineFindOnePredicate(obj, q), { id: q?.where?.id, two_factor_enabled: false, mfa_required_at: LAPSED() })), update: vi.fn(async () => ({})), count: vi.fn(async () => 0), }); diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts index fa0dea92f17..824cdbd2f00 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { assertEngineDeleteDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { AuthManager } from './auth-manager'; import { accessPolicyFloorRefusal, @@ -161,7 +161,8 @@ const createMemoryEngine = () => { async count(name: string, q: any = {}) { return rows(name).filter((r) => matches(r, q.where)).length; }, - async update(name: string, patch: any) { + async update(name: string, patch: any, options?: any) { + assertEngineUpdateDispatch(patch, options); const row = rows(name).find((r) => r.id === patch.id); if (!row) return null; Object.assign(row, patch); @@ -212,8 +213,14 @@ async function twoOrganizationDeployment() { secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, - membershipPolicy: 'invite-only', + // Two self-registered members: open self-registration (the second + // sign-up is past the first-user bypass). + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, + emailAndPassword: { enabled: true, requireEmailVerification: false }, } as any); + // The open audience's declared set has no security catalog behind this + // harness; it resolves here, as it does on a composed deployment. + vi.spyOn(manager as any, 'selfRegistrationSetResolvable').mockResolvedValue(true); const policies: Record> = { [ORG_A]: { allowed_ip_ranges: '10.1.0.0/16', session_idle_timeout_minutes: 5 }, [ORG_B]: { allowed_ip_ranges: '10.2.0.0/16' }, @@ -224,8 +231,10 @@ async function twoOrganizationDeployment() { }); for (const [email, org] of [['alice@alpha.test', ORG_A], ['bob@beta.test', ORG_B]] as const) { const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name: email }, '127.0.0.1'); - expect(res.status).toBe(200); + expect(res.status, `${email}: ${await res.clone().text()}`).toBe(200); const user = (engine.tables.get('sys_user') ?? []).find((u: any) => u.email === email); + // Whatever membership the sign-up settled is replaced by the one under test. + engine.tables.set('sys_member', (engine.tables.get('sys_member') ?? []).filter((m: any) => m.user_id !== user.id)); await engine.insert('sys_member', { user_id: user.id, organization_id: org, role: 'member', created_at: new Date() }); } return { engine, manager }; @@ -275,12 +284,12 @@ describe('two organizations, each policy enforced on its own members', () => { const alice = cookieFrom(await signIn(manager, 'alice@alpha.test', IN_A)); const bob = cookieFrom(await signIn(manager, 'bob@beta.test', IN_B)); // Both sessions were scoped at creation (ADR-0093 D9) and last active an hour ago. - const sessions = (engine.tables.get('sys_session') ?? []) as any[]; + // (The sign-up sessions from the harness carry another organization or none.) + const scopedTo = (row: any) => row.active_organization_id ?? row.activeOrganizationId; + const sessions = ((engine.tables.get('sys_session') ?? []) as any[]).filter((row) => [ORG_A, ORG_B].includes(scopedTo(row))); + expect(sessions.map(scopedTo).sort()).toEqual([ORG_A, ORG_B]); const hourAgo = new Date(Date.now() - 60 * 60_000); - for (const row of sessions) { - expect([ORG_A, ORG_B]).toContain(row.active_organization_id ?? row.activeOrganizationId); - Object.assign(row, { last_activity_at: hourAgo }); - } + for (const row of sessions) Object.assign(row, { last_activity_at: hourAgo }); const getSession = (cookie: string, remoteAddress: string) => manager.handleRequest( new Request('http://localhost:3000/api/v1/auth/get-session', { headers: { cookie } }), diff --git a/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts b/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts index 5566ef75397..422ad454000 100644 --- a/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts +++ b/packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts @@ -50,6 +50,9 @@ import { managedExtensionEditableFields } from './managed-extension-fields.js'; /** The columns the ruling names, in the whitelist's own order — less `require_mfa`, retired with the multi-factor levels (`organization-access-policy.ts`). */ const RULED_COLUMNS = ['parent_organization_id', 'sort_order', 'timezone']; +/** Retired columns: still provisioned, written by no generic surface (the D4 assertion below holds them readonly). */ +const RETIRED_COLUMNS = ['require_mfa']; + /** better-auth's own organization columns on this object — protocol fields. */ const BETTER_AUTH_COLUMNS = ['name', 'slug', 'logo', 'metadata']; @@ -96,17 +99,17 @@ describe('#15873 — the whitelist is the ruled columns (less the retired requir it('every whitelisted name is a declared field, and every better-auth column named here is too', () => { const fields = Object.keys(SysOrganization.fields as Record); - for (const name of [...RULED_COLUMNS, ...BETTER_AUTH_COLUMNS, ...SYSTEM_COLUMNS]) { + for (const name of [...RULED_COLUMNS, ...BETTER_AUTH_COLUMNS, ...SYSTEM_COLUMNS, ...RETIRED_COLUMNS]) { expect(fields, `${name} is declared on sys_organization`).toContain(name); } - // And the three lists above partition the object: a column this file does + // And the four lists above partition the object: a column this file does // not classify is a column the D4 assertion below would judge blind. - expect([...fields].sort()).toEqual([...RULED_COLUMNS, ...BETTER_AUTH_COLUMNS, ...SYSTEM_COLUMNS].sort()); + expect([...fields].sort()).toEqual([...RULED_COLUMNS, ...BETTER_AUTH_COLUMNS, ...SYSTEM_COLUMNS, ...RETIRED_COLUMNS].sort()); }); }); describe('#15873 — the guard holds the column gate on a user-context update', () => { - it.each(BETTER_AUTH_COLUMNS)('%s alone is REFUSED — 403 PERMISSION_DENIED, never silently ignored', async (column) => { + it.each([...BETTER_AUTH_COLUMNS, ...RETIRED_COLUMNS])('%s alone is REFUSED — 403 PERMISSION_DENIED, never silently ignored', async (column) => { const data: Record = { id: 'org_1', [column]: 'hijacked' }; const err = await guardedUpdate(data); expect(err, `${column} must be refused`).toBeTruthy(); @@ -158,7 +161,7 @@ describe('#15873 — ADR-0092 D4: the edit form offers exactly what the guard ad expect(fields[column]?.readonly, `${column} must not be readonly`).not.toBe(true); }); - it.each(BETTER_AUTH_COLUMNS)('%s is readonly in the form — the guard would strip or refuse it', (column) => { + it.each([...BETTER_AUTH_COLUMNS, ...RETIRED_COLUMNS])('%s is readonly in the form — the guard would strip or refuse it', (column) => { expect(fields[column]?.readonly, `${column} must be readonly`).toBe(true); }); diff --git a/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts b/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts index 1d4c8bd10fe..1a5b9547193 100644 --- a/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts +++ b/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts @@ -63,7 +63,7 @@ describe('settings `visible` — declaration ⇄ evaluator', () => { expect(refusals).toEqual([]); }); - it('the corpus is still the size the ruling measured — 9 manifests, 96 predicates', () => { + it('the corpus is still the size the ruling measured — 10 manifests, 96 predicates', () => { // If a bundled manifest gains or loses a `visible`, this number moves and // the reader is sent back to the measurement rather than trusting a stale // one. It is a tripwire on the premise, not a rule about how many @@ -74,7 +74,11 @@ describe('settings `visible` — declaration ⇄ evaluator', () => { ]); // 10 -> 9 at #30204: the `feature_flags` manifest was removed; it declared // no `visible`, so the predicate count does not move. - expect(builtinSettingsManifests).toHaveLength(9); + // 9 -> 10 with the `access_policy` manifest (an organization's own access + // policy, ADR-0069 TL;DR 6); it declares no `visible`, so the predicate + // count does not move. `auth.mfa_grace_period_days` keeps its one + // predicate, now over the three-level `mfa_required`. + expect(builtinSettingsManifests).toHaveLength(10); // 94 -> 96 at #11768: the `audience` group added two gated fields // (`audience_allowed_email_domains`, `audience_self_registration_permission_set`), // each with one `visible` predicate over `data.audience_posture`. diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7a7fbd3e301..6d3abb45ec8 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3706,6 +3706,26 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/organization-access-policy.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/organization-access-policy.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/organization-access-policy.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/organization-add-member.test.ts", "verb": "delete", From 6a149950f94087c5e60aa35f59288fc75944bf63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:50:43 +0000 Subject: [PATCH 06/17] test(plugin-auth): the account-event audit uses mfaRequirement Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-account-event-audit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts b/packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts index 04cd43e1151..c6f61c640d8 100644 --- a/packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts +++ b/packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts @@ -273,7 +273,7 @@ describe('[#30924] an MFA-held sign-in records the hold on its own login row', ( // ADR-0069 D3: a global MFA requirement, a lapsed grace window, an account // that never enrolled. The sign-in itself succeeds (a session is minted and // the user can still reach `/two-factor/*` to enroll); the session is held. - const MFA_POLICY = { mfaRequired: true, mfaGracePeriodDays: 0, plugins: { twoFactor: true } }; + const MFA_POLICY = { mfaRequirement: 'everyone', mfaGracePeriodDays: 0, plugins: { twoFactor: true } }; /** Start the account's grace clock a day ago, so the hold applies now. */ function lapseGrace(engine: any): void { From 220f5d43f87d480351e3b366e168dabc7bd4ba1f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:27:42 +0000 Subject: [PATCH 07/17] =?UTF-8?q?fix:=20gate=20findings=20=E2=80=94=20limi?= =?UTF-8?q?t=20by=20presence,=20typed=20security=20slot,=20collection-time?= =?UTF-8?q?=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-plugin.ts | 7 +++++-- .../src/organization-access-policy-binding.ts | 10 +++++++--- .../plugin-auth/src/organization-access-policy.test.ts | 2 +- .../src/held-session-vendor-admin-doors.pin.test.ts | 4 ++++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 5bc5c8a7637..831ad9be675 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -69,7 +69,10 @@ import { } from './identity-write-guard.js'; import { registerLastAdminGuard } from './last-admin-guard.js'; import { registerApiKeyRestoreGuard } from './api-key-restore-guard.js'; -import { bindOrganizationAccessPolicy } from './organization-access-policy-binding.js'; +import { + bindOrganizationAccessPolicy, + type OrganizationAccessPolicySecuritySurface, +} from './organization-access-policy-binding.js'; import { MFA_REQUIREMENT_LEVELS, readMfaRequirementLevel } from './organization-access-policy.js'; import { registerMfaRequiredEnrollmentPredicate } from './mfa-required-enrollment-predicate.js'; import { registerPersonalViewHooks } from './personal-view-hooks.js'; @@ -2809,7 +2812,7 @@ export class AuthPlugin implements Plugin { engine: () => manager.getDataEngine(), security: () => { try { - return ctx.getService('security'); + return ctx.getService('security'); } catch { return undefined; } diff --git a/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts b/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts index a7c46891460..4b28b924866 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts @@ -55,8 +55,12 @@ export interface AccessPolicySettingsSurface { ): void; } -/** The `security` service member the delegated-administrator check asks (plugin-security). */ -interface SecuritySetResolver { +/** + * The `security` service member the delegated-administrator check asks + * (plugin-security's registered security service). Structural: plugin-auth + * does not depend on plugin-security. + */ +export interface OrganizationAccessPolicySecuritySurface { resolvePermissionSetsForContext(context: Record): Promise; } @@ -66,7 +70,7 @@ export interface OrganizationAccessPolicyBindingDeps { /** The data engine, for the cross-organization MFA probe and the column move. */ engine: () => any; /** The `security` service, when plugin-security is composed. */ - security: () => SecuritySetResolver | undefined; + security: () => OrganizationAccessPolicySecuritySurface | undefined; logger: { info(message: string): void; warn(message: string): void; error(message: string): void }; } diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts index 824cdbd2f00..999975c310d 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts @@ -155,7 +155,7 @@ const createMemoryEngine = () => { }, async find(name: string, q: any = {}) { let out = rows(name).filter((r) => matches(r, q.where)); - if (q.limit) out = out.slice(0, q.limit); + if (typeof q.limit === 'number') out = out.slice(0, q.limit); return out.map((r) => project(r, q.fields)); }, async count(name: string, q: any = {}) { diff --git a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts index 555362708c1..f46ee03c7b4 100644 --- a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts +++ b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts @@ -37,6 +37,10 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +// The harness loads these two inside a function body; paid at collection, so +// no clocked window carries their first load (`check:test-source-alias`). +import '@objectstack/service-datasource'; +import '@objectstack/service-automation'; import { bootStack } from './harness.js'; const app = { From 9f82dfd429a9a21d1b32ec9dc330fb6cf562578f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:38:26 +0000 Subject: [PATCH 08/17] fix: ru/de/ko access policy copy, self-lock-out refusal type Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../src/organization-access-policy.ts | 7 +++++-- .../service-settings/src/translations/de.ts | 21 +++++++++++++++++++ .../service-settings/src/translations/ko.ts | 21 +++++++++++++++++++ .../service-settings/src/translations/ru.ts | 21 +++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.ts b/packages/plugins/plugin-auth/src/organization-access-policy.ts index bb98d70d563..2cf4db2c5fe 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy.ts @@ -429,7 +429,7 @@ interface AccessPolicyWriteInput { */ export function accessPolicyWritePredicate( floor: () => DeploymentAccessFloor | undefined, - selfLockOut?: (input: unknown) => { message: string; constraint: { clientAddress: string | null } } | undefined, + selfLockOut?: (input: unknown) => { message: string; constraint: object } | undefined, ): (input: unknown) => AccessPolicyRefusal | undefined { return (input) => { const { key, next, locale } = (input ?? {}) as AccessPolicyWriteInput; @@ -438,7 +438,10 @@ export function accessPolicyWritePredicate( const refusal = accessPolicyFloorRefusal(key, next, current, locale); if (refusal) return refusal; } - if (key === ACCESS_POLICY_KEYS.allowedIpRanges && selfLockOut) return selfLockOut(input); + if (key === ACCESS_POLICY_KEYS.allowedIpRanges && selfLockOut) { + const refusal = selfLockOut(input); + return refusal ? { message: refusal.message, constraint: { ...refusal.constraint } } : undefined; + } return undefined; }; } diff --git a/packages/services/service-settings/src/translations/de.ts b/packages/services/service-settings/src/translations/de.ts index de066d7efbd..0c66a8c918a 100644 --- a/packages/services/service-settings/src/translations/de.ts +++ b/packages/services/service-settings/src/translations/de.ts @@ -344,6 +344,7 @@ export const de: PlatformTranslationData = { }, mfa_required: { label: 'Mehrfaktor-Authentifizierung verlangen', + options: { off: 'Aus', administrators: 'Administratoren', everyone: 'Alle' }, help: 'Benutzer ohne registrierten Authenticator verlieren nach Ablauf ihrer Schonfrist den Zugriff auf Daten. Das Aktivieren schaltet auch die Zwei-Faktor-Funktion ein, damit Benutzer sich registrieren können.', }, mfa_grace_period_days: { @@ -404,6 +405,26 @@ export const de: PlatformTranslationData = { }, }, }, + access_policy: { + title: 'Zugriffsrichtlinie', + description: 'Die Anmeldebeschränkungen dieser Organisation. Die Authentifizierungseinstellungen der Bereitstellung gelten immer; diese können sie nur ergänzen.', + groups: { + network: { title: 'Netzwerk', description: 'Von wo aus sich Mitglieder dieser Organisation anmelden dürfen.' }, + sessions: { title: 'Sitzungen', description: 'Wie lange die Sitzung eines Mitglieds gültig bleibt. Jedes Limit kann nur kürzer sein als das der Bereitstellung.' }, + multi_factor: { title: 'Multi-Faktor', description: 'Wer in dieser Organisation sein Konto mit einer Authenticator-App (TOTP) schützen muss.' }, + }, + keys: { + allowed_ip_ranges: { label: 'Erlaubte IP-Bereiche' }, + session_idle_timeout_minutes: { label: 'Leerlauf-Zeitlimit (Minuten)' }, + session_absolute_max_hours: { label: 'Maximale Sitzungsdauer (Stunden)' }, + max_concurrent_sessions_per_user: { label: 'Max. gleichzeitige Sitzungen pro Benutzer' }, + mfa_required: { + label: 'Multi-Faktor-Authentifizierung verlangen', + options: { off: 'Aus', administrators: 'Administratoren', everyone: 'Alle' }, + }, + }, + }, + storage: { title: 'Dateispeicher', description: 'Backend für Anhänge, Exporte und Benutzer-Uploads.', diff --git a/packages/services/service-settings/src/translations/ko.ts b/packages/services/service-settings/src/translations/ko.ts index 2c343bcc0a8..981be8b16a0 100644 --- a/packages/services/service-settings/src/translations/ko.ts +++ b/packages/services/service-settings/src/translations/ko.ts @@ -271,6 +271,7 @@ export const ko: PlatformTranslationData = { rate_limit_window_seconds: { label: '인증 속도 제한: 창(초)', help: '위의 요청 상한을 세는 슬라이딩 창입니다.' }, mfa_required: { label: '다단계 인증 요구', + options: { off: '끄기', administrators: '관리자', everyone: '모든 사람' }, help: '인증 앱을 등록하지 않은 사용자는 유예 기간이 끝나면 데이터에 접근할 수 없습니다. 이 항목을 켜면 사용자가 등록할 수 있도록 2단계 인증 기능도 켜집니다.', }, mfa_grace_period_days: { label: 'MFA 유예 기간(일)', help: '강제 차단 전까지 사용자가 등록을 미룰 수 있는 기간입니다. 0이면 즉시 차단합니다.' }, @@ -316,6 +317,26 @@ export const ko: PlatformTranslationData = { google_client_secret: { label: 'Google 클라이언트 보안 비밀', help: '암호화해 저장합니다. 서버 설정에서도 지정할 수 있습니다.' }, }, }, + access_policy: { + title: '액세스 정책', + description: '이 조직의 로그인 제한입니다. 배포의 인증 설정은 항상 적용되며, 여기서는 제한을 추가하기만 할 수 있습니다.', + groups: { + network: { title: '네트워크', description: '이 조직의 구성원이 로그인할 수 있는 위치.' }, + sessions: { title: '세션', description: '구성원 세션의 유효 기간. 각 한도는 배포의 한도보다 짧게만 설정할 수 있습니다.' }, + multi_factor: { title: '다단계 인증', description: '이 조직에서 인증 앱(TOTP)으로 계정을 보호해야 하는 사람.' }, + }, + keys: { + allowed_ip_ranges: { label: '허용된 IP 범위' }, + session_idle_timeout_minutes: { label: '유휴 시간 제한(분)' }, + session_absolute_max_hours: { label: '최대 세션 유지 시간(시간)' }, + max_concurrent_sessions_per_user: { label: '사용자당 최대 동시 세션 수' }, + mfa_required: { + label: '다단계 인증 요구', + options: { off: '끄기', administrators: '관리자', everyone: '모든 사람' }, + }, + }, + }, + storage: { title: '파일 저장소', description: '첨부 파일, 내보내기, 사용자 업로드에 쓰는 백엔드입니다.', diff --git a/packages/services/service-settings/src/translations/ru.ts b/packages/services/service-settings/src/translations/ru.ts index 28b6d7e471e..f43136c1262 100644 --- a/packages/services/service-settings/src/translations/ru.ts +++ b/packages/services/service-settings/src/translations/ru.ts @@ -353,6 +353,7 @@ export const ru: PlatformTranslationData = { }, mfa_required: { label: 'Требовать многофакторную аутентификацию', + options: { off: 'Выкл.', administrators: 'Администраторы', everyone: 'Все' }, help: 'Пользователи без зарегистрированного аутентификатора теряют доступ к данным по окончании льготного периода. Включение также активирует двухфакторную функцию, чтобы пользователи могли зарегистрироваться.', }, mfa_grace_period_days: { @@ -413,6 +414,26 @@ export const ru: PlatformTranslationData = { }, }, }, + access_policy: { + title: 'Политика доступа', + description: 'Ограничения входа этой организации. Настройки аутентификации развёртывания действуют всегда; эти могут только дополнять их.', + groups: { + network: { title: 'Сеть', description: 'Откуда участники этой организации могут входить.' }, + sessions: { title: 'Сеансы', description: 'Сколько действует сеанс участника. Каждый лимит может быть только короче, чем у развёртывания.' }, + multi_factor: { title: 'Многофакторная аутентификация', description: 'Кто в этой организации должен защищать учётную запись приложением-аутентификатором (TOTP).' }, + }, + keys: { + allowed_ip_ranges: { label: 'Разрешённые диапазоны IP' }, + session_idle_timeout_minutes: { label: 'Тайм-аут бездействия (минуты)' }, + session_absolute_max_hours: { label: 'Максимальная длительность сеанса (часы)' }, + max_concurrent_sessions_per_user: { label: 'Макс. одновременных сеансов на пользователя' }, + mfa_required: { + label: 'Требовать многофакторную аутентификацию', + options: { off: 'Выкл.', administrators: 'Администраторы', everyone: 'Все' }, + }, + }, + }, + storage: { title: 'Хранилище файлов', description: 'Бэкенд для вложений, экспорта и файлов, загружаемых пользователями.', From e1edf0785a126c10136a5869c12f18fd3041ccf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:41:10 +0000 Subject: [PATCH 09/17] test: verify pins and dogfood audit test use the MFA levels Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts | 2 +- .../verify/src/held-session-impersonation-target.pin.test.ts | 4 ++-- packages/verify/src/held-session-platform-admin.pin.test.ts | 4 ++-- .../verify/src/held-session-vendor-admin-doors.pin.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts b/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts index 60b4787ec60..e158a50b46f 100644 --- a/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts +++ b/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts @@ -280,7 +280,7 @@ describe('#30924: a sign-in that enforced MFA holds records the hold on its own heldUserId = String(held.id); const settings = await stack.kernel.getServiceAsync('settings'); - await settings.setMany('auth', { mfa_required: true, mfa_grace_period_days: 0 }); + await settings.setMany('auth', { mfa_required: 'everyone', mfa_grace_period_days: 0 }); await ql.update( 'sys_user', { id: heldUserId, two_factor_enabled: false, mfa_required_at: new Date(Date.now() - 86_400_000) }, diff --git a/packages/verify/src/held-session-impersonation-target.pin.test.ts b/packages/verify/src/held-session-impersonation-target.pin.test.ts index b6f94fde566..f6431b23b37 100644 --- a/packages/verify/src/held-session-impersonation-target.pin.test.ts +++ b/packages/verify/src/held-session-impersonation-target.pin.test.ts @@ -162,13 +162,13 @@ async function clearReset(): Promise { async function armMfa(): Promise { // Two-factor with it, as the `auth.mfa_required` setting applies them: the // gate holds a user for MFA only while they can enroll. - auth.applyConfigPatch({ mfaRequired: true, mfaGracePeriodDays: 7, plugins: { twoFactor: true } }); + auth.applyConfigPatch({ mfaRequirement: 'everyone', mfaGracePeriodDays: 7, plugins: { twoFactor: true } }); await ql.update('sys_user', { id: targetId, mfa_required_at: new Date(Date.now() - 10 * DAY_MS) }, SYS); await ql.update('sys_user', { id: adminId, mfa_required_at: new Date() }, SYS); } async function disarmMfa(): Promise { - auth.applyConfigPatch({ mfaRequired: false }); + auth.applyConfigPatch({ mfaRequirement: 'off' }); } /** diff --git a/packages/verify/src/held-session-platform-admin.pin.test.ts b/packages/verify/src/held-session-platform-admin.pin.test.ts index ac7b7d768a9..057d0030255 100644 --- a/packages/verify/src/held-session-platform-admin.pin.test.ts +++ b/packages/verify/src/held-session-platform-admin.pin.test.ts @@ -151,14 +151,14 @@ const HELD_STATES: readonly HeldState[] = [ { code: 'MFA_REQUIRED', async arm() { - auth.applyConfigPatch({ mfaRequired: true, mfaGracePeriodDays: 0 }); + auth.applyConfigPatch({ mfaRequirement: 'everyone', mfaGracePeriodDays: 0 }); const lapsed = new Date(Date.now() - 10 * DAY_MS); for (const id of [adminId, memberId]) { await ql.update('sys_user', { id, mfa_required_at: lapsed }, SYS); } }, async disarm() { - auth.applyConfigPatch({ mfaRequired: false }); + auth.applyConfigPatch({ mfaRequirement: 'off' }); }, }, ]; diff --git a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts index f46ee03c7b4..49b85911c98 100644 --- a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts +++ b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts @@ -261,12 +261,12 @@ const HELD_STATES: readonly HeldState[] = [ async arm() { // Two-factor with it, as the `auth.mfa_required` setting applies them: the // gate holds a user for MFA only while they can enroll. - auth.applyConfigPatch({ mfaRequired: true, mfaGracePeriodDays: 0, plugins: { twoFactor: true } }); + auth.applyConfigPatch({ mfaRequirement: 'everyone', mfaGracePeriodDays: 0, plugins: { twoFactor: true } }); const lapsed = new Date(Date.now() - 10 * DAY_MS); for (const id of everyone()) await ql.update('sys_user', { id, mfa_required_at: lapsed }, SYS); }, async disarm() { - auth.applyConfigPatch({ mfaRequired: false }); + auth.applyConfigPatch({ mfaRequirement: 'off' }); }, }, { From 4513cb9d4241b1a3049d7b01b9e59db21e15035b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:08:09 +0000 Subject: [PATCH 10/17] test(verify): pay the harness's dynamic loads at collection Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../verify/src/held-session-impersonation-target.pin.test.ts | 4 ++++ packages/verify/src/held-session-platform-admin.pin.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/verify/src/held-session-impersonation-target.pin.test.ts b/packages/verify/src/held-session-impersonation-target.pin.test.ts index f6431b23b37..1e4cc8e5fff 100644 --- a/packages/verify/src/held-session-impersonation-target.pin.test.ts +++ b/packages/verify/src/held-session-impersonation-target.pin.test.ts @@ -47,6 +47,10 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +// The harness loads these two inside a function body; paid at collection, so +// no clocked window carries their first load (`check:test-source-alias`). +import '@objectstack/service-datasource'; +import '@objectstack/service-automation'; import { bootStack } from './harness.js'; const app = { diff --git a/packages/verify/src/held-session-platform-admin.pin.test.ts b/packages/verify/src/held-session-platform-admin.pin.test.ts index 057d0030255..b18b6a9abd2 100644 --- a/packages/verify/src/held-session-platform-admin.pin.test.ts +++ b/packages/verify/src/held-session-platform-admin.pin.test.ts @@ -30,6 +30,10 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +// The harness loads these two inside a function body; paid at collection, so +// no clocked window carries their first load (`check:test-source-alias`). +import '@objectstack/service-datasource'; +import '@objectstack/service-automation'; import { bootStack } from './harness.js'; const app = { From d302a81e3e4eef08946cf2b1bf2d1f2d794adc29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:46:16 +0000 Subject: [PATCH 11/17] feat(plugin-auth): per-organization cap and switch judgement, roaming re-judgement, retired option refused Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- ...connect-agent-both-halves-wire.pin.test.ts | 13 +- .../src/apps/translations/zh-Hant.ts | 1 + .../src/auth-manager-after-hook.ts | 7 +- .../src/auth-manager-database-hooks.ts | 37 +++-- .../auth-manager-password-session-policy.ts | 84 +++++++++--- .../src/auth-manager-plugin-list.ts | 6 +- .../plugins/plugin-auth/src/auth-manager.ts | 112 ++++++++++++++- .../src/organization-access-policy-binding.ts | 15 +- .../src/organization-access-policy.test.ts | 129 +++++++++++++++++- .../src/organization-access-policy.ts | 56 +++++++- .../organization-mfa-enrollment-guard.test.ts | 15 ++ .../src/manifests/access-policy.manifest.ts | 4 +- 12 files changed, 424 insertions(+), 55 deletions(-) diff --git a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts index 5b43609fbe6..5691ee9944f 100644 --- a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts +++ b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts @@ -260,17 +260,20 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( // The Setup tree the platform admin is served BY THIS COMPOSITION: 9 // authored groups less `group_approvals` and `group_integrations`, // which nothing in it contributes into and the filter collapses, plus - // the 23 entries `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one. + // the 24 entries `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one. // (25 until #25098 moved the unfiltered Packages entry to Studio; 24 // until #30204 removed the Feature Flags entry with its namespace; 32 // until #30895 moved the MCP entry out of `group_integrations`, the - // one entry this composition put there.) + // one entry this composition put there; 31 until the organization + // Access policy entry, `nav_settings_access_policy`, joined + // Configuration.) // `nav_settings_branding` is not among them, and not because of a // tenancy wall (this harness boots none): since #24766 it is // contributed by `@objectstack/service-settings` together with the // `branding` namespace, offered only where no organization wall stands, // and this harness composes platform-objects and the MCP bundle alone. - expect(allowedIds).toHaveLength(31); + expect(allowedIds).toHaveLength(32); + expect(allowedIds).toContain('nav_settings_access_policy'); expect(allowedIds).not.toContain('nav_settings_branding'); // The two per-user stores are reached from the User record page, not // from Setup's menu (#23803). @@ -293,8 +296,8 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(withAccountHalf.statusCode).toBe(200); expect(withoutAccountHalf.statusCode).toBe(200); expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item)); - // 31: the composition the count in the test above states. - expect(navIds(withAccountHalf.body?.item)).toHaveLength(31); + // 32: the composition the count in the test above states. + expect(navIds(withAccountHalf.body?.item)).toHaveLength(32); // The mechanism behind the invariance, asserted rather than assumed: // the bundle aims exactly one contribution at `setup` and one at diff --git a/packages/platform-objects/src/apps/translations/zh-Hant.ts b/packages/platform-objects/src/apps/translations/zh-Hant.ts index 3ae3e80b04b..7732e3ccdf3 100644 --- a/packages/platform-objects/src/apps/translations/zh-Hant.ts +++ b/packages/platform-objects/src/apps/translations/zh-Hant.ts @@ -95,6 +95,7 @@ export const zhHant: TranslationData = { nav_settings_sms: { label: '簡訊投遞' }, nav_settings_branding: { label: '品牌' }, nav_settings_auth: { label: '驗證' }, + nav_settings_access_policy: { label: '存取原則' }, nav_settings_storage: { label: '檔案儲存' }, nav_settings_ai: { label: 'AI 與 Embedder' }, nav_settings_knowledge: { label: '知識庫' }, diff --git a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts index 7adea600967..51904617301 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts @@ -171,7 +171,12 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) { // deployment's. const scopedOrg = ctx?.context?.newSession?.session?.activeOrganizationId; if (typeof uid === 'string') { - await this.enforceConcurrentCap(uid, typeof scopedOrg === 'string' && scopedOrg ? scopedOrg : undefined); + const newSessionId = ctx?.context?.newSession?.session?.id; + await this.enforceConcurrentCap( + uid, + typeof scopedOrg === 'string' && scopedOrg ? scopedOrg : undefined, + typeof newSessionId === 'string' ? newSessionId : undefined, + ); } // ADR-0069 D7 — login audit: stamp last_login_at/ip on success. // Independent of lockout config (recordSignInOutcome no-ops when diff --git a/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts b/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts index e2130bb423c..d50159e7295 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts @@ -75,7 +75,9 @@ export interface DatabaseHooksHost { audienceLogError: AuthManager['audienceLogError']; getDataEngine: AuthManager['getDataEngine']; isCreatedInThisRequest: AuthManager['isCreatedInThisRequest']; + enforceConcurrentCap: AuthManager['enforceConcurrentCap']; judgeSessionScoping: AuthManager['judgeSessionScoping']; + judgeSessionSwitch: AuthManager['judgeSessionSwitch']; linkingContext: AuthManager['linkingContext']; logDurabilityDegradation: AuthManager['logDurabilityDegradation']; resolveUserAuthGates: AuthManager['resolveUserAuthGates']; @@ -87,7 +89,7 @@ export interface DatabaseHooksHost { /** The body of `AuthManager#composeDatabaseHooks`; the method's docblock there is its contract. */ export function composeDatabaseHooks( - this: Pick, + this: Pick, host?: BetterAuthOptions['databaseHooks'], ): BetterAuthOptions['databaseHooks'] { // A landed link ends a standing unlink record (implicit-account-linking.ts, @@ -387,15 +389,30 @@ export function composeDatabaseHooks( draft = { ...draft, ...(hostResult as any).data }; } } - // A switch of the active organization re-scopes the session: judged as - // the scoping at creation is (above). - if (typeof draft?.activeOrganizationId === 'string' && draft.activeOrganizationId) { - const session = ctx?.context?.session; - await refuseUnadmittedScoping( - draft.userId ?? session?.session?.userId ?? session?.user?.id, - draft.activeOrganizationId, - ctx, - ); + // A switch of the active organization re-scopes the session, and is judged + // against the TARGET organization's policy before the switch lands + // (`organization-access-policy.ts`): its IP list, as the scoping at + // creation is (above); its idle and absolute limits, from the session row + // as it stands — the switch request runs no heartbeat, so nothing refreshes + // `last_activity_at` ahead of the judgement — a session past either is + // ended in place and the switch refused; then its concurrent cap, counting + // this session as scoped to the target. + const target = draft?.activeOrganizationId; + const current = ctx?.context?.session?.session; + if (typeof target === 'string' && target && target !== current?.activeOrganizationId) { + const userId = draft.userId ?? current?.userId ?? ctx?.context?.session?.user?.id; + await refuseUnadmittedScoping(userId, target, ctx); + const sessionId = typeof current?.id === 'string' ? current.id : undefined; + if (sessionId) { + if ((await this.judgeSessionSwitch(sessionId, target)) === 'ended') { + const { APIError } = await import('better-auth/api'); + throw new APIError('UNAUTHORIZED', { + code: 'UNAUTHENTICATED', + message: "Your session has ended under this organization's session limits. Sign in again.", + }); + } + if (typeof userId === 'string' && userId) await this.enforceConcurrentCap(userId, target, sessionId); + } } const held = holdPrincipalSessionExpiry(draft, ctx); if (held) return { data: held }; diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts index 60af6a56201..2e07d7b9890 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts @@ -66,6 +66,7 @@ export interface PasswordSessionPolicyHost { getDataEngine: AuthManager['getDataEngine']; getOrganizationAccessPolicy: AuthManager['getOrganizationAccessPolicy']; isMfaAdministrator: AuthManager['isMfaAdministrator']; + judgeSessionScoping: AuthManager['judgeSessionScoping']; isTwoFactorEnabled: AuthManager['isTwoFactorEnabled']; logDurabilityDegradation: AuthManager['logDurabilityDegradation']; noteMfaRequirementWithoutEnrollment: AuthManager['noteMfaRequirementWithoutEnrollment']; @@ -615,20 +616,31 @@ function sessionHeartbeatMemoFor(engine: object): SessionHeartbeatMemo { } /** The body of `AuthManager#enforceSessionControls`; the method's docblock there is its contract. */ -export async function enforceSessionControls(this: Pick, sessionId: string | undefined, createdAtHint: unknown, organizationId?: string): Promise { +export async function enforceSessionControls( + this: Pick, + sessionId: string | undefined, + createdAtHint: unknown, + organizationId?: string, + door?: { address: string | undefined }, +): Promise { // [ADR-0069 TL;DR 6] The limits in force for this session: the shorter of // the deployment's and those of the organization it is scoped to. - const limits = effectiveSessionLimits( - this.config, - await this.getOrganizationAccessPolicy(organizationId), - ); + const organization = await this.getOrganizationAccessPolicy(organizationId); + const limits = effectiveSessionLimits(this.config, organization); const idleMin = limits.idleMinutes; const absHrs = limits.absoluteHours; + // [ADR-0069 TL;DR 6, D5] On a request through the HTTP auth door (`door`, + // carrying the address that door established), the organization's own IP + // list is judged again, so a session that roams outside a list — or that a + // tightened list no longer admits — is ended in place like one past its + // idle limit; the next request is anonymous, and a new sign-in from there + // is refused at the scoping (`judgeSessionScoping`). + const judgesAddress = !!door && !!organizationId && organization.allowedIpRanges.length > 0; // [#25343] Both limits off no longer returns here: `last_activity_at` is // "the last request on this session" on every deployment, not only where an // idle timeout reads it (ruling 6358844136, letter A). What both-off buys // instead is the memo below, so the added read is throttled like the write. - const limitsOff = idleMin <= 0 && absHrs <= 0; + const limitsOff = idleMin <= 0 && absHrs <= 0 && !judgesAddress; const engine = this.getDataEngine(); if (!engine || !sessionId) return; const memo = limitsOff ? sessionHeartbeatMemoFor(engine) : undefined; @@ -637,14 +649,18 @@ export async function enforceSessionControls(this: Pick 0) { + if (judgesAddress && typeof srow.user_id === 'string' + && (await this.judgeSessionScoping(srow.user_id, organizationId!, door!.address)) === 'refused') { + reason = 'ip_not_allowed'; + } + if (!reason && absHrs > 0) { const created = srow.created_at ?? createdAtHint; if (created && now - new Date(created as any).getTime() > absHrs * 3_600_000) reason = 'absolute_max'; } @@ -659,7 +675,7 @@ export async function enforceSessionControls(this: Pick this.logDurabilityDegradation( 'AuthManager: a session that exceeded its configured limit was NOT revoked — the ' @@ -718,31 +734,59 @@ export async function enforceSessionControls(this: Pick, userId: string, organizationId?: string): Promise { - // [ADR-0069 TL;DR 6] The cap in force: the smaller of the deployment's and - // that of the organization the new session is scoped to. - const cap = effectiveSessionLimits(this.config, await this.getOrganizationAccessPolicy(organizationId)).concurrent; - if (cap <= 0 || !userId) return; +export async function enforceConcurrentCap( + this: Pick, + userId: string, + organizationId?: string, + keepSessionId?: string, +): Promise { + // [ADR-0069 TL;DR 6] Two caps, each counting its own population + // (`organization-access-policy.ts`, "The concurrent cap"): + // - the deployment's counts EVERY live session of the user, whatever + // organization each is scoped to — its meaning before organizations had + // caps, so the floor never loosens; + // - the organization's own counts only the user's sessions scoped to that + // organization, and ends only those — a strict organization never evicts + // another organization's sessions. + // `keepSessionId` is the session this sweep runs for (the new one at sign-in, + // the switching one at an active-organization switch): it counts as scoped + // to `organizationId` and is kept, the oldest others are ended first. + const deploymentCap = Math.max(0, Math.floor(Number(this.config.maxConcurrentSessions) || 0)); + const organizationCap = organizationId + ? (await this.getOrganizationAccessPolicy(organizationId)).maxConcurrentSessions + : 0; + if ((deploymentCap <= 0 && organizationCap <= 0) || !userId) return; const engine = this.getDataEngine(); if (!engine) return; try { const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const rows = await engine.find('sys_session', { where: { user_id: userId }, - fields: ['id', 'created_at', 'expires_at', 'revoked_at'], + fields: ['id', 'created_at', 'expires_at', 'revoked_at', 'active_organization_id'], limit: 200, context: SYSTEM_CTX, }); const now = Date.now(); + const keptFirst = (a: any, b: any) => + (b.id === keepSessionId ? 1 : 0) - (a.id === keepSessionId ? 1 : 0) + || new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime(); const live = (Array.isArray(rows) ? rows : []) .filter((sn: any) => !sn.revoked_at && (!sn.expires_at || new Date(sn.expires_at).getTime() > now)) - .sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); - for (const sn of live.slice(cap)) { + .sort(keptFirst); + const over: any[] = deploymentCap > 0 ? live.slice(deploymentCap) : []; + if (organizationCap > 0) { + const ended = new Set(over.map((sn: any) => sn.id)); + const scoped = live.filter( + (sn: any) => !ended.has(sn.id) && (sn.id === keepSessionId || sn.active_organization_id === organizationId), + ); + over.push(...scoped.slice(organizationCap)); + } + for (const sn of over) { // [#12981] Per-session `.catch` so one refused revocation does not // abandon the rest of the sweep — but it reports instead of swallowing. await engine.update( 'sys_session', - { id: sn.id, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: 'concurrent_cap' }, + { id: sn.id, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: reason }, { context: SYSTEM_CTX } as any, ).catch((e: unknown) => this.logDurabilityDegradation( 'AuthManager: a session over the concurrent-session cap was NOT revoked — the sign-in ' @@ -752,7 +796,7 @@ export async function enforceConcurrentCap(this: Pick { const dataEngine = this.config.dataEngine; if (dataEngine) { const { customSession } = await import('better-auth/plugins/custom-session'); - plugins.push(customSession(async ({ user, session }) => { + plugins.push(customSession(async ({ user, session }, ctx?: any) => { if (!user?.id) return { user, session }; // ADR-0068 D1/D2 (renamed ADR-0090 D3) — emit ONE canonical @@ -998,10 +999,13 @@ export async function buildPluginList(this: PluginListHost): Promise { // revokes the session in place when exceeded (next request → 401). // [ADR-0069 TL;DR 6] Against the limits of the organization the session // is scoped to, never below the deployment's. + // On a request through the HTTP auth door, the organization's IP list + // is judged again with the address that door established. await this.enforceSessionControls( (session as any)?.id, (session as any)?.createdAt, (session as any)?.activeOrganizationId ?? undefined, + ctx?.request ? { address: await readRequestClientAddress() } : undefined, ); // An impersonation session carries the administrator who started it diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f647dcbcf84..600d318f316 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -42,6 +42,8 @@ import { } from '@objectstack/core'; import type { AuthGate } from '@objectstack/core'; import { + effectiveSessionLimits, + isMfaRequirementLevel, judgeSessionScopingAddress, readOrganizationAccessPolicy, type DeploymentAccessFloor, @@ -1316,6 +1318,29 @@ export function unreadableIpRangeEntries(entries: readonly T[]): T[] { * deployment would boot with a gate that admits nobody the entry was written * for, and say nothing. */ +/** + * [ADR-0069 D3, ruling 「可以,按三档做(推荐)」] Refuse the retired + * `mfaRequired` option and an `mfaRequirement` that is not one of the three + * levels, at construction and on every `applyConfigPatch`. The option became a + * level; a host still passing the boolean would otherwise run unheld, silently. + */ +export function assertMfaRequirementOptions(config: object): void { + if (Object.prototype.hasOwnProperty.call(config, 'mfaRequired') && (config as { mfaRequired?: unknown }).mfaRequired !== undefined) { + const was = (config as { mfaRequired?: unknown }).mfaRequired; + throw new Error( + `[auth] the mfaRequired option was retired (got ${JSON.stringify(was)}): multi-factor authentication is ` + + "required by level now. Pass mfaRequirement: 'everyone' for what mfaRequired: true meant, " + + "'administrators' for administrators only, or 'off'.", + ); + } + const level = (config as { mfaRequirement?: unknown }).mfaRequirement; + if (level !== undefined && !isMfaRequirementLevel(level)) { + throw new Error( + `[auth] invalid mfaRequirement ${JSON.stringify(level)}: it is one of 'off', 'administrators' or 'everyone'.`, + ); + } +} + export function assertAllowedIpRanges(configured: unknown): void { if (configured === undefined || configured === null) return; if (!Array.isArray(configured)) { @@ -1837,6 +1862,10 @@ export class AuthManager { // matches no address at the gate instead (`ipMatchesRange`), and the // binding names it. assertAllowedIpRanges(config.allowedIpRanges); + // [ADR-0069 D3] The retired boolean MFA option is refused, never dropped: + // dropped, a host that still passes it would run with no requirement and + // nothing would say so (`assertMfaRequirementOptions`). + assertMfaRequirementOptions(config); // WebContainer (StackBlitz) compatibility — install a synchronous // AsyncLocalStorage polyfill for better-auth's request-state global @@ -2257,6 +2286,7 @@ export class AuthManager { requireEmailVerificationFrom?: VerificationDeclarant; } = {}, ): void { + assertMfaRequirementOptions(patch); const next: AuthManagerOptions = { ...this.config, ...patch, @@ -5407,12 +5437,14 @@ export class AuthManager { // [#25224 cut 4] The body moved to `auth-manager-database-hooks.ts` (see `DatabaseHooksHost`). const readConfig = () => this.config; const readIdpLocale = () => this.idpLocale; - const hookHost: Pick = { + const hookHost: Pick = { audienceLogError: (...a) => this.audienceLogError(...a), get config() { return readConfig(); }, get idpLocale() { return readIdpLocale(); }, isCreatedInThisRequest: (...a) => this.isCreatedInThisRequest(...a), + enforceConcurrentCap: (...a) => this.enforceConcurrentCap(...a), judgeSessionScoping: (...a) => this.judgeSessionScoping(...a), + judgeSessionSwitch: (...a) => this.judgeSessionSwitch(...a), linkingContext: (...a) => this.linkingContext(...a), resolveUserAuthGates: (...a) => this.resolveUserAuthGates(...a), settleMembership: (...a) => this.settleMembership(...a), @@ -6181,16 +6213,18 @@ export class AuthManager { sessionId: string | undefined, createdAtHint: unknown, organizationId?: string, + door?: { address: string | undefined }, ): Promise { // [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`). const readConfig = () => this.config; - const host: Pick = { + const host: Pick = { get config() { return readConfig(); }, getDataEngine: (...a) => this.getDataEngine(...a), getOrganizationAccessPolicy: (...a) => this.getOrganizationAccessPolicy(...a), + judgeSessionScoping: (...a) => this.judgeSessionScoping(...a), logDurabilityDegradation: (...a) => this.logDurabilityDegradation(...a), }; - return enforceSessionControls.call(host, sessionId, createdAtHint, organizationId); + return enforceSessionControls.call(host, sessionId, createdAtHint, organizationId, door); } /** @@ -6198,7 +6232,7 @@ export class AuthManager { * Keeps the newest `maxConcurrentSessions` live sessions for the user and * revokes the rest (oldest first). No-op when off. Best-effort. */ - private enforceConcurrentCap(userId: string, organizationId?: string): Promise { + private enforceConcurrentCap(userId: string, organizationId?: string, keepSessionId?: string): Promise { // [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`). const readConfig = () => this.config; const host: Pick = { @@ -6207,7 +6241,63 @@ export class AuthManager { getOrganizationAccessPolicy: (...a) => this.getOrganizationAccessPolicy(...a), logDurabilityDegradation: (...a) => this.logDurabilityDegradation(...a), }; - return enforceConcurrentCap.call(host, userId, organizationId); + return enforceConcurrentCap.call(host, userId, organizationId, keepSessionId); + } + + /** + * @internal Package-internal: asked by the session update hook when a + * session switches its active organization; not a supported entry point. + * + * [ADR-0069 TL;DR 6, D4] May session `sessionId` enter `organizationId` + * under that organization's idle and absolute limits (the shorter of the + * deployment's and the organization's)? Judged from the session row as it + * stands, BEFORE the switch: the switch request runs no heartbeat, so + * `last_activity_at` is the session's last request in whatever organization + * it was scoped to — idle is the session's own inactivity, and activity in + * another organization is activity. A session past either limit is ended in + * place (the D4 revocation: `expires_at` into the past, `revoked_at`, + * `revoke_reason`), and the answer is `'ended'`; the hook then refuses the + * switch. `'admitted'` otherwise, and on a lookup that fails (the next request + * in the organization judges the same limits again, `enforceSessionControls`). + */ + public async judgeSessionSwitch(sessionId: string, organizationId: string): Promise<'admitted' | 'ended'> { + const limits = effectiveSessionLimits(this.config, await this.getOrganizationAccessPolicy(organizationId)); + if (limits.idleMinutes <= 0 && limits.absoluteHours <= 0) return 'admitted'; + const engine = this.getDataEngine(); + if (!engine) return 'admitted'; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + let row: any; + try { + row = await engine.findOne('sys_session', { + where: { id: sessionId }, + fields: ['id', 'created_at', 'last_activity_at', 'revoked_at'], + context: SYSTEM_CTX, + } as any); + } catch { + return 'admitted'; + } + if (!row?.id || row.revoked_at) return 'admitted'; + const now = Date.now(); + let reason: string | undefined; + if (limits.absoluteHours > 0 && row.created_at + && now - new Date(row.created_at).getTime() > limits.absoluteHours * 3_600_000) reason = 'absolute_max'; + const last = row.last_activity_at ?? row.created_at; + if (!reason && limits.idleMinutes > 0 && last + && now - new Date(last).getTime() > limits.idleMinutes * 60_000) reason = 'idle_timeout'; + if (!reason) return 'admitted'; + await (engine as any).update( + 'sys_session', + { id: sessionId, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: reason }, + { context: SYSTEM_CTX }, + ).catch((e: unknown) => this.logDurabilityDegradation( + 'AuthManager: a session that switched into an organization past that organization\'s session limit was NOT ' + + 'revoked — the revocation write was refused. The switch itself is refused, so the session does not enter ' + + 'the organization, but it stays live where it was. Remedy: make the sys_session update land; the limits ' + + 'are judged again on the next switch and on every request in that organization.', + e, + { object: 'sys_session', sessionId, reason, organizationId }, + )); + return 'ended'; } /** @@ -6309,6 +6399,18 @@ export class AuthManager { }); } + /** + * @internal Package-internal: asked by the `access_policy.allowed_ip_ranges` + * save check; not a supported entry point. + * + * Is `userId` a tenant-level administrator (ADR-0090 D12 kind 1) — the one + * an organization's IP list never holds? The same predicate the session + * scoping asks (`judgeSessionScoping`). + */ + public isTenantLevelAdministrator(userId: string): Promise { + return this.isPlatformAdminUserId(userId); + } + /** * @internal Package-internal: read by the "any organization requires MFA" * cache refresh; not a supported entry point. diff --git a/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts b/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts index 4b28b924866..4dcdb7bcb34 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy-binding.ts @@ -248,12 +248,23 @@ export async function bindOrganizationAccessPolicy(deps: OrganizationAccessPolic if (typeof settings.registerWritePredicate === 'function') { const floor = () => manager.getDeploymentAccessFloor(); - const selfLockOut = allowedIpRangesWritePredicate(() => manager, { recovery: false }); + // The saving request's own address must be admitted — unless the saver is + // a tenant-level administrator, whom an organization's list never holds + // (break-glass): refusing their save would guard against a lock-out that + // cannot happen to them. + const addressCheck = allowedIpRangesWritePredicate(() => manager, { recovery: false }); + const selfLockOut = async (input: unknown) => { + const refusal = addressCheck(input); + if (!refusal || !('clientAddress' in refusal.constraint)) return refusal; + const userId = (input as { ctx?: { userId?: unknown } } | undefined)?.ctx?.userId; + if (typeof userId === 'string' && userId && (await manager.isTenantLevelAdministrator(userId))) return undefined; + return refusal; + }; const enrollment = organizationMfaEnrollmentPredicate(() => manager.isTwoFactorEnabled()); for (const key of Object.values(ACCESS_POLICY_KEYS)) { const base = accessPolicyWritePredicate(floor, key === ACCESS_POLICY_KEYS.allowedIpRanges ? selfLockOut : undefined); const predicate = - key === ACCESS_POLICY_KEYS.mfaRequired ? (input: unknown) => base(input) ?? enrollment(input) : base; + key === ACCESS_POLICY_KEYS.mfaRequired ? async (input: unknown) => (await base(input)) ?? enrollment(input) : base; try { settings.registerWritePredicate( ACCESS_POLICY_NAMESPACE, diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts index 999975c310d..db90d919171 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts @@ -92,18 +92,18 @@ describe('the floor, per key', () => { expect(refusal?.message).not.toMatch(/deployment/); }); - it('the save check refuses an organization list that leaves out the saving address', () => { + it('the save check refuses an organization list that leaves out the saving address', async () => { const manager = { resolveClientAddress: (t: { remoteAddress?: string }) => t.remoteAddress }; const predicate = accessPolicyWritePredicate( () => ({ allowedIpRanges: [] }), allowedIpRangesWritePredicate(() => manager, { recovery: false }), ); - const refusal = predicate({ key: 'allowed_ip_ranges', next: '10.1.0.0/16', transport: { remoteAddress: '192.0.2.7' } }); + const refusal = await predicate({ key: 'allowed_ip_ranges', next: '10.1.0.0/16', transport: { remoteAddress: '192.0.2.7' } }); expect(refusal?.constraint).toEqual({ clientAddress: '192.0.2.7' }); // The env-override way back reopens only the deployment's list, so an // organization's refusal does not name it. expect(refusal?.message).not.toContain('OS_AUTH_ALLOWED_IP_RANGES'); - expect(predicate({ key: 'allowed_ip_ranges', next: '10.1.0.0/16', transport: { remoteAddress: '10.1.2.3' } })) + expect(await predicate({ key: 'allowed_ip_ranges', next: '10.1.0.0/16', transport: { remoteAddress: '10.1.2.3' } })) .toBeUndefined(); }); }); @@ -306,4 +306,127 @@ describe('two organizations, each policy enforced on its own members', () => { expect(await alive(alice, IN_A)).toBe(false); expect(await alive(bob, IN_B)).toBe(true); }); + + it('a session the organization\'s list no longer admits is ended on its next auth-door read (roaming)', async () => { + const { manager } = await twoOrganizationDeployment(); + const alice = cookieFrom(await signIn(manager, 'alice@alpha.test', IN_A)); + const read = (address: string) => + manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/get-session', { headers: { cookie: alice } }), + { remoteAddress: address }, + ); + const alive = async (address: string) => { + const res = await read(address); + if (res.status !== 200) return false; + return Boolean(((await res.json().catch(() => null)) as any)?.user?.id); + }; + expect(await alive(IN_A)).toBe(true); + // From outside Alpha's ranges: the detecting read ends the session in place… + await read(IN_B); + // …and it stays ended, even back inside the ranges. + expect(await alive(IN_A)).toBe(false); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The concurrent cap and the active-organization switch +// ─────────────────────────────────────────────────────────────────────────── + +describe('the concurrent cap: the deployment counts every session, an organization its own', () => { + const MIN = 60_000; + async function withSessions(config: Record, policies: Record>) { + const engine = createMemoryEngine(); + const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...config } as any); + manager.setOrganizationAccessPolicySource({ read: async (id) => policies[id] ?? {}, anyOrganizationRequiresMfa: async () => false }); + const now = Date.now(); + const add = (id: string, org: string, minutesAgo: number) => + engine.insert('sys_session', { + id, user_id: 'u1', active_organization_id: org, + created_at: new Date(now - minutesAgo * MIN), last_activity_at: new Date(now - minutesAgo * MIN), + expires_at: new Date(now + 60 * MIN), + }); + return { engine, manager, add }; + } + const revoked = (engine: any) => + ((engine.tables.get('sys_session') ?? []) as any[]).filter((r) => r.revoked_at).map((r) => r.id).sort(); + + it('an organization cap ends only that organization\'s oldest sessions, never another organization\'s', async () => { + const { engine, manager, add } = await withSessions({}, { [ORG_A]: { max_concurrent_sessions_per_user: 1 } }); + await add('b_old', ORG_B, 50); + await add('a_old', ORG_A, 40); + await add('a_new', ORG_A, 1); + await (manager as any).enforceConcurrentCap('u1', ORG_A, 'a_new'); + expect(revoked(engine)).toEqual(['a_old']); + }); + + it('a switch into a strict organization is swept with the switching session kept', async () => { + const { engine, manager, add } = await withSessions({}, { [ORG_A]: { max_concurrent_sessions_per_user: 1 } }); + await add('a_newer', ORG_A, 5); + await add('b_switching', ORG_B, 30); // older, and switching into Alpha + await (manager as any).enforceConcurrentCap('u1', ORG_A, 'b_switching'); + expect(revoked(engine)).toEqual(['a_newer']); + }); + + it('the deployment cap still counts every session, whatever organization', async () => { + const { engine, manager, add } = await withSessions({ maxConcurrentSessions: 2 }, {}); + await add('a1', ORG_A, 30); + await add('b1', ORG_B, 20); + await add('a2', ORG_A, 1); + await (manager as any).enforceConcurrentCap('u1', ORG_A, 'a2'); + expect(revoked(engine)).toEqual(['a1']); + }); +}); + +describe('a switch of the active organization is judged against the target\'s limits', () => { + const MIN = 60_000; + async function deployment(policies: Record>) { + const engine = createMemoryEngine(); + const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine } as any); + manager.setOrganizationAccessPolicySource({ read: async (id) => policies[id] ?? {}, anyOrganizationRequiresMfa: async () => false }); + await engine.insert('sys_session', { + id: 's1', user_id: 'u1', active_organization_id: ORG_B, + created_at: new Date(Date.now() - 60 * MIN), last_activity_at: new Date(Date.now() - 30 * MIN), + expires_at: new Date(Date.now() + 60 * MIN), + }); + return { engine, manager }; + } + const row = (engine: any) => ((engine.tables.get('sys_session') ?? []) as any[]).find((r) => r.id === 's1'); + + it('a session idle past the target\'s idle limit is ended and the switch refused; last activity is not refreshed first', async () => { + const { engine, manager } = await deployment({ [ORG_A]: { session_idle_timeout_minutes: 10 } }); + const lastBefore = row(engine).last_activity_at; + const hooks: any = (manager as any).composeDatabaseHooks(); + const ctx = { context: { session: { session: { id: 's1', userId: 'u1', activeOrganizationId: ORG_B }, user: { id: 'u1' } } } }; + const refused = await hooks.session.update.before({ activeOrganizationId: ORG_A }, ctx).then(() => undefined, (e: any) => e); + expect(refused?.body?.code ?? refused?.code).toBe('UNAUTHENTICATED'); + expect(row(engine).revoke_reason).toBe('idle_timeout'); + expect(row(engine).last_activity_at).toEqual(lastBefore); + }); + + it('the absolute limit is judged too, and a target with no limits admits the switch', async () => { + const { engine, manager } = await deployment({ [ORG_A]: { session_absolute_max_hours: 0 }, [ORG_B]: {} }); + expect(await manager.judgeSessionSwitch('s1', ORG_A)).toBe('admitted'); + expect(row(engine).revoked_at).toBeUndefined(); + const strict = await deployment({ [ORG_A]: { session_absolute_max_hours: 1 } }); + // 60 minutes old against a one-hour limit: not over yet. + expect(await strict.manager.judgeSessionSwitch('s1', ORG_A)).toBe('admitted'); + row(strict.engine).created_at = new Date(Date.now() - 61 * MIN); + expect(await strict.manager.judgeSessionSwitch('s1', ORG_A)).toBe('ended'); + expect(row(strict.engine).revoke_reason).toBe('absolute_max'); + }); +}); + +describe('the retired mfaRequired option is refused, never dropped', () => { + it('at construction, naming the replacement', () => { + expect(() => new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', mfaRequired: true } as any)) + .toThrow(/mfaRequired option was retired.*mfaRequirement: 'everyone'/); + expect(() => new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', mfaRequirement: 'all' } as any)) + .toThrow(/invalid mfaRequirement "all"/); + }); + + it('on a config patch', () => { + const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', mfaRequirement: 'administrators' } as any); + expect(() => manager.applyConfigPatch({ mfaRequired: false } as any)).toThrow(/mfaRequired option was retired/); + expect(manager.getDeploymentAccessFloor().mfaRequirement).toBe('administrators'); + }); }); diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.ts b/packages/plugins/plugin-auth/src/organization-access-policy.ts index 2cf4db2c5fe..68032f59bba 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy.ts @@ -32,7 +32,8 @@ * | key | organization may save | enforced | * |---|---|---| * | `allowed_ip_ranges` | an empty list; or ranges each inside a deployment range (any range when the deployment sets none), one of which admits the saving request's own address | an address must pass the deployment's list AND the organization's | - * | `session_idle_timeout_minutes`, `session_absolute_max_hours`, `max_concurrent_sessions_per_user` | `0`; or a positive value no larger than the deployment's (any positive value when the deployment's is `0`) | the smaller positive value of the two; `0` on both is no limit | + * | `session_idle_timeout_minutes`, `session_absolute_max_hours` | `0`; or a positive value no larger than the deployment's (any positive value when the deployment's is `0`) | the smaller positive value of the two; `0` on both is no limit | + * | `max_concurrent_sessions_per_user` | as the two limits above | two caps, each over its own population (below) | * | `mfa_required` | `off`; or a level at least as strong as the deployment's | the stronger level of the two (`off` < `administrators` < `everyone`) | * * ## Which organization's policy applies @@ -48,6 +49,45 @@ * is held to that organization's limits and multi-factor level. Switching the * active organization re-scopes the session and is judged the same way. * + * ## The concurrent cap — two caps, two populations + * + * The deployment's cap counts EVERY live session of the user, whatever + * organization each is scoped to, and ends the oldest past it — its meaning + * before organizations had caps, so the floor never loosens. An + * organization's own cap counts only the user's sessions scoped to that + * organization, and ends only those (oldest first): a sign-in to a strict + * organization never evicts a session another organization holds. Both are + * swept when a session is scoped to the organization — at the sign-in that + * mints it, and at a switch of the active organization, where the switching + * session counts as scoped to the target and is kept — so a strict + * organization's cap cannot be exceeded by opening sessions elsewhere and + * switching them in. + * + * ## A switch of the active organization + * + * Judged against the TARGET organization before the switch lands: its IP + * list, its idle and absolute limits (from the session row as it stands — the + * switch runs no heartbeat, so nothing refreshes `last_activity_at` ahead of + * the judgement; idle is the session's own inactivity, so activity in another + * organization is activity), and its concurrent cap. A session past either + * limit is ended in place (the ADR-0069 D4 revocation) and the switch refused. + * + * ## A roaming session + * + * The organization's IP list is judged again on every request that comes + * through the HTTP auth door (the session read, `customSession`), with the + * address that door established: a session the list no longer admits — it + * roamed, or the list was tightened — is ended in place like one past its idle + * limit. Data requests are judged by neither list, as before. + * + * ## Staleness + * + * An organization's policy is read per request through a 30-second cache + * (`ORG_ACCESS_POLICY_TTL_MS`), dropped on this process's `access_policy` + * change event; another node applies a changed policy up to 30 seconds late + * (the "any organization requires MFA" flag, up to 60), the same window the + * deployment's `auth` keys already have. + * * ## Break-glass * * An organization's network list never holds a tenant-level administrator @@ -58,7 +98,8 @@ * last-administrator guard (cloud ADR-0024 D5.2). The save check * ({@link accessPolicyFloorRefusal}) refuses a list that leaves out the saving * request's own address, so the administrator who saves is never the one it - * locks out. + * locks out — and does not refuse a tenant-level administrator's save, whom + * the list never holds. */ import { resolveLocale } from '@objectstack/core'; @@ -429,9 +470,12 @@ interface AccessPolicyWriteInput { */ export function accessPolicyWritePredicate( floor: () => DeploymentAccessFloor | undefined, - selfLockOut?: (input: unknown) => { message: string; constraint: object } | undefined, -): (input: unknown) => AccessPolicyRefusal | undefined { - return (input) => { + selfLockOut?: (input: unknown) => + | { message: string; constraint: object } + | undefined + | Promise<{ message: string; constraint: object } | undefined>, +): (input: unknown) => Promise { + return async (input) => { const { key, next, locale } = (input ?? {}) as AccessPolicyWriteInput; const current = floor(); if (current) { @@ -439,7 +483,7 @@ export function accessPolicyWritePredicate( if (refusal) return refusal; } if (key === ACCESS_POLICY_KEYS.allowedIpRanges && selfLockOut) { - const refusal = selfLockOut(input); + const refusal = await selfLockOut(input); return refusal ? { message: refusal.message, constraint: { ...refusal.constraint } } : undefined; } return undefined; diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts index 161cee094f5..55d65e694a9 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts @@ -115,6 +115,21 @@ describe('an organization cannot save below the deployment floor', () => { expect((await settings.get('access_policy', 'session_idle_timeout_minutes', { tenantId: ORG })).value).toBe(10); }); + it('the self-lock-out check refuses a list leaving out the saver\'s address, but not a tenant-level administrator\'s save', async () => { + const { settings, manager } = await bound({ plugins: { twoFactor: true } }); + const outside = { transport: { remoteAddress: '192.0.2.7' } }; + const admin = vi.spyOn(manager, 'isTenantLevelAdministrator').mockResolvedValue(false); + const refused = await refusalOf( + settings.setMany('access_policy', { allowed_ip_ranges: '10.1.0.0/16' }, { tenantId: ORG, userId: 'org_admin' }, outside), + ); + expect(refused.fields?.[0]).toMatchObject({ field: 'allowed_ip_ranges', constraint: { clientAddress: '192.0.2.7' } }); + // The organization's list never holds a tenant-level administrator, so it + // cannot lock them out: their save on the organization's behalf passes. + admin.mockResolvedValue(true); + await settings.setMany('access_policy', { allowed_ip_ranges: '10.1.0.0/16' }, { tenantId: ORG, userId: 'platform_admin' }, outside); + expect((await settings.get('access_policy', 'allowed_ip_ranges', { tenantId: ORG })).value).toBe('10.1.0.0/16'); + }); + it('the manager reads each organization\'s saved policy, and drops its cache on a change', async () => { const { settings, manager } = await bound({ plugins: { twoFactor: true } }); await settings.setMany('access_policy', { session_idle_timeout_minutes: 10 }, { tenantId: ORG }); diff --git a/packages/services/service-settings/src/manifests/access-policy.manifest.ts b/packages/services/service-settings/src/manifests/access-policy.manifest.ts index ea6b7e4bae4..e2138b1f435 100644 --- a/packages/services/service-settings/src/manifests/access-policy.manifest.ts +++ b/packages/services/service-settings/src/manifests/access-policy.manifest.ts @@ -44,7 +44,7 @@ const manifest = { label: 'Allowed IP ranges', required: false, description: - "CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, a member's session is not opened for this organization from outside these ranges, and so is any request whose client address the server cannot establish. Each range must lie inside the deployment's allowed IP ranges, when it sets any, and the list must include the address you save it from. Empty = only the deployment's ranges apply. Platform administrators are held to the deployment's ranges only.", + "CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, a member's session is not opened for this organization from outside these ranges, and a session that comes from outside them later is signed out; so is any request whose client address the server cannot establish. Each range must lie inside the deployment's allowed IP ranges, when it sets any, and the list must include the address you save it from. Empty = only the deployment's ranges apply. Platform administrators are held to the deployment's ranges only.", }, { type: 'group', @@ -84,7 +84,7 @@ const manifest = { min: 0, max: 100, description: - "Cap a member's simultaneous signed-in sessions; the oldest are signed out past the cap. No higher than the deployment's cap when it sets one. 0 = the deployment's cap.", + "Cap a member's simultaneous signed-in sessions in this organization; the oldest of them are signed out past the cap, and sessions in other organizations are not counted or signed out. Checked at sign-in and when a member switches into this organization. No higher than the deployment's cap when it sets one, which still counts all of a member's sessions. 0 = the deployment's cap only.", }, { type: 'group', From 88cad54c781611ffed3e352fa42c1dee7d1e935e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:46:51 +0000 Subject: [PATCH 12/17] chore(changeset): disclose the refused toggle write, the cap populations and the switch judgement Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .changeset/30922-per-organization-access-policy.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/30922-per-organization-access-policy.md b/.changeset/30922-per-organization-access-policy.md index 5ed0fb79f04..e2063eb86a0 100644 --- a/.changeset/30922-per-organization-access-policy.md +++ b/.changeset/30922-per-organization-access-policy.md @@ -15,14 +15,15 @@ Metadata-protocol: none The rule per key — a value weaker than the deployment's is refused at the save, and enforcement holds the floor whatever is stored: -- `allowed_ip_ranges`: each range must lie inside a deployment range (any range when the deployment sets none), and the list must admit the address it is saved from. A request must pass both lists. A session is scoped to an organization only from an address its list admits: the sign-in is refused with `403 IP_NOT_ALLOWED` when the organization the session would be scoped to does not admit the address, and so is a switch of the active organization. Sign-in itself, before any organization is chosen, is judged by the deployment's list alone. A platform administrator is held by the deployment's list only, so an organization that locks out its own administrators can be reopened. -- `session_idle_timeout_minutes`, `session_absolute_max_hours`, `max_concurrent_sessions_per_user`: `0` keeps the deployment's limit; a positive value may not exceed the deployment's. The shorter of the two applies to every session scoped to the organization. +- `allowed_ip_ranges`: each range must lie inside a deployment range (any range when the deployment sets none), and the list must admit the address it is saved from. A request must pass both lists. A session is scoped to an organization only from an address its list admits: the sign-in is refused with `403 IP_NOT_ALLOWED` when the organization the session would be scoped to does not admit the address, and so is a switch of the active organization. A session the organization's list no longer admits (it roamed, or the list was tightened) is signed out on its next session read through the auth routes. Sign-in itself, before any organization is chosen, is judged by the deployment's list alone. A platform administrator is held by the deployment's list only, so an organization that locks out its own administrators can be reopened, and the save check does not refuse a platform administrator's save from outside an organization's list. +- `session_idle_timeout_minutes`, `session_absolute_max_hours`: `0` keeps the deployment's limit; a positive value may not exceed the deployment's. The shorter of the two applies to every session scoped to the organization. A switch of the active organization is judged against the target organization's limits before it lands, from the session's last request in any organization; a session past either is signed out and the switch refused. +- `max_concurrent_sessions_per_user`: `0` keeps the deployment's cap; a positive value may not exceed the deployment's. The deployment's cap still counts every session of the user, in any organization; an organization's cap counts only the user's sessions in that organization and signs out only those, oldest first. Both are checked at sign-in and when a session switches into the organization. - `mfa_required`: `off` keeps the deployment's level; a level may not be weaker than the deployment's. The stronger of the two applies. `administrators` holds platform administrators, the organization's own administrators (owner or admin membership) and delegated administrators (a permission set with an `adminScope`), and lets everyone else through. What to do on upgrade: -- `auth.mfa_required` saved as on now reads `everyone`, and as off `off`. A stored toggle is rewritten at the first boot; the `OS_AUTH_MFA_REQUIRED` env override still reads `true`/`false` with a warning for one major release — set it to `off`, `administrators` or `everyone`. +- `auth.mfa_required` saved as on now reads `everyone`, and as off `off`. A stored toggle is rewritten at the first boot; the `OS_AUTH_MFA_REQUIRED` env override still reads `true`/`false` with a warning for one major release — set it to `off`, `administrators` or `everyone`. A **write** of `true` or `false` to `auth.mfa_required` is now refused by the setting's option table (`invalid_option`): write `everyone` or `off`. Consumer: objectstack-ai/cloud's `apps/cloud/test/settings-cross-org-doors.test.ts` (`:533` writes `true`, `:552` expects it back) must be rewritten to `everyone` when the framework pin moves past this release. - An organization whose `sys_organization.require_mfa` was on now carries `access_policy.mfa_required` set to `everyone`. Moved at the first boot (unless the organization already chose a level), and the column is cleared once the row lands; a move that fails is logged at `error` and retried at the next boot. The column is now readonly: a data-API write to it is refused like any other non-editable column. -- A host that passed the auth manager or plugin option `mfaRequired: true` passes `mfaRequirement: 'everyone'` (or `'administrators'`) instead. +- A host that passed the auth manager or plugin option `mfaRequired: true` passes `mfaRequirement: 'everyone'` (or `'administrators'`) instead. A host still passing `mfaRequired` is refused at construction (and on `applyConfigPatch`) with an error naming the replacement, never run without the requirement. What does not change: with no organization policy saved, every deployment enforces exactly what it enforced before, and the deployment's own keys keep their names. From c25387c6d8e73724ac1ff946d6fec0e8dcfd0ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:47:25 +0000 Subject: [PATCH 13/17] i18n(platform-objects): zh-Hant copy for the retired require_mfa column Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../src/apps/translations/zh-Hant.objects.generated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/zh-Hant.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-Hant.objects.generated.ts index 5f560637aaf..1578589f575 100644 --- a/packages/platform-objects/src/apps/translations/zh-Hant.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-Hant.objects.generated.ts @@ -556,8 +556,8 @@ export const zhHantObjects: NonNullable = { help: "JSON 序列化的組織中繼資料" }, require_mfa: { - label: "強制多因素驗證", - help: "開啟後,該組織的每位成員都必須先繫結身分驗證器 App,才能存取資料。需要本部署開啟雙重驗證(OS_AUTH_TWO_FACTOR=true);雙重驗證關閉時,儲存為開啟會被拒絕。" + label: "強制多因素驗證(已停用)", + help: "已停用。請在「設定 → 存取原則」中設定本組織的多因素驗證要求。此處原為開啟的值已遷移為「所有人」並清除。" }, timezone: { label: "時區", From 27d4828b2e7dca46197899f5a73d829219b2c77f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:57:40 +0000 Subject: [PATCH 14/17] fix(plugin-auth): the idle/absolute revocation keeps its own reason Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../plugin-auth/src/auth-manager-password-session-policy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts index 2e07d7b9890..249a06f1f8b 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts @@ -675,7 +675,7 @@ export async function enforceSessionControls( // that IS over its limit fully live. await engine.update( 'sys_session', - { id: sessionId, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: 'concurrent_cap' }, + { id: sessionId, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: reason }, { context: SYSTEM_CTX } as any, ).catch((e: unknown) => this.logDurabilityDegradation( 'AuthManager: a session that exceeded its configured limit was NOT revoked — the ' @@ -786,7 +786,7 @@ export async function enforceConcurrentCap( // abandon the rest of the sweep — but it reports instead of swallowing. await engine.update( 'sys_session', - { id: sn.id, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: reason }, + { id: sn.id, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: 'concurrent_cap' }, { context: SYSTEM_CTX } as any, ).catch((e: unknown) => this.logDurabilityDegradation( 'AuthManager: a session over the concurrent-session cap was NOT revoked — the sign-in ' From 2e1b12c26984e3e46d6f15f4370206d7e05f64a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:58:26 +0000 Subject: [PATCH 15/17] test(plugin-auth): the switch hook sweeps the target organization's cap Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../plugin-auth/src/organization-access-policy.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts index db90d919171..cdb8ed78e1c 100644 --- a/packages/plugins/plugin-auth/src/organization-access-policy.test.ts +++ b/packages/plugins/plugin-auth/src/organization-access-policy.test.ts @@ -367,6 +367,16 @@ describe('the concurrent cap: the deployment counts every session, an organizati expect(revoked(engine)).toEqual(['a_newer']); }); + it('the session hook sweeps the target organization\'s cap when a session switches in', async () => { + const { engine, manager, add } = await withSessions({}, { [ORG_A]: { max_concurrent_sessions_per_user: 1 } }); + await add('a_newer', ORG_A, 5); + await add('b_switching', ORG_B, 30); + const hooks: any = (manager as any).composeDatabaseHooks(); + const ctx = { context: { session: { session: { id: 'b_switching', userId: 'u1', activeOrganizationId: ORG_B }, user: { id: 'u1' } } } }; + await hooks.session.update.before({ activeOrganizationId: ORG_A }, ctx); + expect(revoked(engine)).toEqual(['a_newer']); + }); + it('the deployment cap still counts every session, whatever organization', async () => { const { engine, manager, add } = await withSessions({ maxConcurrentSessions: 2 }, {}); await add('a1', ORG_A, 30); From 6b87c7c31c60fbd91ecc7445385376121564114d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 19:17:33 +0000 Subject: [PATCH 16/17] docs(census): the tenant audit census counts the switch revocation's write Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 36 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 21 +++++------ 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index ff22ac4cded..1f20150bdf9 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **55 of the 261 sites are spelled that way**. A +forwarding shim cannot, and **55 of the 262 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **261** | +| 175 write call sites | quoted in the merged changeset | **262** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **184 of 261** decidable, **77** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 144 decidably elevated, 0 decidably not, 109 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **185 of 262** decidable, **77** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 145 decidably elevated, 0 decidably not, 109 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -217,11 +217,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -144 of 261 (55%) as decidably elevated, with 109 more whose elevation is a +145 of 262 (55%) as decidably elevated, with 109 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 261`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 262`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,28 +233,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **261** | -| …whose object name is statically decidable | 184 | +| write call sites on the application surface | **262** | +| …whose object name is statically decidable | 185 | | …whose object name is chosen at run time | 77 | -| …against an object with tenancy ENABLED | 178 | +| …against an object with tenancy ENABLED | 179 | | …against an object that declares tenancy off | 6 | -| threading a tenant context | 198 | +| threading a tenant context | 199 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 55 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 144 | +| threading a decidably ELEVATED (`isSystem`) context | 145 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 109 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 199 | +| receiver carried a readable engine type | 200 | | receiver erased, placed by the object NAME | 52 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 116 | +| object name spelled inline | 117 | | object name spelled through a `const` | 68 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 70 | @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `2e1b12c26`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1044 | +| engine-shaped types recognised | 110 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 233 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index d05ca1acbbb..51a3af089ff 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 261 | -| Object name statically decidable | 184 | +| Write call sites | 262 | +| Object name statically decidable | 185 | | Object name chosen at run time | 77 | -| Against a tenancy-enabled object | 178 | +| Against a tenancy-enabled object | 179 | | Against an object declaring tenancy off | 6 | -| Threading a tenant context | 198 | +| Threading a tenant context | 199 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 55 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 144 | +| Threading a decidably elevated context | 145 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 109 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `2e1b12c26`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1044 | +| engine-shaped types recognised | 110 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 233 | ## Every site @@ -139,6 +139,7 @@ Measured on 2026-11-05 at `e40e04854`. | `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 | | `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_user` | enabled | options unreadable | 3 | | `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts` | `update` | `sys_user` | enabled | options unreadable | 3 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 | From b0b494c04a92294308656bd183f2764c286e9665 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 19:55:42 +0000 Subject: [PATCH 17/17] fix(plugin-auth): type the switch judgement's engine options Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 600d318f316..b0ebea66807 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -6272,7 +6272,7 @@ export class AuthManager { where: { id: sessionId }, fields: ['id', 'created_at', 'last_activity_at', 'revoked_at'], context: SYSTEM_CTX, - } as any); + }); } catch { return 'admitted'; } @@ -6285,7 +6285,7 @@ export class AuthManager { if (!reason && limits.idleMinutes > 0 && last && now - new Date(last).getTime() > limits.idleMinutes * 60_000) reason = 'idle_timeout'; if (!reason) return 'admitted'; - await (engine as any).update( + await engine.update( 'sys_session', { id: sessionId, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: reason }, { context: SYSTEM_CTX },