Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/adr-0069-mfa-tails.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/platform-objects/src/identity/sys-organization.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
61 changes: 61 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
53 changes: 50 additions & 3 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
export class AuthManager {
private auth: Auth<any> | 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`
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
51 changes: 50 additions & 1 deletion packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<any | null> {
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,
Expand Down Expand Up @@ -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
Expand Down