From bf1ecc54b3040ba189cfe183d3c48002bfad04f8 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 19:32:45 +0200 Subject: [PATCH] Expose credential envelope administration --- src/govoplan_access/backend/manifest.py | 9 ++ webui/src/features/admin/AdminPage.tsx | 31 +++- .../admin/CredentialEnvelopesPanel.tsx | 147 ++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 webui/src/features/admin/CredentialEnvelopesPanel.tsx diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 11206ae..3c5ed9f 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -72,6 +72,8 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( _permission("access:system_role:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "Access", "system"), _permission("access:system_setting:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "Access", "system"), _permission("access:system_setting:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "Access", "system"), + _permission("access:system_credential:read", "View system credentials", "List instance-wide reusable credential envelopes without revealing secret values.", "Access", "system"), + _permission("access:system_credential:write", "Manage system credentials", "Create, update, and retire instance-wide reusable credential envelopes.", "Access", "system"), _permission("access:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "Access", "system"), _permission("access:audit:read", "View system audit", "Read audit records across tenants.", "Access", "system"), _permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"), @@ -92,6 +94,9 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( _permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"), _permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"), _permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"), + _permission("access:credential:read", "View credentials", "List reusable credential envelopes without revealing secret values.", "Tenant access", "tenant"), + _permission("access:credential:write", "Manage credentials", "Create, update, and retire reusable credential envelopes.", "Tenant access", "tenant"), + _permission("access:credential:manage_own", "Manage own credentials", "Manage reusable credentials owned by the current membership.", "Tenant access", "tenant"), _permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"), _permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"), _permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"), @@ -126,6 +131,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( "access:system_role:assign", "access:system_setting:read", "access:system_setting:write", + "access:system_credential:read", + "access:system_credential:write", "access:governance:read", "access:governance:write", ), @@ -186,6 +193,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( "access:api_key:revoke", "access:setting:read", "access:setting:write", + "access:credential:read", + "access:credential:write", "access:policy:read", "access:policy:write", ), diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx index b15d758..4ee9b18 100644 --- a/webui/src/features/admin/AdminPage.tsx +++ b/webui/src/features/admin/AdminPage.tsx @@ -25,6 +25,7 @@ import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPan import ApiKeysPanel from "./ApiKeysPanel"; import FileConnectorsPanel from "./FileConnectorsPanel"; import MailProfilesPanel from "./MailProfilesPanel"; +import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel"; import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui"; type AdminSection = string; @@ -43,6 +44,7 @@ const handledAdminSectionIds = new Set([ "system-users", "system-file-connectors", "system-mail-servers", + "system-credentials", "tenant-settings", "tenant-roles", "tenant-function-role-mappings", @@ -50,11 +52,14 @@ const handledAdminSectionIds = new Set([ "tenant-users", "tenant-file-connectors", "tenant-mail-servers", + "tenant-credentials", "tenant-api-keys", "tenant-group-file-connectors", "tenant-group-mail-servers", + "tenant-group-credentials", "tenant-user-file-connectors", - "tenant-user-mail-servers" + "tenant-user-mail-servers", + "tenant-user-credentials" ]); export default function AdminPage({ @@ -95,6 +100,9 @@ export default function AdminPage({ if (hasScope(auth, "system:settings:read")) { if (mailProfilesAvailable) sections.add("system-mail-servers"); } + if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) { + sections.add("system-credentials"); + } if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants"); if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users"); if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles"); @@ -113,6 +121,11 @@ export default function AdminPage({ if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors"); } if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings"); + if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) { + sections.add("tenant-credentials"); + if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials"); + if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials"); + } return sections; }, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]); const [searchParams, setSearchParams] = useSearchParams(); @@ -170,6 +183,7 @@ export default function AdminPage({ visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50), visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60), visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70), + visibleNavItem(available, "system-credentials", "i18n:govoplan-core.credentials.dd097a22", 80), ...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds), ...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds) ]) @@ -185,7 +199,8 @@ export default function AdminPage({ visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40), visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50), visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60), - visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70), + visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70), + visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80), visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90), ...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds) ]) @@ -197,6 +212,7 @@ export default function AdminPage({ sortNavItems([ visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), + visibleNavItem(available, "tenant-group-credentials", "i18n:govoplan-core.credentials.dd097a22", 30), ...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds) ]) ) @@ -207,6 +223,7 @@ export default function AdminPage({ sortNavItems([ visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), + visibleNavItem(available, "tenant-user-credentials", "i18n:govoplan-core.credentials.dd097a22", 30), ...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds) ]) ) @@ -224,6 +241,13 @@ export default function AdminPage({ {!contributedSection && active === "system-mail-servers" && ( )} + {!contributedSection && active === "system-credentials" && ( + + )} {!contributedSection && active === "system-tenants" && ( )} @@ -245,8 +269,11 @@ export default function AdminPage({ {!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && } {!contributedSection && active === "tenant-api-keys" && } {!contributedSection && active === "tenant-mail-servers" && } + {!contributedSection && active === "tenant-credentials" && } {!contributedSection && active === "tenant-user-mail-servers" && } {!contributedSection && active === "tenant-group-mail-servers" && } + {!contributedSection && active === "tenant-user-credentials" && } + {!contributedSection && active === "tenant-group-credentials" && } {!contributedSection && active === "tenant-user-file-connectors" && } {!contributedSection && active === "tenant-group-file-connectors" && } {!contributedSection && active === "tenant-settings" && } diff --git a/webui/src/features/admin/CredentialEnvelopesPanel.tsx b/webui/src/features/admin/CredentialEnvelopesPanel.tsx new file mode 100644 index 0000000..b41258a --- /dev/null +++ b/webui/src/features/admin/CredentialEnvelopesPanel.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from "react"; +import { + AdminPageLayout, + CredentialEnvelopeManager, + adminErrorMessage, + useDeltaWatermarks, + type ApiSettings, + type CredentialEnvelopeTargetOption, + type MailProfileScope +} from "@govoplan/core-webui"; +import { + fetchGroupsDelta, + fetchUsersDelta, + type GroupSummary, + type UserAdminItem +} from "../../api/admin"; +import { loadDeltaRows } from "./utils/deltaRows"; + +type ScopeType = Extract; + +export default function CredentialEnvelopesPanel({ + settings, + scopeType, + canWrite +}: { + settings: ApiSettings; + scopeType: ScopeType; + canWrite: boolean; +}) { + const [targets, setTargets] = useState([]); + const [loadingTargets, setLoadingTargets] = useState( + scopeType === "user" || scopeType === "group" + ); + const [targetError, setTargetError] = useState(""); + const usersRef = useRef([]); + const groupsRef = useRef([]); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = + useDeltaWatermarks(); + + useEffect(() => { + usersRef.current = []; + groupsRef.current = []; + resetDeltaWatermark(); + void loadTargets(); + }, [ + resetDeltaWatermark, + scopeType, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey + ]); + + async function loadTargets() { + if (scopeType !== "user" && scopeType !== "group") { + setTargets([]); + setLoadingTargets(false); + setTargetError(""); + return; + } + setLoadingTargets(true); + setTargetError(""); + try { + if (scopeType === "user") { + const users = await loadDeltaRows( + usersRef.current, + "access:credential-users", + getDeltaWatermark, + setDeltaWatermark, + (since) => fetchUsersDelta(settings, { since }), + (response) => response.users, + (user) => user.id, + "access_user", + (left, right) => left.email.localeCompare(right.email) + ); + usersRef.current = users; + setTargets( + users.map((user) => ({ + id: user.id, + label: user.display_name || user.email, + secondary: user.display_name ? user.email : null + })) + ); + } else { + const groups = await loadDeltaRows( + groupsRef.current, + "access:credential-groups", + getDeltaWatermark, + setDeltaWatermark, + (since) => fetchGroupsDelta(settings, { since }), + (response) => response.groups, + (group) => group.id, + "access_group", + (left, right) => + left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug) + ); + groupsRef.current = groups; + setTargets( + groups.map((group) => ({ + id: group.id, + label: group.name, + secondary: group.slug + })) + ); + } + } catch (err) { + setTargets([]); + setTargetError(adminErrorMessage(err)); + } finally { + setLoadingTargets(false); + } + } + + return ( + + + + ); +} + +function scopeTitle(scopeType: ScopeType): string { + if (scopeType === "system") return "System credentials"; + if (scopeType === "tenant") return "Tenant credentials"; + if (scopeType === "group") return "Group credentials"; + return "User credentials"; +} + +function scopeDescription(scopeType: ScopeType): string { + if (scopeType === "system") { + return "Instance credentials that can be inherited by tenants and limited to selected modules or servers."; + } + if (scopeType === "tenant") { + return "Tenant credentials shared by Mail, Files, Calendar, Addresses, and other permitted modules."; + } + return `Reusable credentials owned by the selected ${scopeType}.`; +}