feat(security): isolate bounded work and support required auth actions

This commit is contained in:
2026-09-08 07:47:17 +02:00
parent a6d056a3df
commit dc1f244f17
23 changed files with 1628 additions and 21 deletions
+50 -11
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes, useLocation } from "react-router";
import { lazy, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchSession, fetchShellAuth, logout, updateProfile } from "./api/auth";
import type { AuthActionUiCapability } from "./types";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, apiSettingsForAuthUpdate, clearApiReadCache, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
@@ -34,6 +35,7 @@ import { applyAppearanceOverrides } from "./components/appearanceOverrides";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
const AuthActionGate = lazy(() => import("./features/auth/AuthActionGate"));
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false,
@@ -69,6 +71,11 @@ export default function App() {
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const requiredAuthAction = auth?.user.required_auth_action ?? null;
const authActions = useMemo(
() => uiCapability<AuthActionUiCapability>("auth.actions", publicWebModules),
[publicWebModules]
);
const viewsRuntime = useMemo(
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
[webModules]
@@ -83,7 +90,7 @@ export default function App() {
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
const contextModules = auth && !requiredAuthAction ? webModules : publicWebModules;
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
@@ -102,7 +109,7 @@ export default function App() {
}, []);
useEffect(() => {
if (!auth || !viewsRuntime) {
if (!auth || requiredAuthAction || !viewsRuntime) {
setBaseViewProjection(null);
setWorkflowViewProjection(null);
return;
@@ -137,11 +144,12 @@ export default function App() {
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
requiredAuthAction,
viewsRuntime
]);
useEffect(() => {
if (!auth || !viewsRuntime) {
if (!auth || requiredAuthAction || !viewsRuntime) {
setWorkflowViewProjection(null);
return;
}
@@ -167,6 +175,7 @@ export default function App() {
auth?.user?.id,
auth?.active_tenant?.id,
auth?.tenant.id,
requiredAuthAction,
viewsRuntime
]);
@@ -289,7 +298,10 @@ export default function App() {
}, [settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!auth) return;
if (!auth || requiredAuthAction) {
setPlatformModules(null);
return;
}
let cancelled = false;
let inFlight = false;
@@ -329,12 +341,12 @@ export default function App() {
window.removeEventListener("focus", refreshVisibleModules);
document.removeEventListener("visibilitychange", refreshVisibleModules);
};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
setWebModuleLoadFailures([]);
if (!auth) {
if (!auth || requiredAuthAction) {
setLocalWebModules([]);
setRemoteWebModules([]);
setWebModulesLoading(false);
@@ -374,7 +386,7 @@ export default function App() {
}
});
return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, platformModules]);
useEffect(() => {
let cancelled = false;
@@ -489,7 +501,7 @@ export default function App() {
window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession);
};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, auth?.principal?.session_id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
if (checkingSession) {
return (
@@ -540,6 +552,27 @@ export default function App() {
}
if (requiredAuthAction) {
return <PlatformLanguageProvider
systemAvailableLanguages={systemLanguages?.available}
systemEnabledLanguageCodes={systemLanguages?.enabled}
defaultLanguage={systemLanguages?.defaultLanguage}
preferredLanguageCode={auth.user.preferred_language ?? undefined}
moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<PlatformViewProvider modules={publicWebModules} projection={null}>
<ModuleLoadBoundary resetKey={requiredAuthAction}>
<AuthActionGate settings={settings} auth={auth} capability={authActions}
onAuthChange={updateAuth}
onSignOut={() => { void logout(settings).catch(() => undefined).finally(() => updateAuth(null, "")); }} />
</ModuleLoadBoundary>
{reloginMessage && <LoginModal settings={settings} message={reloginMessage}
onClose={() => setReloginMessage("")} onLogin={handleRelogin} />}
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
const localDocsAvailable = hasAnyScope(auth, ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]) &&
webModules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
@@ -657,7 +690,7 @@ function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayl
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
export function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal);
@@ -710,6 +743,8 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
tenant_display_name: user.tenant_display_name ?? null,
is_tenant_admin: user.is_tenant_admin ?? false,
password_reset_required: user.password_reset_required ?? false,
required_auth_action: user.required_auth_action ?? null,
local_password: user.local_password ?? false,
preferred_language: user.preferred_language ?? null,
enabled_language_codes: user.enabled_language_codes ?? [],
ui_preferences: normalizeUiPreferences(user.ui_preferences)
@@ -728,16 +763,20 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
tenant_display_name: principal.display_name ?? null,
is_tenant_admin: false,
password_reset_required: false,
required_auth_action: null,
local_password: false,
preferred_language: null,
enabled_language_codes: [],
ui_preferences: DEFAULT_UI_PREFERENCES
};
}
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
export function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.user.required_auth_action ?? null) !== (auth.user.required_auth_action ?? null)) return false;
if (Boolean(sessionInfo.user.local_password) !== Boolean(auth.user.local_password)) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;