security: harden connector policy and add dashboard widget
This commit is contained in:
@@ -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.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.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.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=(
|
documentation=(
|
||||||
|
|||||||
@@ -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 ''}"
|
return f"{scope_type.strip().casefold()}:{scope_id or ''}"
|
||||||
|
|
||||||
|
|
||||||
async def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes:
|
def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes:
|
||||||
data = await upload.read(max_bytes + 1)
|
data = upload.file.read(max_bytes + 1)
|
||||||
if len(data) > max_bytes:
|
if len(data) > max_bytes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
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
|
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)
|
tmp = tempfile.NamedTemporaryFile(prefix="govoplan-upload-", suffix=suffix, delete=False)
|
||||||
total = 0
|
total = 0
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = await upload.read(1024 * 1024)
|
chunk = upload.file.read(1024 * 1024)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
total += len(chunk)
|
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:
|
if explicit_page_size is not None:
|
||||||
return explicit_page_size
|
return explicit_page_size
|
||||||
if not cursor:
|
if not cursor:
|
||||||
return None
|
return DEFAULT_FILE_LIST_PAGE_SIZE
|
||||||
try:
|
try:
|
||||||
values = decode_keyset_cursor(scope, cursor)
|
values = decode_keyset_cursor(scope, cursor)
|
||||||
except KeysetCursorError as exc:
|
except KeysetCursorError as exc:
|
||||||
@@ -2094,7 +2094,7 @@ def list_files(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/upload", response_model=FileUploadResponse)
|
@router.post("/upload", response_model=FileUploadResponse)
|
||||||
async def upload_files(
|
def upload_files(
|
||||||
files: list[UploadFile] = FastAPIFile(...),
|
files: list[UploadFile] = FastAPIFile(...),
|
||||||
owner_type: Literal["user", "group"] = Form(default="user"),
|
owner_type: Literal["user", "group"] = Form(default="user"),
|
||||||
owner_id: str | None = Form(default=None),
|
owner_id: str | None = Form(default=None),
|
||||||
@@ -2121,7 +2121,7 @@ async def upload_files(
|
|||||||
content_type = upload.content_type or None
|
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
|
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"):
|
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:
|
try:
|
||||||
extracted = extract_zip_upload(
|
extracted = extract_zip_upload(
|
||||||
session,
|
session,
|
||||||
@@ -2143,7 +2143,7 @@ async def upload_files(
|
|||||||
_cleanup_temp_file(zip_path)
|
_cleanup_temp_file(zip_path)
|
||||||
uploaded_assets.extend(item.asset for item in extracted)
|
uploaded_assets.extend(item.asset for item in extracted)
|
||||||
continue
|
continue
|
||||||
data = await _read_limited_upload(upload, max_bytes=upload_limit)
|
data = _read_limited_upload(upload, max_bytes=upload_limit)
|
||||||
stored = create_file_asset(
|
stored = create_file_asset(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
@@ -2173,7 +2173,7 @@ async def upload_files(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/upload-zip", response_model=FileUploadResponse)
|
@router.post("/upload-zip", response_model=FileUploadResponse)
|
||||||
async def upload_zip(
|
def upload_zip(
|
||||||
file: UploadFile = FastAPIFile(...),
|
file: UploadFile = FastAPIFile(...),
|
||||||
owner_type: Literal["user", "group"] = Form(default="user"),
|
owner_type: Literal["user", "group"] = Form(default="user"),
|
||||||
owner_id: str | None = Form(default=None),
|
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])
|
upload_resolutions = _conflict_resolutions([ConflictResolutionRequest(**item) for item in raw_resolutions])
|
||||||
_enforce_connector_policy(source_provenance_json, connector_policy_json, operation="import")
|
_enforce_connector_policy(source_provenance_json, connector_policy_json, operation="import")
|
||||||
metadata = _source_metadata_from_form(source_provenance_json, source_revision)
|
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(
|
extracted = extract_zip_upload(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
|
|||||||
@@ -246,11 +246,11 @@ def _applied_fields(policy: Mapping[str, Any]) -> tuple[str, ...]:
|
|||||||
|
|
||||||
def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[str]) -> bool:
|
def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[str]) -> bool:
|
||||||
if field == "connectors":
|
if field == "connectors":
|
||||||
return _matches_exact(request.connector_id, patterns)
|
return _matches_reference(request.connector_id, patterns)
|
||||||
if field == "credentials":
|
if field == "credentials":
|
||||||
return _matches_exact(request.credential_id, patterns)
|
return _matches_reference(request.credential_id, patterns)
|
||||||
if field == "providers":
|
if field == "providers":
|
||||||
return _matches_exact(request.provider, patterns)
|
return _matches_reference(request.provider, patterns)
|
||||||
if field == "external_ids":
|
if field == "external_ids":
|
||||||
return _matches_glob(request.external_id, patterns)
|
return _matches_glob(request.external_id, patterns)
|
||||||
if field == "external_paths":
|
if field == "external_paths":
|
||||||
@@ -260,11 +260,11 @@ def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[s
|
|||||||
return False
|
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:
|
if value is None:
|
||||||
return False
|
return False
|
||||||
clean = value.casefold()
|
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:
|
def _matches_glob(value: str | None, patterns: list[str]) -> bool:
|
||||||
|
|||||||
@@ -185,15 +185,23 @@ class _OutboundPolicyHTTPTransport(httpx.BaseTransport):
|
|||||||
self._connection_pool.close()
|
self._connection_pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
_CONNECTOR_HTTP_CLIENT = httpx.Client(
|
||||||
|
transport=_OutboundPolicyHTTPTransport(),
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _stream_connector_request(method: str, url: str, **kwargs: Any) -> Iterator[httpx.Response]:
|
def _stream_connector_request(method: str, url: str, **kwargs: Any) -> Iterator[httpx.Response]:
|
||||||
with httpx.Client(
|
timeout = kwargs.pop("timeout", 15.0)
|
||||||
transport=_OutboundPolicyHTTPTransport(),
|
with _CONNECTOR_HTTP_CLIENT.stream(
|
||||||
follow_redirects=False,
|
method,
|
||||||
timeout=kwargs.pop("timeout", 15.0),
|
url,
|
||||||
) as client:
|
timeout=timeout,
|
||||||
with client.stream(method, url, **kwargs) as response:
|
**kwargs,
|
||||||
yield response
|
) as response:
|
||||||
|
yield response
|
||||||
|
|
||||||
|
|
||||||
def request_connector_bytes(
|
def request_connector_bytes(
|
||||||
|
|||||||
66
tests/test_connector_policy.py
Normal file
66
tests/test_connector_policy.py
Normal file
@@ -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()
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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",
|
"lucide-react": "^1.23.0",
|
||||||
"@govoplan/core-webui": "^0.1.9"
|
"@govoplan/core-webui": "^0.1.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,16 +14,21 @@ import {
|
|||||||
AdvancedOptionsPanel,
|
AdvancedOptionsPanel,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
mergeDeltaRows,
|
mergeDeltaRows,
|
||||||
|
ReferenceMultiSelect,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
staticReferenceOptionProvider,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TableActionGroup,
|
TableActionGroup,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
useDeltaWatermarks,
|
useDeltaWatermarks,
|
||||||
|
usePlatformLanguage,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
|
wildcardReferenceOption,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type ConnectionTreeColumn,
|
type ConnectionTreeColumn,
|
||||||
type FileConnectorScope,
|
type FileConnectorScope,
|
||||||
type FileConnectorTargetOption,
|
type FileConnectorTargetOption,
|
||||||
|
type ReferenceOption,
|
||||||
i18nMessage } from
|
i18nMessage } from
|
||||||
"@govoplan/core-webui";
|
"@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -186,6 +191,7 @@ export default function FileConnectorSettingsPanel({
|
|||||||
const [credentialLoginResult, setCredentialLoginResult] = useState<{ok: boolean;message: string;} | null>(null);
|
const [credentialLoginResult, setCredentialLoginResult] = useState<{ok: boolean;message: string;} | null>(null);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
|
|
||||||
const requiresTarget = scopeType === "user" || scopeType === "group";
|
const requiresTarget = scopeType === "user" || scopeType === "group";
|
||||||
@@ -198,6 +204,10 @@ export default function FileConnectorSettingsPanel({
|
|||||||
const profileDirty = Boolean(draft) && connectorProfileDraftKey(draft) !== savedDraftKey;
|
const profileDirty = Boolean(draft) && connectorProfileDraftKey(draft) !== savedDraftKey;
|
||||||
const credentialDirty = Boolean(credentialDraft) && connectorCredentialDraftKey(credentialDraft) !== savedCredentialDraftKey;
|
const credentialDirty = Boolean(credentialDraft) && connectorCredentialDraftKey(credentialDraft) !== savedCredentialDraftKey;
|
||||||
const policyDirty = scopeReady && centralPolicyDraftKey(policyDraft) !== savedPolicyDraftKey;
|
const policyDirty = scopeReady && centralPolicyDraftKey(policyDraft) !== savedPolicyDraftKey;
|
||||||
|
const policyConflicts = useMemo(
|
||||||
|
() => policyReferenceConflicts(policyDraft),
|
||||||
|
[policyDraft]
|
||||||
|
);
|
||||||
|
|
||||||
useUnsavedDraftGuard({
|
useUnsavedDraftGuard({
|
||||||
dirty: profileDirty || credentialDirty || policyDirty,
|
dirty: profileDirty || credentialDirty || policyDirty,
|
||||||
@@ -233,6 +243,71 @@ export default function FileConnectorSettingsPanel({
|
|||||||
),
|
),
|
||||||
[activeScopeId, credentials, scopeType]
|
[activeScopeId, credentials, scopeType]
|
||||||
);
|
);
|
||||||
|
const connectorReferenceOptions = useMemo<ReferenceOption[]>(
|
||||||
|
() => 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<ReferenceOption[]>(
|
||||||
|
() => 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<ReferenceOption[]>(
|
||||||
|
() => 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(() => {
|
useEffect(() => {
|
||||||
resetDeltaWatermark(deltaKey);
|
resetDeltaWatermark(deltaKey);
|
||||||
@@ -865,22 +940,70 @@ export default function FileConnectorSettingsPanel({
|
|||||||
<>
|
<>
|
||||||
<div className="form-grid two">
|
<div className="form-grid two">
|
||||||
<FormField label="i18n:govoplan-files.allowed_connection_ids.0df854c8">
|
<FormField label="i18n:govoplan-files.allowed_connection_ids.0df854c8">
|
||||||
<textarea value={policyDraft.allowedConnectors} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedConnectors: event.target.value })} rows={3} placeholder={"tenant-webdav\nseafile-*"} />
|
<ReferenceMultiSelect
|
||||||
|
values={lines(policyDraft.allowedConnectors)}
|
||||||
|
onChange={(values) => patchPolicyDraft({ allowedConnectors: values.join("\n") })}
|
||||||
|
provider={connectorReferenceProvider}
|
||||||
|
createCustomOption={(value) => wildcardReferenceOption(value)}
|
||||||
|
aria-label="Allowed file connections"
|
||||||
|
placeholder="Add a connection or wildcard pattern"
|
||||||
|
disabled={saving || !canWrite}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-files.denied_connection_ids.a95218b4">
|
<FormField label="i18n:govoplan-files.denied_connection_ids.a95218b4">
|
||||||
<textarea value={policyDraft.deniedConnectors} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedConnectors: event.target.value })} rows={3} placeholder={"legacy-*\nblocked-smb"} />
|
<ReferenceMultiSelect
|
||||||
|
values={lines(policyDraft.deniedConnectors)}
|
||||||
|
onChange={(values) => patchPolicyDraft({ deniedConnectors: values.join("\n") })}
|
||||||
|
provider={connectorReferenceProvider}
|
||||||
|
createCustomOption={(value) => wildcardReferenceOption(value)}
|
||||||
|
aria-label="Denied file connections"
|
||||||
|
placeholder="Add a connection or wildcard pattern"
|
||||||
|
disabled={saving || !canWrite}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-files.allowed_credential_ids.efcaba2d">
|
<FormField label="i18n:govoplan-files.allowed_credential_ids.efcaba2d">
|
||||||
<textarea value={policyDraft.allowedCredentials} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedCredentials: event.target.value })} rows={3} placeholder={"tenant-webdav-credentials\nshared-*"} />
|
<ReferenceMultiSelect
|
||||||
|
values={lines(policyDraft.allowedCredentials)}
|
||||||
|
onChange={(values) => patchPolicyDraft({ allowedCredentials: values.join("\n") })}
|
||||||
|
provider={credentialReferenceProvider}
|
||||||
|
createCustomOption={(value) => wildcardReferenceOption(value)}
|
||||||
|
aria-label="Allowed file credentials"
|
||||||
|
placeholder="Add a credential or wildcard pattern"
|
||||||
|
disabled={saving || !canWrite}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-files.denied_credential_ids.b6838bc2">
|
<FormField label="i18n:govoplan-files.denied_credential_ids.b6838bc2">
|
||||||
<textarea value={policyDraft.deniedCredentials} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedCredentials: event.target.value })} rows={3} placeholder={"personal-*\nblocked-*"} />
|
<ReferenceMultiSelect
|
||||||
|
values={lines(policyDraft.deniedCredentials)}
|
||||||
|
onChange={(values) => patchPolicyDraft({ deniedCredentials: values.join("\n") })}
|
||||||
|
provider={credentialReferenceProvider}
|
||||||
|
createCustomOption={(value) => wildcardReferenceOption(value)}
|
||||||
|
aria-label="Denied file credentials"
|
||||||
|
placeholder="Add a credential or wildcard pattern"
|
||||||
|
disabled={saving || !canWrite}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-files.allowed_providers.82a9c23e">
|
<FormField label="i18n:govoplan-files.allowed_providers.82a9c23e">
|
||||||
<textarea value={policyDraft.allowedProviders} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedProviders: event.target.value })} rows={3} placeholder={"webdav\nnextcloud\nsmb"} />
|
<ReferenceMultiSelect
|
||||||
|
values={lines(policyDraft.allowedProviders)}
|
||||||
|
onChange={(values) => patchPolicyDraft({ allowedProviders: values.join("\n") })}
|
||||||
|
provider={providerReferenceProvider}
|
||||||
|
createCustomOption={(value) => wildcardReferenceOption(value)}
|
||||||
|
aria-label="Allowed file providers"
|
||||||
|
placeholder="Add a provider or wildcard pattern"
|
||||||
|
disabled={saving || !canWrite}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-files.denied_providers.149b29f4">
|
<FormField label="i18n:govoplan-files.denied_providers.149b29f4">
|
||||||
<textarea value={policyDraft.deniedProviders} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedProviders: event.target.value })} rows={3} placeholder={"generic\nnfs"} />
|
<ReferenceMultiSelect
|
||||||
|
values={lines(policyDraft.deniedProviders)}
|
||||||
|
onChange={(values) => patchPolicyDraft({ deniedProviders: values.join("\n") })}
|
||||||
|
provider={providerReferenceProvider}
|
||||||
|
createCustomOption={(value) => wildcardReferenceOption(value)}
|
||||||
|
aria-label="Denied file providers"
|
||||||
|
placeholder="Add a provider or wildcard pattern"
|
||||||
|
disabled={saving || !canWrite}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-files.allowed_paths.c953b4be">
|
<FormField label="i18n:govoplan-files.allowed_paths.c953b4be">
|
||||||
<textarea value={policyDraft.allowedPaths} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedPaths: event.target.value })} rows={3} placeholder={"GovOPlaN\nreports/*"} />
|
<textarea value={policyDraft.allowedPaths} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedPaths: event.target.value })} rows={3} placeholder={"GovOPlaN\nreports/*"} />
|
||||||
@@ -895,6 +1018,20 @@ export default function FileConnectorSettingsPanel({
|
|||||||
<textarea value={policyDraft.deniedUrls} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedUrls: event.target.value })} rows={3} placeholder={"http://*\n*://legacy.example.test/*"} />
|
<textarea value={policyDraft.deniedUrls} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedUrls: event.target.value })} rows={3} placeholder={"http://*\n*://legacy.example.test/*"} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
{policyConflicts.length ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="warning"
|
||||||
|
resetKey={policyConflicts.join("\u0000")}
|
||||||
|
>
|
||||||
|
Allow and deny rules contain the same values for{" "}
|
||||||
|
{policyConflicts.join("; ")}. Deny rules take precedence.
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : (
|
||||||
|
<p className="muted small-note">
|
||||||
|
When an allow and deny rule both match, the deny rule takes
|
||||||
|
precedence.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="file-connector-settings-section file-connector-policy-locks">
|
<div className="file-connector-settings-section file-connector-policy-locks">
|
||||||
<ToggleSwitch label="i18n:govoplan-files.lower_connection_limits.4f9838bc" checked={policyDraft.allowLowerConnectionLimits} disabled={saving || !canWrite} onChange={(allowLowerConnectionLimits) => patchPolicyDraft({ allowLowerConnectionLimits })} />
|
<ToggleSwitch label="i18n:govoplan-files.lower_connection_limits.4f9838bc" checked={policyDraft.allowLowerConnectionLimits} disabled={saving || !canWrite} onChange={(allowLowerConnectionLimits) => patchPolicyDraft({ allowLowerConnectionLimits })} />
|
||||||
<ToggleSwitch label="i18n:govoplan-files.lower_credential_limits.ec2b72bc" checked={policyDraft.allowLowerCredentialLimits} disabled={saving || !canWrite} onChange={(allowLowerCredentialLimits) => patchPolicyDraft({ allowLowerCredentialLimits })} />
|
<ToggleSwitch label="i18n:govoplan-files.lower_credential_limits.ec2b72bc" checked={policyDraft.allowLowerCredentialLimits} disabled={saving || !canWrite} onChange={(allowLowerCredentialLimits) => patchPolicyDraft({ allowLowerCredentialLimits })} />
|
||||||
@@ -1693,6 +1830,26 @@ function lines(value: string): string[] {
|
|||||||
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function policyReferenceConflicts(
|
||||||
|
draft: CentralConnectorPolicyDraft
|
||||||
|
): string[] {
|
||||||
|
return [
|
||||||
|
["connections", draft.allowedConnectors, draft.deniedConnectors],
|
||||||
|
["credentials", draft.allowedCredentials, draft.deniedCredentials],
|
||||||
|
["providers", draft.allowedProviders, draft.deniedProviders],
|
||||||
|
["paths", draft.allowedPaths, draft.deniedPaths],
|
||||||
|
["endpoint URLs", draft.allowedUrls, draft.deniedUrls]
|
||||||
|
].flatMap(([label, allowed, denied]) => {
|
||||||
|
const deniedValues = new Set(
|
||||||
|
lines(denied).map((value) => value.toLocaleLowerCase())
|
||||||
|
);
|
||||||
|
const overlaps = lines(allowed).filter((value) =>
|
||||||
|
deniedValues.has(value.toLocaleLowerCase())
|
||||||
|
);
|
||||||
|
return overlaps.length ? [`${label}: ${overlaps.join(", ")}`] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function policyRuleList(policy: Record<string, unknown>, kind: "allow" | "deny", field: "connectors" | "providers" | "credentials" | "external_paths" | "external_urls"): string[] {
|
function policyRuleList(policy: Record<string, unknown>, kind: "allow" | "deny", field: "connectors" | "providers" | "credentials" | "external_paths" | "external_urls"): string[] {
|
||||||
const rule = recordValue(policy[kind]) ??
|
const rule = recordValue(policy[kind]) ??
|
||||||
recordValue(policy[kind === "allow" ? "allowlist" : "denylist"]) ??
|
recordValue(policy[kind === "allow" ? "allowlist" : "denylist"]) ??
|
||||||
|
|||||||
98
webui/src/features/files/FileSpacesWidget.tsx
Normal file
98
webui/src/features/files/FileSpacesWidget.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { Folder, Network } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
DashboardWidgetList,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingFrame,
|
||||||
|
StatusBadge,
|
||||||
|
useDashboardWidgetData,
|
||||||
|
type ApiSettings,
|
||||||
|
type DashboardWidgetConfiguration
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
listFileSpaces,
|
||||||
|
type FileSpace
|
||||||
|
} from "../../api/files";
|
||||||
|
|
||||||
|
export default function FileSpacesWidget({
|
||||||
|
settings,
|
||||||
|
refreshKey,
|
||||||
|
configuration
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
refreshKey: number;
|
||||||
|
configuration: DashboardWidgetConfiguration;
|
||||||
|
}) {
|
||||||
|
const maxItems = numberSetting(configuration.maxItems, 6, 1, 16);
|
||||||
|
const includeConnectors = configuration.includeConnectors !== false;
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const response = await listFileSpaces(settings);
|
||||||
|
return response.spaces
|
||||||
|
.filter(
|
||||||
|
(space) =>
|
||||||
|
includeConnectors || (space.space_type ?? "managed") === "managed"
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label))
|
||||||
|
.slice(0, maxItems);
|
||||||
|
}, [includeConnectors, maxItems, settings]);
|
||||||
|
const { data: spaces, loading, error } = useDashboardWidgetData(
|
||||||
|
load,
|
||||||
|
refreshKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingFrame loading={loading} label="Loading file spaces">
|
||||||
|
{error && (
|
||||||
|
<DismissibleAlert tone="warning" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
<DashboardWidgetList
|
||||||
|
emptyText="No file spaces are available."
|
||||||
|
items={(spaces ?? []).map((space) => ({
|
||||||
|
id: space.id,
|
||||||
|
title: space.label,
|
||||||
|
detail: spaceDescription(space),
|
||||||
|
meta: space.owner_type === "group" ? "Group" : "Personal",
|
||||||
|
leading:
|
||||||
|
space.space_type === "connector" ? (
|
||||||
|
<Network size={17} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Folder size={17} aria-hidden="true" />
|
||||||
|
),
|
||||||
|
trailing: space.read_only ? (
|
||||||
|
<StatusBadge status="locked" label="Read only" />
|
||||||
|
) : undefined,
|
||||||
|
to: "/files"
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="dashboard-contribution-footer">
|
||||||
|
<Link className="btn btn-secondary" to="/files">
|
||||||
|
Open files
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spaceDescription(space: FileSpace): string {
|
||||||
|
if (space.space_type !== "connector") {
|
||||||
|
return space.description || "Managed storage";
|
||||||
|
}
|
||||||
|
return [space.provider, space.library_id, space.remote_path]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "Connected storage";
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberSetting(
|
||||||
|
value: unknown,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number
|
||||||
|
): number {
|
||||||
|
const numeric = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(numeric)
|
||||||
|
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import { hasScope, type AdminSectionsUiCapability, type FilesConnectorsUiCapability, type FilesFileExplorerUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
import {
|
||||||
|
hasScope,
|
||||||
|
type AdminSectionsUiCapability,
|
||||||
|
type DashboardWidgetsUiCapability,
|
||||||
|
type FilesConnectorsUiCapability,
|
||||||
|
type FilesFileExplorerUiCapability,
|
||||||
|
type PlatformWebModule
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
import { FolderTree } from "./features/files/components/FileManagerComponents";
|
import { FolderTree } from "./features/files/components/FileManagerComponents";
|
||||||
import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel";
|
import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel";
|
||||||
import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
|
import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
|
||||||
|
import FileSpacesWidget from "./features/files/FileSpacesWidget";
|
||||||
import { listFileSpaces, listFiles, listFilesDelta, listFolders, resolveFilePatterns, shareFilesWithTarget } from "./api/files";
|
import { listFileSpaces, listFiles, listFilesDelta, listFolders, resolveFilePatterns, shareFilesWithTarget } from "./api/files";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/file-manager.css";
|
import "./styles/file-manager.css";
|
||||||
@@ -14,6 +22,50 @@ const translations = {
|
|||||||
en: generatedTranslations.en,
|
en: generatedTranslations.en,
|
||||||
de: generatedTranslations.de
|
de: generatedTranslations.de
|
||||||
};
|
};
|
||||||
|
const fileDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
id: "files.spaces",
|
||||||
|
surfaceId: "files.widget.spaces",
|
||||||
|
title: "File spaces",
|
||||||
|
description: "Managed and connected file spaces available to you.",
|
||||||
|
moduleId: "files",
|
||||||
|
category: "Content",
|
||||||
|
order: 35,
|
||||||
|
defaultVisible: false,
|
||||||
|
defaultSize: "medium",
|
||||||
|
supportedSizes: ["medium", "wide"],
|
||||||
|
anyOf: fileRead,
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfiguration: {
|
||||||
|
maxItems: 6,
|
||||||
|
includeConnectors: true
|
||||||
|
},
|
||||||
|
configurationFields: [
|
||||||
|
{
|
||||||
|
id: "maxItems",
|
||||||
|
label: "Maximum spaces",
|
||||||
|
kind: "number",
|
||||||
|
min: 1,
|
||||||
|
max: 16,
|
||||||
|
step: 1,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "includeConnectors",
|
||||||
|
label: "Include connected storage",
|
||||||
|
kind: "boolean"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
render: ({ settings, refreshKey, configuration }) =>
|
||||||
|
createElement(FileSpacesWidget, {
|
||||||
|
settings,
|
||||||
|
refreshKey,
|
||||||
|
configuration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
const fileConnectorAdminSections: AdminSectionsUiCapability = {
|
const fileConnectorAdminSections: AdminSectionsUiCapability = {
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
@@ -56,7 +108,8 @@ export const filesModule: PlatformWebModule = {
|
|||||||
{ id: "files.admin.tenant-connectors", moduleId: "files", kind: "section", label: "Tenant file connections", order: 65 },
|
{ id: "files.admin.tenant-connectors", moduleId: "files", kind: "section", label: "Tenant file connections", order: 65 },
|
||||||
{ id: "files.admin.group-connectors", moduleId: "files", kind: "section", label: "Group file connections", order: 65 },
|
{ id: "files.admin.group-connectors", moduleId: "files", kind: "section", label: "Group file connections", order: 65 },
|
||||||
{ id: "files.admin.user-connectors", moduleId: "files", kind: "section", label: "User file connections", order: 65 },
|
{ id: "files.admin.user-connectors", moduleId: "files", kind: "section", label: "User file connections", order: 65 },
|
||||||
{ id: "files.settings.connectors", moduleId: "files", kind: "section", label: "Personal file connections", order: 20 }
|
{ id: "files.settings.connectors", moduleId: "files", kind: "section", label: "Personal file connections", order: 20 },
|
||||||
|
{ id: "files.widget.spaces", moduleId: "files", kind: "section", label: "File spaces widget", order: 35 }
|
||||||
],
|
],
|
||||||
navItems: [{ to: "/files", label: "i18n:govoplan-files.files.6ce6c512", iconName: "folder", anyOf: fileRead, order: 40 }],
|
navItems: [{ to: "/files", label: "i18n:govoplan-files.files.6ce6c512", iconName: "folder", anyOf: fileRead, order: 40 }],
|
||||||
routes: [
|
routes: [
|
||||||
@@ -76,7 +129,8 @@ export const filesModule: PlatformWebModule = {
|
|||||||
"files.connectors": {
|
"files.connectors": {
|
||||||
FileConnectorScopeManager: FileConnectorSettingsPanel
|
FileConnectorScopeManager: FileConnectorSettingsPanel
|
||||||
} satisfies FilesConnectorsUiCapability,
|
} satisfies FilesConnectorsUiCapability,
|
||||||
"admin.sections": fileConnectorAdminSections
|
"admin.sections": fileConnectorAdminSections,
|
||||||
|
"dashboard.widgets": fileDashboardWidgets
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user