From 97a24292dcee62b3a780debda7bb72bb2aa4cdac Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:19:33 +0800 Subject: [PATCH] feat(auth): per-org MFA + dispatcher/MCP gate (ADR-0069 D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes enforced MFA with the two remaining tails. - platform-objects: sys_organization.require_mfa (per-org tightening above the global floor). - plugin-auth: computeAuthGate treats the active org's require_mfa as an effective MFA requirement even when global mfaRequired is off; isAuthGateActive consults a 60s-TTL cached "any org requires MFA" flag (lazy background refresh) so the cheap sync check stays honest without per-request org queries. - runtime: enforceAuthGate runs in the dispatcher after resolveExecutionContext, gating MCP/GraphQL/embedded data paths the same way the REST seam gates the Console — reusing the shared core evaluateAuthGate + allow-list. Default-off / additive; ADR-0049. Verified live (dogfood): with GLOBAL mfa off, an org member whose org has require_mfa=true is gated 403 MFA_REQUIRED while an admin with no org is not (per-org scoping). With global mfa on, POST /api/v1/mcp returns 403 with details.code=MFA_REQUIRED (the dispatcher seam). Unit: plugin-auth 195 + runtime 444 + core 299 + platform-objects 63 + service-settings 129 green; full build incl. strict DTS green. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0069-mfa-tails.md | 14 +++++ .../src/identity/sys-organization.object.ts | 12 ++++ .../plugin-auth/src/auth-manager.test.ts | 61 +++++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 53 +++++++++++++++- packages/runtime/src/http-dispatcher.ts | 51 +++++++++++++++- 5 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 .changeset/adr-0069-mfa-tails.md diff --git a/.changeset/adr-0069-mfa-tails.md b/.changeset/adr-0069-mfa-tails.md new file mode 100644 index 0000000000..9215c60491 --- /dev/null +++ b/.changeset/adr-0069-mfa-tails.md @@ -0,0 +1,14 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/plugin-auth': minor +'@objectstack/runtime': minor +--- + +Auth: per-org MFA + dispatcher/MCP gate — complete the ADR-0069 enforced-MFA story + +Two follow-ups that make enforced MFA total: + +- **Per-org `sys_organization.require_mfa`** — an org may require MFA above the global floor. `computeAuthGate` now treats the active org's `require_mfa` as an effective MFA requirement even when the global `mfa_required` is off; `isAuthGateActive()` stays cheap via a 60s-TTL "any org requires MFA" cache (lazy background refresh), so a brand-new per-org requirement activates the gate on the next request without per-request org queries. +- **Dispatcher/MCP gate** — the auth-policy gate now also runs in the runtime dispatcher (after `resolveExecutionContext`), so MCP / GraphQL / embedded data paths enforce `PASSWORD_EXPIRED` / `MFA_REQUIRED` consistently with the REST seam (reusing the shared `evaluateAuthGate` allow-list). Previously only the REST surface (the Console) was gated. + +Default-off / additive. Per ADR-0049 each setting ships with its enforcement. diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index f8c4d127ba..edb31b2294 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -202,6 +202,18 @@ 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. + require_mfa: Field.boolean({ + label: 'Require Multi-Factor Auth', + required: false, + defaultValue: false, + group: 'Configuration', + description: 'When true, every member of this organization must enroll an authenticator app to access data.', + }), + // ── System ─────────────────────────────────────────────────── id: Field.text({ label: 'Organization ID', diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 99c00a12f2..bf8a65976c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1975,4 +1975,65 @@ describe('AuthManager', () => { expect(g?.code).toBe('MFA_REQUIRED'); }); }); + + // 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). + describe('per-org enforced MFA (ADR-0069 D3)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + const makeEngine = (user: any, org: any) => ({ + findOne: vi.fn(async (obj: string, q: any) => { + const row = obj === 'sys_organization' ? org : user; + if (!row) return null; + const w = q?.where ?? {}; + return Object.entries(w).every(([k, v]) => (row as any)[k] === v) ? row : null; + }), + update: vi.fn(async () => ({})), + count: vi.fn(async () => 1), + }); + const mgr = (engine: any, extra: any = {}) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra }); + warn.mockRestore(); + return m; + }; + + it('isAuthGateActive trips on the cached org-MFA flag even with global MFA off', () => { + const m = mgr(makeEngine(null, null), {}); + // Prime the cache as if an org requires MFA. + (m as any)._orgMfaCache = { value: true, at: Date.now() }; + expect(m.isAuthGateActive()).toBe(true); + }); + + it('blocks MFA_REQUIRED when the ACTIVE ORG requires MFA (global off) and grace elapsed', async () => { + 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 }, + ); + const m = mgr(engine, { mfaRequired: false, 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'); + }); + + it('does NOT block when the active org does not require MFA (global off)', async () => { + 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 }, + ); + const m = mgr(engine, { mfaRequired: false }); + (m as any)._orgMfaCache = { value: true, at: Date.now() }; + await expect((m as any).computeAuthGate('u1', 'org1', false)).resolves.toBeUndefined(); + }); + + 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 }); + // 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(); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index addb0507b0..2d2a3329fa 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -346,6 +346,10 @@ export interface AuthManagerOptions extends Partial { export class AuthManager { private auth: Auth | null = null; private config: AuthManagerOptions; + // ADR-0069 — cached "does any org require MFA" flag (per-org tightening). + // Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap. + private _orgMfaCache: { value: boolean; at: number } = { value: false, at: 0 }; + private _orgMfaRefreshing = false; /** * Result of the dev-only admin seed (set by `AuthPlugin.maybeSeedDevAdmin` @@ -2289,12 +2293,44 @@ export class AuthManager { * default), keeping the gate zero-overhead until an admin opts in. */ public isAuthGateActive(): boolean { + // Per-org MFA (no global flag) can still activate the gate — keep the cheap + // sync check honest by consulting a lazily-refreshed cache. + this.refreshOrgMfaCacheIfStale(); return ( Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || - this.config.mfaRequired === true + this.config.mfaRequired === true || + this._orgMfaCache.value ); } + /** + * ADR-0069 — refresh the "any org requires MFA" cache in the background when + * stale (60s TTL). Fire-and-forget: a brand-new per-org requirement activates + * the gate on the next request, never blocking this one. No-op when global MFA + * is already on (the gate is active regardless). + */ + private refreshOrgMfaCacheIfStale(): void { + if (this.config.mfaRequired === true) 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, roles: [], permissions: [] }; + const n = await engine.count('sys_organization', { + where: { require_mfa: true }, context: SYSTEM_CTX, + } as any); + this._orgMfaCache = { value: typeof n === 'number' && n > 0, at: Date.now() }; + } catch { + // leave the prior value; try again after the TTL + } finally { + this._orgMfaRefreshing = false; + } + })(); + } + /** * ADR-0069 — compute the auth-policy gate posture for a session. Returns an * `{ code, message }` when the user is currently blocked (e.g. password @@ -2308,8 +2344,10 @@ export class AuthManager { _twoFactorEnabledHint: boolean, ): Promise<{ code: string; message: string } | undefined> { const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0); - const mfaRequired = this.config.mfaRequired === true; - if (expiryDays <= 0 && !mfaRequired) return undefined; // no gate feature active + 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; + if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires) return undefined; // no gate feature active const engine = this.getDataEngine(); if (!engine || !userId) return undefined; try { @@ -2320,6 +2358,15 @@ export class AuthManager { context: SYSTEM_CTX, } as any); + // 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, + } as any); + mfaRequired = org?.require_mfa === true || org?.require_mfa === 1; + } + // ── Password expiry ─────────────────────────────────────────────── if (expiryDays > 0) { const changed = u?.password_changed_at; diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 116e10a9f8..25ccaf0857 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core'; +import { ObjectKernel, getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -1034,6 +1034,45 @@ export class HttpDispatcher { * response object that callers should surface directly — no further * dispatch happens. */ + /** + * ADR-0069 — returns a 403 response when the resolved session is blocked by + * an auth-policy gate (expired password / required MFA) on a non-allow-listed + * path, else null. Mirrors the REST `enforceAuth` seam so REST + dispatcher + * (MCP, GraphQL) enforce consistently. Fails open on any lookup error. + */ + private async enforceAuthGate(context: any, cleanPath: string): Promise { + try { + if (isAuthGateAllowlisted(cleanPath)) return null; + const authService: any = await this.resolveService('auth', context.environmentId); + if (!authService || typeof authService.isAuthGateActive !== 'function' || !authService.isAuthGateActive()) { + return null; + } + let api: any = authService.api; + if (!api && typeof authService.getApi === 'function') api = await authService.getApi(); + if (!api?.getSession) return null; + // Normalize headers to a Web Headers instance for getSession. + const raw: any = context?.request?.headers; + let headers: any; + if (raw && typeof raw.get === 'function') { + headers = raw; + } else if (raw && typeof raw === 'object') { + headers = new (globalThis as any).Headers(); + for (const k of Object.keys(raw)) { + const v = raw[k]; + if (v != null) headers.set(String(k), Array.isArray(v) ? v.join(',') : String(v)); + } + } else { + return null; + } + const session: any = await api.getSession({ headers }).catch(() => undefined); + const gate = evaluateAuthGate(session?.user, cleanPath); + if (!gate) return null; + return this.error(gate.message, 403, { code: gate.code }); + } catch { + return null; // fail-open — never break dispatch on a gate hiccup + } + } + private async enforceProjectMembership( context: HttpProtocolContext, path: string, @@ -3544,6 +3583,16 @@ export class HttpDispatcher { // anonymous request — leave executionContext undefined } + // ── ADR-0069 Authentication-policy gate ── + // Block a gated session (expired password / required MFA) from + // protected MCP/GraphQL/data routes, mirroring the REST seam. The core + // allow-list keeps auth + remediation reachable. Skipped (no session + // lookup) when no gate feature is active. + const authGated = await this.enforceAuthGate(context, cleanPath); + if (authGated) { + return { handled: true, response: authGated }; + } + // ── Project Membership Enforcement ── // Once the environmentId is known, gate scoped data/meta/AI/automation // routes on `sys_environment_member`. Control-plane paths, the system