From 95aef1895596c38ac18026bcba03ec3013f255e3 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 06:16:43 +0200 Subject: [PATCH] feat: select users in file access explanations --- src/govoplan_files/backend/manifest.py | 5 +-- webui/src/api/files.ts | 8 +++-- webui/src/features/files/FilesPage.tsx | 49 +++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index a0dbca7..47e595e 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -978,7 +978,7 @@ manifest = ModuleManifest( summary="List, grant, update, expire, or revoke direct read, write, and manage access without changing ownership.", body=( "File owners and file administrators can manage direct shares for users, groups, the tenant, and Campaign. Expired and revoked grants stop authorizing access immediately while independent active grants remain effective. " - "The Files share dialog lists active and historical grants, and revocation is idempotent." + "The Files share dialog lists active and historical grants, and revocation is idempotent. The access explanation action evaluates the signed-in user by default. When Policy permits selected-user diagnostics, the same shared dialog can evaluate another active user in the current tenant; those cross-user diagnostics are recorded in audit evidence and do not grant access." ), layer="available", documentation_types=("user",), @@ -1003,7 +1003,7 @@ manifest = ModuleManifest( kind="repository", ), ), - related_modules=("campaigns",), + related_modules=("access", "audit", "campaigns", "policy"), unlocks=( "A supporting process can grant governed file access without changing file ownership.", ), @@ -1025,6 +1025,7 @@ manifest = ModuleManifest( "limitations": [ "Campaign-target grants are normally created by the Campaign integration rather than selected manually in the Files dialog.", "A user may retain access through another active direct grant or ownership path after one share is revoked.", + "Selected-user explanations require Policy permission and are diagnostics only; they do not impersonate the user or grant file access.", ], "outcome": "Direct access has the requested permission and lifetime while the managed asset keeps its owner.", "verification": "Test one intended and one denied path, then expire or revoke the grant and verify that only independent access paths remain.", diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts index a98ed62..0a670ac 100644 --- a/webui/src/api/files.ts +++ b/webui/src/api/files.ts @@ -10,11 +10,15 @@ import { type FilesManagedFileLinkTarget, type ReferenceOptionProvider } from "@govoplan/core-webui"; -export { fetchResourceAccessExplanation } from "@govoplan/core-webui"; +export { + fetchResourceAccessExplanation, + fetchResourceAccessExplanationSubjects +} from "@govoplan/core-webui"; export type { AccessDecisionProvenanceItem, ResourceAccessExplanationUser as AccessExplanationUser, - ResourceAccessExplanationResponse + ResourceAccessExplanationResponse, + ResourceAccessExplanationSubjectsResponse } from "@govoplan/core-webui"; export type FileSpace = { diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx index 0fb6b40..880a356 100644 --- a/webui/src/features/files/FilesPage.tsx +++ b/webui/src/features/files/FilesPage.tsx @@ -29,6 +29,7 @@ import { downloadFile, downloadFilesAsZip, fetchResourceAccessExplanation, + fetchResourceAccessExplanationSubjects, listFilesDelta, listFilesByProperties, listFileConnectorProfiles, @@ -52,7 +53,8 @@ import { type FileSpace, type ManagedFile, type RenameResponse, - type ResourceAccessExplanationResponse } from + type ResourceAccessExplanationResponse, + type ResourceAccessExplanationSubjectsResponse } from "../../api/files"; import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants"; import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents"; @@ -176,6 +178,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut const [error, setError] = useState(""); const [accessExplanationTarget, setAccessExplanationTarget] = useState(null); const [resourceAccessExplanation, setResourceAccessExplanation] = useState(null); + const [resourceAccessSubjects, setResourceAccessSubjects] = useState(null); + const [resourceAccessSubjectId, setResourceAccessSubjectId] = useState(""); const [resourceAccessLoading, setResourceAccessLoading] = useState(false); const [shareDialogFile, setShareDialogFile] = useState(null); const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); @@ -1911,14 +1915,25 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut } async function openAccessExplanation(target: FileAccessExplanationTarget): Promise { - if (!auth.user?.id) return; + const currentUserId = auth.principal?.membership_id || auth.user?.id; + if (!currentUserId) return; setAccessExplanationTarget(target); setResourceAccessExplanation(null); + setResourceAccessSubjects(null); setResourceAccessLoading(true); setError(""); try { + const subjects = await fetchResourceAccessExplanationSubjects(settings, { + tenantId: (auth.active_tenant ?? auth.tenant)?.id + }); + const initialSubjectId = subjects.users.some((user) => user.id === currentUserId) + ? currentUserId + : subjects.users[0]?.id; + if (!initialSubjectId) throw new Error("No permitted access-explanation subject is available."); + setResourceAccessSubjects(subjects); + setResourceAccessSubjectId(initialSubjectId); setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, { - userId: auth.user.id, + userId: initialSubjectId, resourceType: target.resourceType, resourceId: target.resourceId, action: target.action, @@ -1932,6 +1947,27 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut } } + async function selectAccessExplanationSubject(userId: string): Promise { + if (!accessExplanationTarget || userId === resourceAccessSubjectId) return; + setResourceAccessLoading(true); + setError(""); + try { + const explanation = await fetchResourceAccessExplanation(settings, { + userId, + resourceType: accessExplanationTarget.resourceType, + resourceId: accessExplanationTarget.resourceId, + action: accessExplanationTarget.action, + tenantId: (auth.active_tenant ?? auth.tenant)?.id + }); + setResourceAccessExplanation(explanation); + setResourceAccessSubjectId(userId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setResourceAccessLoading(false); + } + } + function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) { const sets = selectedSetsForContext(menu); const space = spaceForContext(menu); @@ -2595,13 +2631,16 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut } {accessExplanationTarget && - {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}}}> + {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);setResourceAccessSubjects(null);setResourceAccessSubjectId("");}}}> void selectAccessExplanationSubject(userId)} fallbackResourceLabel={accessExplanationTarget.label} />
- +
}