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
+3
View File
@@ -28,6 +28,7 @@ import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
import PasswordLifecycleScenario from "./PasswordLifecycleScenario";
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
@@ -83,6 +84,8 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("password-lifecycle")) return <PasswordLifecycleScenario />;
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
@@ -0,0 +1,67 @@
import { useState } from "react";
import "../src/styles/auth-gate.css";
import { useLocation } from "react-router";
import PasswordChangePanel from "../../../govoplan-access/webui/src/features/passwords/PasswordChangePanel";
import PasswordRecoveryPage from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryPage";
import PasswordRecoveryIssueDialog from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryIssueDialog";
import PasswordLoginHelp from "../../../govoplan-access/webui/src/features/passwords/PasswordLoginHelp";
import SystemUsersPanel from "../../../govoplan-access/webui/src/features/admin/SystemUsersPanel";
import { passwordTranslations } from "../../../govoplan-access/webui/src/i18n/passwordTranslations";
import { generatedTranslations } from "../../../govoplan-access/webui/src/i18n/generatedTranslations";
import AuthActionGate from "../src/features/auth/AuthActionGate";
import LoginModal from "../src/features/auth/LoginModal";
import Button from "../src/components/Button";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate, PlatformWebModule } from "../src/types";
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
const capability: AuthActionUiCapability = {
actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp
};
const modules: PlatformWebModule[] = [{
id: "access", label: "Access", version: "fixture", uiCapabilities: { "auth.actions": capability },
helpContexts: [
{ id: "access.password.change", topic_id: "access.help.password-change", title: "Change your local password", documentation_types: ["user", "admin"] },
{ id: "access.password.recover", topic_id: "access.help.password-recovery", title: "Recover a local password", documentation_types: ["user", "admin"] },
{ id: "access.password.issue-recovery", topic_id: "access.help.password-issue-recovery", title: "Issue and hand over a recovery code", documentation_types: ["user", "admin"] }
]
}];
export default function PasswordLifecycleScenario() {
const parameters = new URLSearchParams(useLocation().search);
const mode = parameters.get("mode") ?? "required";
const language = parameters.get("language") ?? "en";
const tenant = { id: "tenant-1", slug: "fixture", name: "Fixture" };
const owner = parameters.get("owner") !== "false";
const [auth, setAuth] = useState<AuthInfo>({
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", local_password: parameters.get("external") !== "true", required_auth_action: mode === "required" || mode === "missing" ? "change_password" : null },
tenant, active_tenant: tenant, scopes: mode === "required" || mode === "missing" ? [] : owner ? ["system:*"] : ["system:accounts:update"], roles: [], groups: [],
principal: { account_id: "account-1", membership_id: "membership-1", auth_method: parameters.get("api-key") ? "api_key" : "session", scopes: [], group_ids: [], session_id: "old-session" },
profile_loaded: true, roles_loaded: true, groups_loaded: true
});
const [updated, setUpdated] = useState("");
const [open, setOpen] = useState(true);
function update(next: AuthUpdate | null, token?: string) {
setUpdated(JSON.stringify({ action: next?.user?.required_auth_action ?? null, token, session: next?.principal?.session_id }));
if (next?.user) setAuth((current) => ({ ...current, ...next, user: { ...current.user, ...next.user }, tenant: current.tenant, active_tenant: current.active_tenant, tenants: current.tenants }));
}
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations, passwordTranslations]}>
<PlatformModulesProvider modules={mode === "missing" ? [] : modules}>
<div data-testid="password-scenario">
{mode === "required" || mode === "missing"
? auth.user.required_auth_action
? <AuthActionGate settings={settings} auth={auth} capability={mode === "missing" ? null : capability} onAuthChange={update} onSignOut={() => setOpen(false)} />
: <h1>Workspace available</h1>
: mode === "recover" ? <PasswordRecoveryPage settings={settings} />
: mode === "issue" ? open
? <PasswordRecoveryIssueDialog settings={settings} account={{ account_id: "target-1", email: "target@example.test" }} onClose={() => setOpen(false)} />
: <Button onClick={() => setOpen(true)}>Reopen recovery</Button>
: mode === "admin" ? <SystemUsersPanel settings={settings} auth={auth} canCreate={false} canUpdate canSuspend={false} canAssignRoles={false} canManageMemberships={false} onAuthRefresh={async () => {}} />
: mode === "login" ? open && <LoginModal settings={settings} onClose={() => setOpen(false)} onLogin={() => {}} />
: <PasswordChangePanel settings={settings} auth={auth} onAuthChange={update} />}
<output data-testid="auth-update">{updated}</output>
</div>
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
+5 -1
View File
@@ -3,6 +3,10 @@
// generated module catalogue into this isolated test bundle.
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
export { fetchAuthGroups } from "../src/api/auth";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "../src/api/adminCommon";
export type { AdminOverview, PermissionItem, TenantAdminItem } from "../src/api/adminCommon";
export type * from "../src/api/privacyRetention";
export type { ResourceAccessExplanationOptions } from "../src/api/resourceAccess";
export { default as FormSection } from "../src/components/FormSection";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
export type * from "../src/api/mailContracts";
@@ -62,7 +66,7 @@ export { MailServerFolderLookupResultView } from "../src/components/mail/MailSer
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
export { adminErrorMessage } from "../src/components/admin/adminUtils";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "../src/components/admin/adminUtils";
export { default as ConnectionTree } from "../src/components/ConnectionTree";
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
export { default as StageRail } from "../src/components/StageRail";
@@ -0,0 +1,273 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { createRequire } from "node:module";
const axePath = createRequire(import.meta.url).resolve("axe-core/axe.min.js");
const currentPassword = "current-password-fixture";
const nextPassword = "new-password-fixture";
const recoveryCode = "pr_fixture-code-never-a-real-secret";
const policy = { recovery_enabled: true, min_length: 10, max_length: 1024, recovery_minutes: 15 };
async function mockPasswordApi(page: Page, options: { enabled?: boolean; failChange?: boolean; failRecovery?: boolean } = {}) {
const posts: Array<{ path: string; body: Record<string, unknown> }> = [];
await page.route("**/api/v1/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() === "POST") posts.push({ path, body: request.postDataJSON() });
const json = (data: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data) });
if (path === "/api/v1/auth/password/policy") return json({ ...policy, recovery_enabled: options.enabled ?? true });
if (path === "/api/v1/auth/password/change") {
if (options.failChange) return json({ detail: { code: "current_password_invalid", input: currentPassword } }, 403);
return json({ user: { required_auth_action: null, local_password: true }, principal: { auth_method: "session", session_id: "rotated-session" } });
}
if (path.startsWith("/api/v1/auth/password/recovery/")) return json({ recovery_code: recoveryCode, expires_at: "2026-10-01T10:15:00Z" });
if (path === "/api/v1/auth/password/recover") return options.failRecovery
? json({ detail: { code: "recovery_invalid", input: recoveryCode } }, 400) : json({ ok: true });
if (path === "/api/v1/admin/system/accounts/delta") return json({ accounts: [
{ account_id: "target-local", email: "local@example.test", local_password: true, is_active: true, roles: [], memberships: [] },
{ account_id: "target-external", email: "external@example.test", local_password: false, is_active: true, roles: [], memberships: [] }
], roles: [], deleted: [], watermark: "fixture", full: true, has_more: false });
if (path.endsWith("/tenants")) return json({ tenants: [] });
// All browser verification uses synthetic responses; nothing reaches a live provider.
return json({ detail: "Unmocked fixture request" }, 404);
});
return posts;
}
async function expectNoSecretsInStorage(page: Page) {
const values = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }));
for (const secret of [currentPassword, nextPassword, recoveryCode]) {
expect(values).not.toContain(secret);
expect(page.url()).not.toContain(secret);
}
}
async function expectHelpContext(control: Locator, context: string) {
await expect(control).toBeVisible();
expect(await control.evaluate((element) => {
const scoped = element.closest<HTMLElement>("[data-help-context-id]");
return { context: scoped?.dataset.helpContextId, module: scoped?.dataset.helpModuleId };
})).toEqual({ context, module: "access" });
}
test("required-action F1 resolves public static help without privileged API calls or secret queries", async ({ page }) => {
await mockPasswordApi(page);
const apiRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/v1/")) apiRequests.push(new URL(request.url()).pathname);
});
await page.goto("/?password-lifecycle&mode=required&language=en");
const current = page.getByLabel("Current password", { exact: true });
await current.fill(currentPassword);
await expectHelpContext(current, "access.password.change");
await expectHelpContext(page.getByLabel("New password", { exact: true }), "access.password.change");
await expectHelpContext(page.getByLabel("Confirm new password", { exact: true }), "access.password.change");
await expectHelpContext(page.getByRole("button", { name: "Change password", exact: true }), "access.password.change");
await current.press("F1");
const dialog = page.getByRole("dialog");
await expect(dialog.locator('[data-help-context="access.password.change"]')).toBeVisible();
await expect(dialog).toContainText("access.help.password-change");
await expect(dialog).not.toContainText(currentPassword);
await page.evaluate(() => {
window.open = (url) => {
document.body.dataset.openedHelpUrl = String(url);
return null;
};
});
await dialog.getByRole("button", { name: "Open user documentation", exact: true }).click();
const opened = new URL(await page.locator("body").getAttribute("data-opened-help-url") ?? "");
expect(opened.origin).toBe("https://govoplan.add-ideas.de");
expect(opened.searchParams.get("topic")).toBe("access.help.password-change");
expect(opened.searchParams.get("module")).toBe("access");
expect(opened.href).not.toContain(currentPassword);
expect(apiRequests.every((path) => path === "/api/v1/auth/password/policy")).toBe(true);
await expect(page.getByText("Workspace available")).toHaveCount(0);
await expectNoSecretsInStorage(page);
});
test("recovery credentials, verification, one-time display and navigation have exact owning help", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=recover&language=en");
for (const label of ["Email", "Recovery code", "New password", "Confirm new password"]) {
await expectHelpContext(page.getByLabel(label, { exact: true }), "access.password.recover");
}
await expectHelpContext(page.getByRole("button", { name: "Recover local password", exact: true }), "access.password.recover");
await expectHelpContext(page.getByRole("link", { name: "Return to sign in", exact: true }), "access.password.recover");
await page.goto("/?password-lifecycle&mode=login&language=en");
await expectHelpContext(page.getByRole("link", { name: "Forgot your password?", exact: true }), "access.password.recover");
await page.goto("/?password-lifecycle&mode=admin&language=en");
await expectHelpContext(page.getByRole("button", { name: "Issue recovery code", exact: true }), "access.password.issue-recovery");
await page.goto("/?password-lifecycle&mode=issue&language=en");
const current = page.getByLabel("Current password", { exact: true });
await expectHelpContext(current, "access.password.issue-recovery");
const verified = page.getByRole("checkbox");
await expectHelpContext(verified, "access.password.issue-recovery");
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
await expectHelpContext(issue, "access.password.issue-recovery");
await current.fill(currentPassword);
await verified.check();
await issue.click();
await expectHelpContext(page.getByLabel("Recovery code", { exact: true }), "access.password.issue-recovery");
await expectHelpContext(page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last(), "access.password.issue-recovery");
});
test("required password change gates workspace and accepts the rotated cookie session", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=required&language=en");
await expect(page.getByRole("heading", { name: "Change your initial password" })).toBeVisible();
await expect(page.getByText("Workspace available")).toHaveCount(0);
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByRole("heading", { name: "Workspace available" })).toBeVisible();
expect(posts).toEqual([{ path: "/api/v1/auth/password/change", body: { current_password: currentPassword, new_password: nextPassword } }]);
await expect(page.getByTestId("auth-update")).toHaveText(JSON.stringify({ action: null, token: "", session: "rotated-session" }));
await expectNoSecretsInStorage(page);
});
test("missing optional auth UI keeps the required account out of the workspace", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=missing&language=en");
await expect(page.getByText(/A required account action must be completed/)).toBeVisible();
await expect(page.getByText("Workspace available")).toHaveCount(0);
});
test("password limits count Unicode code points, including astral characters", async ({ page }) => {
const posts = await mockPasswordApi(page);
const unicodeCurrent = "🔑".repeat(600);
const unicodeNext = "🔐".repeat(1024);
await page.goto("/?password-lifecycle&mode=settings&language=en");
await page.getByLabel("Current password", { exact: true }).fill(unicodeCurrent);
const password = page.getByLabel("New password", { exact: true });
const confirmation = page.getByLabel("Confirm new password", { exact: true });
await password.fill("a".repeat(1025));
await confirmation.fill("a".repeat(1025));
await expect(page.getByRole("button", { name: "Change password", exact: true })).toBeDisabled();
await password.fill(unicodeNext);
await confirmation.fill(unicodeNext);
await expect(password).toHaveValue(unicodeNext);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByTestId("auth-update")).toContainText("rotated-session");
expect(posts[0].body).toEqual({ current_password: unicodeCurrent, new_password: unicodeNext });
});
test("self-service is available with recovery disabled and clears rejected credentials", async ({ page }) => {
const posts = await mockPasswordApi(page, { enabled: false, failChange: true });
await page.goto("/?password-lifecycle&mode=settings&language=en");
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByText(/Your current password was not accepted/)).toBeVisible();
expect(posts).toHaveLength(1);
for (const label of ["Current password", "New password", "Confirm new password"]) await expect(page.getByLabel(label, { exact: true })).toHaveValue("");
await expect(page.locator("body")).not.toContainText(currentPassword);
await expectNoSecretsInStorage(page);
});
test("external accounts and API-key sessions cannot use the password change form", async ({ page }) => {
const posts = await mockPasswordApi(page);
for (const query of ["external=true", "api-key=true"]) {
await page.goto(`/?password-lifecycle&mode=settings&language=en&${query}`);
await expect(page.getByText(/Password changes require an interactive session/)).toBeVisible();
await expect(page.getByLabel("Current password", { exact: true })).toHaveCount(0);
}
expect(posts).toHaveLength(0);
});
test("recovery replaces the password without signing in and clears all secrets", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=recover&language=en");
await page.getByLabel("Email", { exact: true }).fill("person@example.test");
await page.getByLabel("Recovery code", { exact: true }).fill(recoveryCode);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Recover local password", exact: true }).click();
await expect(page.getByText(/Your password was replaced and existing sessions/)).toBeVisible();
expect(posts).toEqual([{ path: "/api/v1/auth/password/recover", body: { email: "person@example.test", recovery_code: recoveryCode, new_password: nextPassword } }]);
await expect(page.getByTestId("auth-update")).toHaveText("");
await expect(page.getByRole("link", { name: "Return to sign in" })).toHaveAttribute("href", "/");
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
await expectNoSecretsInStorage(page);
});
test("expired recovery codes show translated errors without reflecting response secrets", async ({ page }) => {
await mockPasswordApi(page, { failRecovery: true });
await page.goto("/?password-lifecycle&mode=recover&language=de");
await page.getByLabel("E-Mail", { exact: true }).fill("person@example.test");
await page.getByLabel("Wiederherstellungscode", { exact: true }).fill(recoveryCode);
await page.getByLabel("Neues Passwort", { exact: true }).fill(nextPassword);
await page.getByLabel("Neues Passwort bestätigen", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Lokales Passwort wiederherstellen", exact: true }).click();
await expect(page.getByText(/Dieser Wiederherstellungscode ist ungültig/)).toBeVisible();
await expect(page.getByLabel("Wiederherstellungscode", { exact: true })).toHaveValue("");
await expect(page.locator("body")).not.toContainText(recoveryCode);
});
test("issuing a code requires independent identity verification and discards the one-time display on close", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=issue&language=en");
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await expect(issue).toBeDisabled();
await page.getByRole("checkbox", { name: /I independently verified/ }).check();
await issue.click();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveValue(recoveryCode);
expect(posts).toEqual([{ path: "/api/v1/auth/password/recovery/target-1", body: { current_password: currentPassword, identity_verified: true } }]);
await expect(page.getByText(/Expires:/)).toBeVisible();
await expectNoSecretsInStorage(page);
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click();
await page.getByRole("button", { name: "Reopen recovery" }).click();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
await expect(page.getByLabel("Current password", { exact: true })).toHaveValue("");
await expect(page.getByRole("checkbox", { name: /I independently verified/ })).not.toBeChecked();
});
test("System account recovery actions require a local interactive System owner and a local target", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=admin&language=en");
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(1);
for (const query of ["owner=false", "external=true", "api-key=true"]) {
await page.goto(`/?password-lifecycle&mode=admin&language=en&${query}`);
await expect(page.getByText("local@example.test", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(0);
}
});
test("forgot-password link and recovery controls follow the disabled policy", async ({ page }) => {
await mockPasswordApi(page, { enabled: false });
await page.goto("/?password-lifecycle&mode=login&language=en");
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("link", { name: "Forgot your password?" })).toHaveCount(0);
await page.goto("/?password-lifecycle&mode=recover&language=en");
await expect(page.getByText(/Administrator-assisted password recovery is not enabled/)).toBeVisible();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
});
test("enabled forgot-password link navigates without credentials in its URL", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=login&language=en");
const link = page.getByRole("link", { name: "Forgot your password?" });
await expect(link).toHaveAttribute("href", "/password-recovery");
await link.click();
await expect(page).toHaveURL(/\/password-recovery$/);
});
for (const [language, theme] of [["en", "light"], ["de", "light"], ["en", "dark"], ["de", "dark"]]) {
test(`required password form is accessible on mobile in ${language} ${theme}`, async ({ page }, testInfo) => {
await mockPasswordApi(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/?password-lifecycle&mode=required&language=${language}&theme=${theme}`);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await page.addScriptTag({ path: axePath });
const violations = await page.evaluate(async () => {
const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe;
const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
return result.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }));
});
expect(violations).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(`password-required-${language}-${theme}.png`), fullPage: true });
});
}