From 58c0441763b1a6e2c7abeaf7b431e2287d3f8cb1 Mon Sep 17 00:00:00 2001
From: Albrecht Degering
Date: Sat, 11 Jul 2026 00:39:39 +0200
Subject: [PATCH] Add file resource access explanations
---
src/govoplan_files/backend/capabilities.py | 267 ++++++++++++++++++
src/govoplan_files/backend/manifest.py | 3 +
tests/test_access_provider.py | 101 +++++++
webui/src/api/files.ts | 49 ++++
webui/src/features/files/FilesPage.tsx | 173 +++++++++++-
.../components/FileManagerComponents.tsx | 9 +-
webui/src/i18n/generatedTranslations.ts | 28 ++
7 files changed, 625 insertions(+), 5 deletions(-)
create mode 100644 tests/test_access_provider.py
diff --git a/src/govoplan_files/backend/capabilities.py b/src/govoplan_files/backend/capabilities.py
index 609d920..5e79516 100644
--- a/src/govoplan_files/backend/capabilities.py
+++ b/src/govoplan_files/backend/capabilities.py
@@ -1,8 +1,16 @@
from __future__ import annotations
+import base64
+import binascii
from typing import Any
+from sqlalchemy import or_
+
+from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
+from govoplan_core.core.files import FileAccessProvider
from govoplan_core.core.modules import ModuleContext
+from govoplan_core.security.module_permissions import scopes_grant_compatible
+from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
from govoplan_files.backend.runtime import configure_runtime
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
@@ -12,6 +20,21 @@ from govoplan_files.backend.storage.campaign_attachments import (
)
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
from govoplan_files.backend.storage.files import current_version_and_blob
+from govoplan_files.backend.storage.paths import normalize_folder
+
+
+VIRTUAL_FOLDER_RESOURCE_PREFIX = "virtual-folder:v1"
+
+WRITE_ACTIONS = {
+ "files:file:upload",
+ "files:file:organize",
+ "files:file:share",
+ "files:file:delete",
+ "files:upload",
+ "files:organize",
+ "files:share",
+ "files:delete",
+}
class FilesCampaignCapability:
@@ -32,3 +55,247 @@ class FilesCampaignCapability:
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
configure_runtime(registry=context.registry, settings=context.settings)
return FilesCampaignCapability()
+
+
+class FilesAccessService(FileAccessProvider):
+ def explain_resource_provenance(
+ self,
+ session: object,
+ principal: PrincipalRef,
+ *,
+ resource_type: str,
+ resource_id: str,
+ action: str,
+ ) -> tuple[AccessDecisionProvenance, ...]:
+ normalized_type = resource_type.lower().strip()
+ if normalized_type in {"file", "file_asset", "files:file"}:
+ return self._explain_file(session, principal, resource_id=resource_id, action=action)
+ if normalized_type in {"folder", "file_folder", "files:folder"}:
+ return self._explain_folder(session, principal, resource_id=resource_id, action=action)
+ return ()
+
+ def _explain_file(
+ self,
+ session: object,
+ principal: PrincipalRef,
+ *,
+ resource_id: str,
+ action: str,
+ ) -> tuple[AccessDecisionProvenance, ...]:
+ asset = session.get(FileAsset, resource_id) # type: ignore[attr-defined]
+ if asset is None or (principal.tenant_id and asset.tenant_id != principal.tenant_id):
+ return (_missing_resource("file", resource_id, principal.tenant_id, source="files.not_found"),)
+ items = [_file_resource(asset)]
+ items.extend(_owner_provenance(asset.owner_type, _owner_id(asset.owner_type, asset.owner_user_id, asset.owner_group_id), principal, source="files.owner"))
+ items.extend(_admin_provenance(principal, "files:file:admin", source="files.admin_scope"))
+ permission_values = {"write", "manage"} if _requires_write_share(action) else {"read", "write", "manage"}
+ shares = (
+ session.query(FileShare) # type: ignore[attr-defined]
+ .filter(
+ FileShare.tenant_id == asset.tenant_id,
+ FileShare.file_asset_id == asset.id,
+ FileShare.revoked_at.is_(None),
+ FileShare.permission.in_(sorted(permission_values)),
+ or_(
+ (FileShare.target_type == "user") & (FileShare.target_id == principal.membership_id),
+ (FileShare.target_type == "group") & (FileShare.target_id.in_(sorted(principal.group_ids))),
+ (FileShare.target_type == "tenant") & (FileShare.target_id == asset.tenant_id),
+ ),
+ )
+ .order_by(FileShare.target_type.asc(), FileShare.target_id.asc())
+ .all()
+ )
+ for share in shares:
+ items.append(_share_provenance(share, source="files.share"))
+ return tuple(items)
+
+ def _explain_folder(
+ self,
+ session: object,
+ principal: PrincipalRef,
+ *,
+ resource_id: str,
+ action: str,
+ ) -> tuple[AccessDecisionProvenance, ...]:
+ del action
+ folder = session.get(FileFolder, resource_id) # type: ignore[attr-defined]
+ if folder is None:
+ return self._explain_virtual_folder(session, principal, resource_id=resource_id)
+ if principal.tenant_id and folder.tenant_id != principal.tenant_id:
+ return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
+ return tuple([
+ AccessDecisionProvenance(
+ kind="resource",
+ id=folder.id,
+ label=folder.path,
+ tenant_id=folder.tenant_id,
+ source="files.folder",
+ details={
+ "resource_type": "folder",
+ "path": folder.path,
+ "owner_type": folder.owner_type,
+ "deleted": folder.deleted_at is not None,
+ },
+ ),
+ *_owner_provenance(folder.owner_type, _owner_id(folder.owner_type, folder.owner_user_id, folder.owner_group_id), principal, source="files.owner"),
+ *_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
+ ])
+
+ def _explain_virtual_folder(
+ self,
+ session: object,
+ principal: PrincipalRef,
+ *,
+ resource_id: str,
+ ) -> tuple[AccessDecisionProvenance, ...]:
+ folder_ref = parse_virtual_folder_resource_id(resource_id)
+ if folder_ref is None:
+ return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
+ tenant_id, owner_type, owner_id, path = folder_ref
+ if principal.tenant_id and tenant_id != principal.tenant_id:
+ return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
+ child_prefix = f"{path}/"
+ owner_filter = FileAsset.owner_user_id == owner_id if owner_type == "user" else FileAsset.owner_group_id == owner_id
+ child = (
+ session.query(FileAsset.id) # type: ignore[attr-defined]
+ .filter(
+ FileAsset.tenant_id == tenant_id,
+ FileAsset.owner_type == owner_type,
+ owner_filter,
+ FileAsset.deleted_at.is_(None),
+ FileAsset.display_path.like(f"{child_prefix}%"),
+ )
+ .first()
+ )
+ if child is None:
+ return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
+ return tuple([
+ AccessDecisionProvenance(
+ kind="resource",
+ id=resource_id,
+ label=path,
+ tenant_id=tenant_id,
+ source="files.virtual_folder",
+ details={
+ "resource_type": "folder",
+ "path": path,
+ "owner_type": owner_type,
+ "owner_id": owner_id,
+ "virtual": True,
+ "deleted": False,
+ },
+ ),
+ *_owner_provenance(owner_type, owner_id, principal, source="files.owner"),
+ *_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
+ ])
+
+
+def access_capability(context: ModuleContext) -> FilesAccessService:
+ configure_runtime(registry=context.registry, settings=context.settings)
+ return FilesAccessService()
+
+
+def virtual_folder_resource_id(*, tenant_id: str, owner_type: str, owner_id: str, path: str) -> str:
+ normalized_path = normalize_folder(path)
+ encoded_path = base64.urlsafe_b64encode(normalized_path.encode("utf-8")).decode("ascii").rstrip("=")
+ return f"{VIRTUAL_FOLDER_RESOURCE_PREFIX}:{tenant_id}:{owner_type}:{owner_id}:{encoded_path}"
+
+
+def parse_virtual_folder_resource_id(resource_id: str) -> tuple[str, str, str, str] | None:
+ parts = resource_id.split(":", 5)
+ if len(parts) != 6 or f"{parts[0]}:{parts[1]}" != VIRTUAL_FOLDER_RESOURCE_PREFIX:
+ return None
+ _, _, tenant_id, owner_type, owner_id, encoded_path = parts
+ if owner_type not in {"user", "group"} or not tenant_id or not owner_id or not encoded_path:
+ return None
+ padding = "=" * (-len(encoded_path) % 4)
+ try:
+ decoded_path = base64.urlsafe_b64decode(f"{encoded_path}{padding}").decode("utf-8")
+ path = normalize_folder(decoded_path)
+ except (binascii.Error, UnicodeDecodeError, ValueError):
+ return None
+ if not path:
+ return None
+ return tenant_id, owner_type, owner_id, path
+
+
+def _file_resource(asset: FileAsset) -> AccessDecisionProvenance:
+ return AccessDecisionProvenance(
+ kind="resource",
+ id=asset.id,
+ label=asset.filename,
+ tenant_id=asset.tenant_id,
+ source="files.file",
+ details={
+ "resource_type": "file",
+ "display_path": asset.display_path,
+ "owner_type": asset.owner_type,
+ "deleted": asset.deleted_at is not None,
+ },
+ )
+
+
+def _missing_resource(resource_type: str, resource_id: str, tenant_id: str | None, *, source: str) -> AccessDecisionProvenance:
+ return AccessDecisionProvenance(
+ kind="resource",
+ id=resource_id,
+ tenant_id=tenant_id,
+ source=source,
+ details={"resource_type": resource_type, "found": False},
+ )
+
+
+def _owner_id(owner_type: str, owner_user_id: str | None, owner_group_id: str | None) -> str | None:
+ return owner_user_id if owner_type == "user" else owner_group_id
+
+
+def _owner_provenance(owner_type: str, owner_id: str | None, principal: PrincipalRef, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
+ if owner_id is None:
+ return ()
+ matches_user = owner_type == "user" and owner_id == principal.membership_id
+ matches_group = owner_type == "group" and owner_id in principal.group_ids
+ if not (matches_user or matches_group):
+ return ()
+ return (
+ AccessDecisionProvenance(
+ kind="owner",
+ id=owner_id,
+ tenant_id=principal.tenant_id,
+ source=source,
+ details={"owner_type": owner_type},
+ ),
+ )
+
+
+def _admin_provenance(principal: PrincipalRef, required_scope: str, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
+ if not scopes_grant_compatible(principal.scopes, required_scope):
+ return ()
+ return (
+ AccessDecisionProvenance(
+ kind="policy",
+ id=required_scope,
+ label=required_scope,
+ tenant_id=principal.tenant_id,
+ source=source,
+ details={"grant": "tenant_admin"},
+ ),
+ )
+
+
+def _share_provenance(share: FileShare, *, source: str) -> AccessDecisionProvenance:
+ return AccessDecisionProvenance(
+ kind="share",
+ id=share.id,
+ label=share.permission,
+ tenant_id=share.tenant_id,
+ source=source,
+ details={
+ "target_type": share.target_type,
+ "target_id": share.target_id,
+ "permission": share.permission,
+ },
+ )
+
+
+def _requires_write_share(action: str) -> bool:
+ return action in WRITE_ACTIONS
diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py
index 31b5140..bce1972 100644
--- a/src/govoplan_files/backend/manifest.py
+++ b/src/govoplan_files/backend/manifest.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
+from govoplan_core.core.files import CAPABILITY_FILES_ACCESS
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
FrontendModule,
@@ -115,6 +116,7 @@ manifest = ModuleManifest(
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns",),
provides_interfaces=(
+ ModuleInterfaceProvider(name="files.access", version="0.1.6"),
ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"),
),
requires_interfaces=(
@@ -172,6 +174,7 @@ manifest = ModuleManifest(
),
),
capability_factories={
+ CAPABILITY_FILES_ACCESS: lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["access_capability"]).access_capability(context),
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
},
)
diff --git a/tests/test_access_provider.py b/tests/test_access_provider.py
new file mode 100644
index 0000000..e52fb0e
--- /dev/null
+++ b/tests/test_access_provider.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import unittest
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from govoplan_access.backend.db.models import Account, Group, User
+from govoplan_core.core.access import PrincipalRef
+from govoplan_core.db.base import Base
+from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
+from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
+
+
+TENANT_ID = "tenant-1"
+USER_ID = "user-1"
+OTHER_USER_ID = "user-2"
+GROUP_ID = "group-1"
+
+
+class FilesAccessProviderTests(unittest.TestCase):
+ def test_file_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
+ session = _session()
+ _seed_access_subjects(session)
+ owned = FileAsset(id="file-owned", tenant_id=TENANT_ID, owner_type="user", owner_user_id=USER_ID, display_path="owned.pdf", filename="owned.pdf")
+ shared = FileAsset(id="file-shared", tenant_id=TENANT_ID, owner_type="user", owner_user_id=OTHER_USER_ID, display_path="shared.pdf", filename="shared.pdf")
+ session.add_all([
+ owned,
+ shared,
+ FileShare(id="share-group", tenant_id=TENANT_ID, file_asset_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
+ ])
+ session.commit()
+
+ service = FilesAccessService()
+ owned_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id=owned.id, action="files:file:read")
+ shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="file", resource_id=shared.id, action="files:file:read")
+ admin_items = service.explain_resource_provenance(session, _principal(scopes={"files:file:admin"}), resource_type="file", resource_id=shared.id, action="files:file:read")
+ missing_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id="missing-file", action="files:file:read")
+
+ self.assertTrue(any(item.kind == "resource" and item.source == "files.file" and item.id == owned.id for item in owned_items))
+ self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
+ self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
+ self.assertTrue(any(item.kind == "policy" and item.id == "files:file:admin" for item in admin_items))
+ self.assertEqual("files.not_found", missing_items[0].source)
+ self.assertIs(missing_items[0].details["found"], False)
+
+ def test_file_access_provider_explains_persisted_and_virtual_folders(self) -> None:
+ session = _session()
+ _seed_access_subjects(session)
+ persisted = FileFolder(id="folder-persisted", tenant_id=TENANT_ID, owner_type="group", owner_group_id=GROUP_ID, path="records")
+ virtual_child = FileAsset(
+ id="file-in-virtual-folder",
+ tenant_id=TENANT_ID,
+ owner_type="group",
+ owner_group_id=GROUP_ID,
+ display_path="inferred/sub/file.pdf",
+ filename="file.pdf",
+ )
+ session.add_all([persisted, virtual_child])
+ session.commit()
+
+ service = FilesAccessService()
+ principal = _principal(group_ids={GROUP_ID})
+ persisted_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=persisted.id, action="files:file:read")
+ virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/sub")
+ virtual_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=virtual_id, action="files:file:read")
+ missing_virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/missing")
+ missing_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=missing_virtual_id, action="files:file:read")
+
+ self.assertTrue(any(item.kind == "resource" and item.source == "files.folder" and item.id == persisted.id for item in persisted_items))
+ self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in persisted_items))
+ self.assertTrue(any(item.kind == "resource" and item.source == "files.virtual_folder" and item.id == virtual_id for item in virtual_items))
+ self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in virtual_items))
+ self.assertEqual("files.not_found", missing_items[0].source)
+
+
+def _session():
+ engine = create_engine("sqlite:///:memory:", future=True)
+ Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, FileAsset.__table__, FileFolder.__table__, FileShare.__table__])
+ return sessionmaker(bind=engine, future=True)()
+
+
+def _seed_access_subjects(session) -> None:
+ session.add_all([
+ Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
+ Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
+ User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
+ User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
+ Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
+ ])
+ session.commit()
+
+
+def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
+ return PrincipalRef(
+ account_id="account-1",
+ membership_id=USER_ID,
+ tenant_id=TENANT_ID,
+ scopes=frozenset(scopes or {"files:file:read"}),
+ group_ids=frozenset(group_ids or set()),
+ )
diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts
index 18062cd..8db6750 100644
--- a/webui/src/api/files.ts
+++ b/webui/src/api/files.ts
@@ -559,6 +559,55 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
+export type AccessExplanationUser = {
+ id: string;
+ account_id?: string | null;
+ email?: string | null;
+ display_name?: string | null;
+};
+
+export type AccessDecisionProvenanceItem = {
+ kind: string;
+ id?: string | null;
+ label?: string | null;
+ tenant_id?: string | null;
+ source?: string | null;
+ details?: Record;
+};
+
+export type ResourceAccessExplanationResponse = {
+ user: AccessExplanationUser;
+ resource_type: string;
+ resource_id: string;
+ action: string;
+ provenance: AccessDecisionProvenanceItem[];
+};
+
+export function fetchResourceAccessExplanation(
+settings: ApiSettings,
+options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
+: Promise {
+ const params = new URLSearchParams({
+ user_id: options.userId,
+ resource_type: options.resourceType,
+ resource_id: options.resourceId,
+ action: options.action
+ });
+ if (options.tenantId) params.set("tenant_id", options.tenantId);
+ return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
+}
+
+export function virtualFolderResourceId(options: {tenantId: string;ownerType: "user" | "group";ownerId: string;path: string;}): string {
+ return `virtual-folder:v1:${options.tenantId}:${options.ownerType}:${options.ownerId}:${base64Url(options.path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""))}`;
+}
+
+function base64Url(value: string): string {
+ const bytes = new TextEncoder().encode(value);
+ let binary = "";
+ bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
+}
+
export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise {
return apiFetch(settings, "/api/v1/files/bulk-shares", {
method: "POST",
diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx
index 097fec2..c919dd0 100644
--- a/webui/src/features/files/FilesPage.tsx
+++ b/webui/src/features/files/FilesPage.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
-import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
+import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
import {
Button,
ConfirmDialog,
@@ -22,6 +22,7 @@ import {
deleteFolder,
downloadFile,
downloadFilesAsZip,
+ fetchResourceAccessExplanation,
listFilesDelta,
listFileConnectorProfiles,
listFileSpaces,
@@ -30,6 +31,7 @@ import {
syncFileConnectorFile,
transferFiles,
uploadFiles,
+ virtualFolderResourceId,
type ConflictResolution,
type ConflictStrategy,
type FileConnectorBrowseItem,
@@ -38,7 +40,8 @@ import {
type FileFolder,
type FileSpace,
type ManagedFile,
- type RenameResponse } from
+ type RenameResponse,
+ type ResourceAccessExplanationResponse } 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";
@@ -84,6 +87,12 @@ import { useFileDialogs } from "./hooks/useFileDialogs";
import { useFileDragDropState } from "./hooks/useFileDragDropState";
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
+type FileAccessExplanationTarget = {
+ resourceType: "file" | "folder";
+ resourceId: string;
+ label: string;
+ action: string;
+};
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
const FILE_LIST_OVERSCAN_ROWS = 8;
@@ -133,6 +142,9 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [connectorSpaceError, setConnectorSpaceError] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
+ const [accessExplanationTarget, setAccessExplanationTarget] = useState(null);
+ const [resourceAccessExplanation, setResourceAccessExplanation] = useState(null);
+ const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
const {
dialog,
@@ -216,6 +228,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
});
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
+ const accessExplainableTarget = useMemo(
+ () => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
+ [activeSpaceId, filesBySpace, foldersBySpace, selectedFileIds, selectedFolderPaths]
+ );
+ const canExplainResourceAccess =
+ hasScope(auth, "admin:users:read") ||
+ hasScope(auth, "admin:roles:read") ||
+ hasScope(auth, "access:membership:read") ||
+ hasScope(auth, "access:role:read");
const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]);
const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null);
const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null;
@@ -1456,6 +1477,74 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
await deleteSelected(fileIds, folderPaths, space);
}
+ function accessTargetForSelection(fileIds: Set, folderPaths: Set, spaceId: string): FileAccessExplanationTarget | null {
+ if (fileIds.size === 1 && folderPaths.size === 0) {
+ const fileId = Array.from(fileIds)[0];
+ const file = filesInSpace(spaceId).find((item) => item.id === fileId);
+ return file ? {
+ resourceType: "file",
+ resourceId: file.id,
+ label: file.display_path || file.filename,
+ action: "files:file:read"
+ } : null;
+ }
+ if (fileIds.size === 0 && folderPaths.size === 1) {
+ const folderPath = normalizeFolder(Array.from(folderPaths)[0]);
+ const folder = foldersInSpace(spaceId).find((item) => normalizeFolder(item.path) === folderPath);
+ if (folder) {
+ return {
+ resourceType: "folder",
+ resourceId: folder.id,
+ label: folder.path,
+ action: "files:file:read"
+ };
+ }
+ const space = findSpace(spaceId);
+ const tenantId = (auth.active_tenant ?? auth.tenant)?.id;
+ if (!space || !tenantId || !folderPath) return null;
+ return {
+ resourceType: "folder",
+ resourceId: virtualFolderResourceId({ tenantId, ownerType: space.owner_type, ownerId: space.owner_id, path: folderPath }),
+ label: folderPath,
+ action: "files:file:read"
+ };
+ }
+ return null;
+ }
+
+ function accessTargetForContext(menu: ContextMenuState | null): FileAccessExplanationTarget | null {
+ const { fileIds, folderPaths } = selectedSetsForContext(menu);
+ return accessTargetForSelection(fileIds, folderPaths, menu?.spaceId ?? activeSpaceId);
+ }
+
+ function openAccessExplanationForContext(menu: ContextMenuState | null) {
+ const target = accessTargetForContext(menu);
+ setContextMenu(null);
+ if (target) void openAccessExplanation(target);
+ }
+
+ async function openAccessExplanation(target: FileAccessExplanationTarget): Promise {
+ if (!auth.user?.id) return;
+ setAccessExplanationTarget(target);
+ setResourceAccessExplanation(null);
+ setResourceAccessLoading(true);
+ setError("");
+ try {
+ setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, {
+ userId: auth.user.id,
+ resourceType: target.resourceType,
+ resourceId: target.resourceId,
+ action: target.action,
+ tenantId: (auth.active_tenant ?? auth.tenant)?.id
+ }));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ setAccessExplanationTarget(null);
+ } finally {
+ setResourceAccessLoading(false);
+ }
+ }
+
function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) {
const sets = selectedSetsForContext(menu);
const space = spaceForContext(menu);
@@ -1688,6 +1777,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
{hasSelection && }
+
{activeSpaceIsConnector &&
;
+ if (!explanation) return null;
+ const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
+ const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
+ return (
+ <>
+
+
i18n:govoplan-files.user.9f8a2389{userLabel}
+
i18n:govoplan-files.resource.d1c626a9{resourceLabel}
+
i18n:govoplan-files.action.97c89a4d{explanation.action}
+
i18n:govoplan-files.evidence.8487d192{explanation.provenance.length}
+
+ {explanation.provenance.length === 0 ?
+ i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e
:
+
+ {explanation.provenance.map((item, index) =>
+
+
{provenanceKindLabel(item.kind)}
+
+ {item.source || "i18n:govoplan-files.no_source.6dcf9723"}
+ {item.id && <> · {item.id}>}
+
+ {item.label &&
{item.label}
}
+ {Object.keys(item.details ?? {}).length > 0 &&
{formatProvenanceDetails(item.details ?? {})}
}
+
+ )}
+
+ }
+ >);
+}
+
+function provenanceKindLabel(kind: string): string {
+ switch (kind) {
+ case "resource":
+ return "i18n:govoplan-files.resource.d1c626a9";
+ case "owner":
+ return "i18n:govoplan-files.owner.89ff3122";
+ case "share":
+ return "i18n:govoplan-files.share.09ca55ca";
+ case "policy":
+ return "i18n:govoplan-files.policy.0b779a05";
+ case "role":
+ return "i18n:govoplan-files.role.b5b4a5a2";
+ case "right":
+ return "i18n:govoplan-files.permission.2f81a22d";
+ default:
+ return kind;
+ }
+}
+
+function formatProvenanceDetails(details: Record): string {
+ return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
+}
+
+function formatProvenanceValue(value: unknown): string {
+ if (value === null || value === undefined) return "i18n:govoplan-files.none.6eef6648";
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
+ return JSON.stringify(value);
+}
+
function FileSearchRow({
value,
caseSensitive,
diff --git a/webui/src/features/files/components/FileManagerComponents.tsx b/webui/src/features/files/components/FileManagerComponents.tsx
index 60716f6..4e87a36 100644
--- a/webui/src/features/files/components/FileManagerComponents.tsx
+++ b/webui/src/features/files/components/FileManagerComponents.tsx
@@ -1,5 +1,5 @@
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
-import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
+import { Copy, Download, FolderOpen, Home, KeyRound, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext, i18nMessage } from "@govoplan/core-webui";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
@@ -220,12 +220,14 @@ export function FileContextMenu({
canDownload,
canOrganize,
canDelete,
+ canExplainAccess,
downloadLabel,
onCreateFolder,
onUpload,
onDownload,
onMove,
onCopy,
+ onExplainAccess,
onDelete
@@ -242,7 +244,7 @@ export function FileContextMenu({
-}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onDelete: () => void;}) {
+}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;canExplainAccess: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onExplainAccess: () => void;onDelete: () => void;}) {
const showNewFolder = true;
const showDelete = menu.target !== "empty";
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
@@ -261,6 +263,7 @@ export function FileContextMenu({
{downloadLabel}
i18n:govoplan-files.move.8a74a26e
i18n:govoplan-files.copy.92556c6d
+ i18n:govoplan-files.explain_access.4d5fac37
{showDelete && i18n:govoplan-files.delete.f6fdbe48}
);
@@ -332,4 +335,4 @@ export function FileConflictDialog({
);
-}
\ No newline at end of file
+}
diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts
index 38e5ece..eedf5b1 100644
--- a/webui/src/i18n/generatedTranslations.ts
+++ b/webui/src/i18n/generatedTranslations.ts
@@ -14,6 +14,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
+ "i18n:govoplan-files.access_explanation.75ee7f62": "Access explanation",
+ "i18n:govoplan-files.action.97c89a4d": "Action",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
@@ -24,6 +26,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available",
+ "i18n:govoplan-files.close.bbfa773e": "Close",
+ "i18n:govoplan-files.evidence.8487d192": "Evidence",
+ "i18n:govoplan-files.explain_access.4d5fac37": "Explain access",
+ "i18n:govoplan-files.loading_access_explanation.04a7c934": "Loading access explanation...",
+ "i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
+ "i18n:govoplan-files.no_source.6dcf9723": "No source",
+ "i18n:govoplan-files.owner.89ff3122": "Owner",
+ "i18n:govoplan-files.permission.2f81a22d": "Permission",
+ "i18n:govoplan-files.policy.0b779a05": "Policy",
+ "i18n:govoplan-files.resource.d1c626a9": "Resource",
+ "i18n:govoplan-files.role.b5b4a5a2": "Role",
+ "i18n:govoplan-files.share.09ca55ca": "Share",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",
@@ -357,6 +371,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
+ "i18n:govoplan-files.access_explanation.75ee7f62": "Zugriffserklärung",
+ "i18n:govoplan-files.action.97c89a4d": "Aktion",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
@@ -367,6 +383,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available",
+ "i18n:govoplan-files.close.bbfa773e": "Schließen",
+ "i18n:govoplan-files.evidence.8487d192": "Nachweise",
+ "i18n:govoplan-files.explain_access.4d5fac37": "Zugriff erklären",
+ "i18n:govoplan-files.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
+ "i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
+ "i18n:govoplan-files.no_source.6dcf9723": "Keine Quelle",
+ "i18n:govoplan-files.owner.89ff3122": "Eigentümer",
+ "i18n:govoplan-files.permission.2f81a22d": "Berechtigung",
+ "i18n:govoplan-files.policy.0b779a05": "Richtlinie",
+ "i18n:govoplan-files.resource.d1c626a9": "Ressource",
+ "i18n:govoplan-files.role.b5b4a5a2": "Rolle",
+ "i18n:govoplan-files.share.09ca55ca": "Freigabe",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",