diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 07dca62..45e1de3 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -209,6 +209,7 @@ manifest = ModuleManifest( ViewSurface(id="files.admin.group-connectors", module_id="files", kind="section", label="Group file connections", order=65), ViewSurface(id="files.admin.user-connectors", module_id="files", kind="section", label="User file connections", order=65), ViewSurface(id="files.settings.connectors", module_id="files", kind="section", label="Personal file connections", order=20), + ViewSurface(id="files.widget.spaces", module_id="files", kind="section", label="File spaces widget", order=35), ), ), documentation=( diff --git a/src/govoplan_files/backend/router.py b/src/govoplan_files/backend/router.py index e27dacf..ac5321b 100644 --- a/src/govoplan_files/backend/router.py +++ b/src/govoplan_files/backend/router.py @@ -348,8 +348,8 @@ def _file_connector_policy_resource_id(scope_type: str, scope_id: str | None) -> return f"{scope_type.strip().casefold()}:{scope_id or ''}" -async def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes: - data = await upload.read(max_bytes + 1) +def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes: + data = upload.file.read(max_bytes + 1) if len(data) > max_bytes: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, @@ -358,12 +358,12 @@ async def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes: return data -async def _spool_limited_upload_to_temp(upload: UploadFile, *, max_bytes: int, suffix: str = ".upload") -> str: +def _spool_limited_upload_to_temp(upload: UploadFile, *, max_bytes: int, suffix: str = ".upload") -> str: tmp = tempfile.NamedTemporaryFile(prefix="govoplan-upload-", suffix=suffix, delete=False) total = 0 try: while True: - chunk = await upload.read(1024 * 1024) + chunk = upload.file.read(1024 * 1024) if not chunk: break total += len(chunk) @@ -1205,7 +1205,7 @@ def _cursor_page_size(scope: str, cursor: str | None, explicit_page_size: int | if explicit_page_size is not None: return explicit_page_size if not cursor: - return None + return DEFAULT_FILE_LIST_PAGE_SIZE try: values = decode_keyset_cursor(scope, cursor) except KeysetCursorError as exc: @@ -2094,7 +2094,7 @@ def list_files( @router.post("/upload", response_model=FileUploadResponse) -async def upload_files( +def upload_files( files: list[UploadFile] = FastAPIFile(...), owner_type: Literal["user", "group"] = Form(default="user"), owner_id: str | None = Form(default=None), @@ -2121,7 +2121,7 @@ async def upload_files( content_type = upload.content_type or None upload_limit = settings.file_upload_zip_max_bytes if unpack_zip and filename.lower().endswith(".zip") else settings.file_upload_max_bytes if unpack_zip and filename.lower().endswith(".zip"): - zip_path = await _spool_limited_upload_to_temp(upload, max_bytes=upload_limit, suffix=".zip") + zip_path = _spool_limited_upload_to_temp(upload, max_bytes=upload_limit, suffix=".zip") try: extracted = extract_zip_upload( session, @@ -2143,7 +2143,7 @@ async def upload_files( _cleanup_temp_file(zip_path) uploaded_assets.extend(item.asset for item in extracted) continue - data = await _read_limited_upload(upload, max_bytes=upload_limit) + data = _read_limited_upload(upload, max_bytes=upload_limit) stored = create_file_asset( session, tenant_id=principal.tenant_id, @@ -2173,7 +2173,7 @@ async def upload_files( @router.post("/upload-zip", response_model=FileUploadResponse) -async def upload_zip( +def upload_zip( file: UploadFile = FastAPIFile(...), owner_type: Literal["user", "group"] = Form(default="user"), owner_id: str | None = Form(default=None), @@ -2194,7 +2194,7 @@ async def upload_zip( upload_resolutions = _conflict_resolutions([ConflictResolutionRequest(**item) for item in raw_resolutions]) _enforce_connector_policy(source_provenance_json, connector_policy_json, operation="import") metadata = _source_metadata_from_form(source_provenance_json, source_revision) - zip_path = await _spool_limited_upload_to_temp(file, max_bytes=settings.file_upload_zip_max_bytes, suffix=".zip") + zip_path = _spool_limited_upload_to_temp(file, max_bytes=settings.file_upload_zip_max_bytes, suffix=".zip") extracted = extract_zip_upload( session, tenant_id=principal.tenant_id, diff --git a/src/govoplan_files/backend/storage/connector_policy.py b/src/govoplan_files/backend/storage/connector_policy.py index 74373d1..9b74303 100644 --- a/src/govoplan_files/backend/storage/connector_policy.py +++ b/src/govoplan_files/backend/storage/connector_policy.py @@ -246,11 +246,11 @@ def _applied_fields(policy: Mapping[str, Any]) -> tuple[str, ...]: def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[str]) -> bool: if field == "connectors": - return _matches_exact(request.connector_id, patterns) + return _matches_reference(request.connector_id, patterns) if field == "credentials": - return _matches_exact(request.credential_id, patterns) + return _matches_reference(request.credential_id, patterns) if field == "providers": - return _matches_exact(request.provider, patterns) + return _matches_reference(request.provider, patterns) if field == "external_ids": return _matches_glob(request.external_id, patterns) if field == "external_paths": @@ -260,11 +260,11 @@ def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[s return False -def _matches_exact(value: str | None, patterns: list[str]) -> bool: +def _matches_reference(value: str | None, patterns: list[str]) -> bool: if value is None: return False clean = value.casefold() - return any(pattern == "*" or clean == pattern.casefold() for pattern in patterns) + return any(fnmatchcase(clean, pattern.casefold()) for pattern in patterns) def _matches_glob(value: str | None, patterns: list[str]) -> bool: diff --git a/src/govoplan_files/backend/storage/http_client.py b/src/govoplan_files/backend/storage/http_client.py index 533fc78..f13c5a7 100644 --- a/src/govoplan_files/backend/storage/http_client.py +++ b/src/govoplan_files/backend/storage/http_client.py @@ -185,15 +185,23 @@ class _OutboundPolicyHTTPTransport(httpx.BaseTransport): self._connection_pool.close() +_CONNECTOR_HTTP_CLIENT = httpx.Client( + transport=_OutboundPolicyHTTPTransport(), + follow_redirects=False, + timeout=15.0, +) + + @contextmanager def _stream_connector_request(method: str, url: str, **kwargs: Any) -> Iterator[httpx.Response]: - with httpx.Client( - transport=_OutboundPolicyHTTPTransport(), - follow_redirects=False, - timeout=kwargs.pop("timeout", 15.0), - ) as client: - with client.stream(method, url, **kwargs) as response: - yield response + timeout = kwargs.pop("timeout", 15.0) + with _CONNECTOR_HTTP_CLIENT.stream( + method, + url, + timeout=timeout, + **kwargs, + ) as response: + yield response def request_connector_bytes( diff --git a/tests/test_connector_policy.py b/tests/test_connector_policy.py new file mode 100644 index 0000000..76ebaf8 --- /dev/null +++ b/tests/test_connector_policy.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import unittest + +from govoplan_files.backend.storage.connector_policy import ( + ConnectorAccessRequest, + ConnectorPolicySource, + connector_policy_decision, +) + + +class ConnectorPolicyPatternTests(unittest.TestCase): + def test_resource_reference_allow_patterns_match(self) -> None: + decision = connector_policy_decision( + ConnectorAccessRequest( + connector_id="dev-smb", + credential_id="credential-primary", + provider="smb", + ), + ( + ConnectorPolicySource( + scope_type="tenant", + scope_id="tenant-1", + label="Tenant", + policy={ + "allow": { + "connectors": ["dev-*"], + "credentials": ["credential-*"], + "providers": ["s?b"], + } + }, + ), + ), + ) + + self.assertTrue(decision.allowed) + + def test_resource_reference_deny_patterns_take_precedence(self) -> None: + decision = connector_policy_decision( + ConnectorAccessRequest( + connector_id="dev-smb", + credential_id="credential-legacy", + provider="smb", + ), + ( + ConnectorPolicySource( + scope_type="tenant", + scope_id="tenant-1", + label="Tenant", + policy={ + "allow": {"connectors": ["dev-*"]}, + "deny": {"credentials": ["*-legacy"]}, + }, + ), + ), + ) + + self.assertFalse(decision.allowed) + self.assertEqual( + ("connector_policy_denylist",), + decision.requirements, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 37bd0a8..7813095 100644 --- a/webui/package.json +++ b/webui/package.json @@ -22,7 +22,7 @@ "typescript": "^5.7.2", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1", + "react-router-dom": ">=7.18.2 <8", "lucide-react": "^1.23.0", "@govoplan/core-webui": "^0.1.9" }, diff --git a/webui/src/features/files/FileConnectorSettingsPanel.tsx b/webui/src/features/files/FileConnectorSettingsPanel.tsx index 833611a..a7ba39f 100644 --- a/webui/src/features/files/FileConnectorSettingsPanel.tsx +++ b/webui/src/features/files/FileConnectorSettingsPanel.tsx @@ -14,16 +14,21 @@ import { AdvancedOptionsPanel, LoadingFrame, mergeDeltaRows, + ReferenceMultiSelect, SegmentedControl, + staticReferenceOptionProvider, StatusBadge, TableActionGroup, ToggleSwitch, useDeltaWatermarks, + usePlatformLanguage, useUnsavedDraftGuard, + wildcardReferenceOption, type ApiSettings, type ConnectionTreeColumn, type FileConnectorScope, type FileConnectorTargetOption, + type ReferenceOption, i18nMessage } from "@govoplan/core-webui"; import { @@ -186,6 +191,7 @@ export default function FileConnectorSettingsPanel({ const [credentialLoginResult, setCredentialLoginResult] = useState<{ok: boolean;message: string;} | null>(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); + const { translateText } = usePlatformLanguage(); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const requiresTarget = scopeType === "user" || scopeType === "group"; @@ -198,6 +204,10 @@ export default function FileConnectorSettingsPanel({ const profileDirty = Boolean(draft) && connectorProfileDraftKey(draft) !== savedDraftKey; const credentialDirty = Boolean(credentialDraft) && connectorCredentialDraftKey(credentialDraft) !== savedCredentialDraftKey; const policyDirty = scopeReady && centralPolicyDraftKey(policyDraft) !== savedPolicyDraftKey; + const policyConflicts = useMemo( + () => policyReferenceConflicts(policyDraft), + [policyDraft] + ); useUnsavedDraftGuard({ dirty: profileDirty || credentialDirty || policyDirty, @@ -233,6 +243,71 @@ export default function FileConnectorSettingsPanel({ ), [activeScopeId, credentials, scopeType] ); + const connectorReferenceOptions = useMemo( + () => profiles.map((profile) => ({ + value: profile.id, + label: profile.label || profile.id, + description: [ + profile.provider, + profile.source_path, + profile.enabled ? null : "Inactive" + ].filter(Boolean).join(" · "), + kind: "file_connection", + availability: profile.enabled ? "available" : "inactive", + disabled: !profile.enabled, + sourceModule: "files", + provenance: { + scopeType: profile.scope_type, + scopeId: profile.scope_id, + sourcePath: profile.source_path + } + })), + [profiles] + ); + const credentialReferenceOptions = useMemo( + () => credentials.map((credential) => ({ + value: credential.id, + label: credential.label || credential.id, + description: [ + credential.provider || "shared", + credential.source_path, + credential.enabled ? null : "Inactive" + ].filter(Boolean).join(" · "), + kind: "file_credential", + availability: credential.enabled ? "available" : "inactive", + disabled: !credential.enabled, + sourceModule: credential.source_kind === "credential_envelope" ? "core" : "files", + provenance: { + scopeType: credential.scope_type, + scopeId: credential.scope_id, + sourcePath: credential.source_path, + sourceKind: credential.source_kind + } + })), + [credentials] + ); + const providerReferenceOptions = useMemo( + () => PROVIDERS.map((provider) => ({ + value: provider.id, + label: translateText(provider.label), + description: provider.id, + kind: "file_provider", + sourceModule: "files" + })), + [translateText] + ); + const connectorReferenceProvider = useMemo( + () => staticReferenceOptionProvider(connectorReferenceOptions), + [connectorReferenceOptions] + ); + const credentialReferenceProvider = useMemo( + () => staticReferenceOptionProvider(credentialReferenceOptions), + [credentialReferenceOptions] + ); + const providerReferenceProvider = useMemo( + () => staticReferenceOptionProvider(providerReferenceOptions), + [providerReferenceOptions] + ); useEffect(() => { resetDeltaWatermark(deltaKey); @@ -865,22 +940,70 @@ export default function FileConnectorSettingsPanel({ <>
-