feat(security): isolate bounded work and support required auth actions
This commit is contained in:
+50
-11
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user