Skip to content

Commit e011d42

Browse files
os-zhuangclaude
andauthored
feat(auth): per-org MFA + dispatcher/MCP gate (ADR-0069 D3) (#2395)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 07c2773 commit e011d42

5 files changed

Lines changed: 187 additions & 4 deletions

File tree

.changeset/adr-0069-mfa-tails.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/plugin-auth': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
Auth: per-org MFA + dispatcher/MCP gate — complete the ADR-0069 enforced-MFA story
8+
9+
Two follow-ups that make enforced MFA total:
10+
11+
- **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.
12+
- **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.
13+
14+
Default-off / additive. Per ADR-0049 each setting ships with its enforcement.

packages/platform-objects/src/identity/sys-organization.object.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ export const SysOrganization = ObjectSchema.create({
202202
group: 'Configuration',
203203
}),
204204

205+
// ADR-0069 D3 — per-org MFA tightening above the global floor. When true,
206+
// members of this org must enrol TOTP to access data (enforced at the
207+
// session-validation gate). An org can only tighten, never loosen, the
208+
// global `mfa_required` setting.
209+
require_mfa: Field.boolean({
210+
label: 'Require Multi-Factor Auth',
211+
required: false,
212+
defaultValue: false,
213+
group: 'Configuration',
214+
description: 'When true, every member of this organization must enroll an authenticator app to access data.',
215+
}),
216+
205217
// ── System ───────────────────────────────────────────────────
206218
id: Field.text({
207219
label: 'Organization ID',

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1975,4 +1975,65 @@ describe('AuthManager', () => {
19751975
expect(g?.code).toBe('MFA_REQUIRED');
19761976
});
19771977
});
1978+
1979+
// ADR-0069 D3 — per-org MFA tightening (an org may require MFA above the
1980+
// global floor). Uses an object-aware engine mock (sys_user + sys_organization).
1981+
describe('per-org enforced MFA (ADR-0069 D3)', () => {
1982+
const SECRET = 'test-secret-at-least-32-chars-long';
1983+
const makeEngine = (user: any, org: any) => ({
1984+
findOne: vi.fn(async (obj: string, q: any) => {
1985+
const row = obj === 'sys_organization' ? org : user;
1986+
if (!row) return null;
1987+
const w = q?.where ?? {};
1988+
return Object.entries(w).every(([k, v]) => (row as any)[k] === v) ? row : null;
1989+
}),
1990+
update: vi.fn(async () => ({})),
1991+
count: vi.fn(async () => 1),
1992+
});
1993+
const mgr = (engine: any, extra: any = {}) => {
1994+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1995+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra });
1996+
warn.mockRestore();
1997+
return m;
1998+
};
1999+
2000+
it('isAuthGateActive trips on the cached org-MFA flag even with global MFA off', () => {
2001+
const m = mgr(makeEngine(null, null), {});
2002+
// Prime the cache as if an org requires MFA.
2003+
(m as any)._orgMfaCache = { value: true, at: Date.now() };
2004+
expect(m.isAuthGateActive()).toBe(true);
2005+
});
2006+
2007+
it('blocks MFA_REQUIRED when the ACTIVE ORG requires MFA (global off) and grace elapsed', async () => {
2008+
const old = new Date(Date.now() - 30 * 86_400_000).toISOString();
2009+
const engine = makeEngine(
2010+
{ id: 'u1', two_factor_enabled: false, mfa_required_at: old },
2011+
{ id: 'org1', require_mfa: true },
2012+
);
2013+
const m = mgr(engine, { mfaRequired: false, mfaGracePeriodDays: 7 });
2014+
(m as any)._orgMfaCache = { value: true, at: Date.now() }; // org-requires cache primed
2015+
const g = await (m as any).computeAuthGate('u1', 'org1', false);
2016+
expect(g?.code).toBe('MFA_REQUIRED');
2017+
});
2018+
2019+
it('does NOT block when the active org does not require MFA (global off)', async () => {
2020+
const old = new Date(Date.now() - 30 * 86_400_000).toISOString();
2021+
const engine = makeEngine(
2022+
{ id: 'u1', two_factor_enabled: false, mfa_required_at: old },
2023+
{ id: 'org1', require_mfa: false },
2024+
);
2025+
const m = mgr(engine, { mfaRequired: false });
2026+
(m as any)._orgMfaCache = { value: true, at: Date.now() };
2027+
await expect((m as any).computeAuthGate('u1', 'org1', false)).resolves.toBeUndefined();
2028+
});
2029+
2030+
it('is a no-op when no org requires MFA and there is no active org', async () => {
2031+
const engine = makeEngine({ id: 'u1', two_factor_enabled: false }, null);
2032+
const m = mgr(engine, { mfaRequired: false });
2033+
// cache says no org requires MFA
2034+
(m as any)._orgMfaCache = { value: false, at: Date.now() };
2035+
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
2036+
expect(engine.findOne).not.toHaveBeenCalled();
2037+
});
2038+
});
19782039
});

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,10 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
346346
export class AuthManager {
347347
private auth: Auth<any> | null = null;
348348
private config: AuthManagerOptions;
349+
// ADR-0069 — cached "does any org require MFA" flag (per-org tightening).
350+
// Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap.
351+
private _orgMfaCache: { value: boolean; at: number } = { value: false, at: 0 };
352+
private _orgMfaRefreshing = false;
349353

350354
/**
351355
* Result of the dev-only admin seed (set by `AuthPlugin.maybeSeedDevAdmin`
@@ -2289,12 +2293,44 @@ export class AuthManager {
22892293
* default), keeping the gate zero-overhead until an admin opts in.
22902294
*/
22912295
public isAuthGateActive(): boolean {
2296+
// Per-org MFA (no global flag) can still activate the gate — keep the cheap
2297+
// sync check honest by consulting a lazily-refreshed cache.
2298+
this.refreshOrgMfaCacheIfStale();
22922299
return (
22932300
Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 ||
2294-
this.config.mfaRequired === true
2301+
this.config.mfaRequired === true ||
2302+
this._orgMfaCache.value
22952303
);
22962304
}
22972305

2306+
/**
2307+
* ADR-0069 — refresh the "any org requires MFA" cache in the background when
2308+
* stale (60s TTL). Fire-and-forget: a brand-new per-org requirement activates
2309+
* the gate on the next request, never blocking this one. No-op when global MFA
2310+
* is already on (the gate is active regardless).
2311+
*/
2312+
private refreshOrgMfaCacheIfStale(): void {
2313+
if (this.config.mfaRequired === true) return;
2314+
if (this._orgMfaRefreshing) return;
2315+
if (Date.now() - this._orgMfaCache.at < 60_000) return;
2316+
const engine = this.getDataEngine();
2317+
if (!engine) return;
2318+
this._orgMfaRefreshing = true;
2319+
void (async () => {
2320+
try {
2321+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2322+
const n = await engine.count('sys_organization', {
2323+
where: { require_mfa: true }, context: SYSTEM_CTX,
2324+
} as any);
2325+
this._orgMfaCache = { value: typeof n === 'number' && n > 0, at: Date.now() };
2326+
} catch {
2327+
// leave the prior value; try again after the TTL
2328+
} finally {
2329+
this._orgMfaRefreshing = false;
2330+
}
2331+
})();
2332+
}
2333+
22982334
/**
22992335
* ADR-0069 — compute the auth-policy gate posture for a session. Returns an
23002336
* `{ code, message }` when the user is currently blocked (e.g. password
@@ -2308,8 +2344,10 @@ export class AuthManager {
23082344
_twoFactorEnabledHint: boolean,
23092345
): Promise<{ code: string; message: string } | undefined> {
23102346
const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0);
2311-
const mfaRequired = this.config.mfaRequired === true;
2312-
if (expiryDays <= 0 && !mfaRequired) return undefined; // no gate feature active
2347+
const mfaGlobal = this.config.mfaRequired === true;
2348+
// Per-org tightening: an org may require MFA above the global floor.
2349+
const orgMaybeRequires = !mfaGlobal && !!_activeOrgId && this._orgMfaCache.value;
2350+
if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires) return undefined; // no gate feature active
23132351
const engine = this.getDataEngine();
23142352
if (!engine || !userId) return undefined;
23152353
try {
@@ -2320,6 +2358,15 @@ export class AuthManager {
23202358
context: SYSTEM_CTX,
23212359
} as any);
23222360

2361+
// Effective MFA requirement: global floor OR the active org's require_mfa.
2362+
let mfaRequired = mfaGlobal;
2363+
if (!mfaRequired && orgMaybeRequires) {
2364+
const org = await engine.findOne('sys_organization', {
2365+
where: { id: _activeOrgId }, fields: ['require_mfa'], context: SYSTEM_CTX,
2366+
} as any);
2367+
mfaRequired = org?.require_mfa === true || org?.require_mfa === 1;
2368+
}
2369+
23232370
// ── Password expiry ───────────────────────────────────────────────
23242371
if (expiryDays > 0) {
23252372
const changed = u?.password_changed_at;

packages/runtime/src/http-dispatcher.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core';
3+
import { ObjectKernel, getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from '@objectstack/core';
44
import { CoreServiceName } from '@objectstack/spec/system';
55
import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
66
import type { ExecutionContext } from '@objectstack/spec/kernel';
@@ -1034,6 +1034,45 @@ export class HttpDispatcher {
10341034
* response object that callers should surface directly — no further
10351035
* dispatch happens.
10361036
*/
1037+
/**
1038+
* ADR-0069 — returns a 403 response when the resolved session is blocked by
1039+
* an auth-policy gate (expired password / required MFA) on a non-allow-listed
1040+
* path, else null. Mirrors the REST `enforceAuth` seam so REST + dispatcher
1041+
* (MCP, GraphQL) enforce consistently. Fails open on any lookup error.
1042+
*/
1043+
private async enforceAuthGate(context: any, cleanPath: string): Promise<any | null> {
1044+
try {
1045+
if (isAuthGateAllowlisted(cleanPath)) return null;
1046+
const authService: any = await this.resolveService('auth', context.environmentId);
1047+
if (!authService || typeof authService.isAuthGateActive !== 'function' || !authService.isAuthGateActive()) {
1048+
return null;
1049+
}
1050+
let api: any = authService.api;
1051+
if (!api && typeof authService.getApi === 'function') api = await authService.getApi();
1052+
if (!api?.getSession) return null;
1053+
// Normalize headers to a Web Headers instance for getSession.
1054+
const raw: any = context?.request?.headers;
1055+
let headers: any;
1056+
if (raw && typeof raw.get === 'function') {
1057+
headers = raw;
1058+
} else if (raw && typeof raw === 'object') {
1059+
headers = new (globalThis as any).Headers();
1060+
for (const k of Object.keys(raw)) {
1061+
const v = raw[k];
1062+
if (v != null) headers.set(String(k), Array.isArray(v) ? v.join(',') : String(v));
1063+
}
1064+
} else {
1065+
return null;
1066+
}
1067+
const session: any = await api.getSession({ headers }).catch(() => undefined);
1068+
const gate = evaluateAuthGate(session?.user, cleanPath);
1069+
if (!gate) return null;
1070+
return this.error(gate.message, 403, { code: gate.code });
1071+
} catch {
1072+
return null; // fail-open — never break dispatch on a gate hiccup
1073+
}
1074+
}
1075+
10371076
private async enforceProjectMembership(
10381077
context: HttpProtocolContext,
10391078
path: string,
@@ -3544,6 +3583,16 @@ export class HttpDispatcher {
35443583
// anonymous request — leave executionContext undefined
35453584
}
35463585

3586+
// ── ADR-0069 Authentication-policy gate ──
3587+
// Block a gated session (expired password / required MFA) from
3588+
// protected MCP/GraphQL/data routes, mirroring the REST seam. The core
3589+
// allow-list keeps auth + remediation reachable. Skipped (no session
3590+
// lookup) when no gate feature is active.
3591+
const authGated = await this.enforceAuthGate(context, cleanPath);
3592+
if (authGated) {
3593+
return { handled: true, response: authGated };
3594+
}
3595+
35473596
// ── Project Membership Enforcement ──
35483597
// Once the environmentId is known, gate scoped data/meta/AI/automation
35493598
// routes on `sys_environment_member`. Control-plane paths, the system

0 commit comments

Comments
 (0)