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 && + + + } + {dialog === "upload" && i18n:govoplan-files.loading_access_explanation.04a7c934

; + 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({ + {showDelete && } ); @@ -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",