Compare commits
7 Commits
2b34f6e305
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 85606d5580 | |||
| 632cc6cf7d | |||
| 86a905a3a7 | |||
| 5b868272b9 | |||
| 93eea839c8 | |||
| b667e7ff0e | |||
| 0fd8f6e02a |
@@ -735,14 +735,14 @@ returning different content or credentials.
|
|||||||
|
|
||||||
| Area | Implemented now | Planned or explicitly outside the current boundary |
|
| Area | Implemented now | Planned or explicitly outside the current boundary |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Managed storage | Local durable-root backend, fallback read roots, tenant blob deduplication, checksums | Operational S3 after pinned SDK transport ([#34](https://git.add-ideas.de/add-ideas/govoplan-files/issues/34)); automated integrity/orphan reconciliation ([#36](https://git.add-ideas.de/add-ideas/govoplan-files/issues/36)) |
|
| Managed storage | Local durable-root backend, fallback read roots, tenant blob deduplication, checksums | Operational S3 after pinned SDK transport ([#34](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/34)); automated integrity/orphan reconciliation ([#36](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/36)) |
|
||||||
| Upload | Bounded direct upload, drag-and-drop UI, ZIP spool/extract limits, explicit conflicts | Malware scanning, quotas, type policy, resumable/chunked upload |
|
| Upload | Bounded direct upload, drag-and-drop UI, ZIP spool/extract limits, explicit conflicts | Malware scanning, quotas, type policy, resumable/chunked upload |
|
||||||
| Organization | Folders, bulk rename preview/apply, move/copy, drag-and-drop, ZIP download, pattern resolution | General file-history UI and user-driven append-version/restore |
|
| Organization | Folders, bulk rename preview/apply, move/copy, drag-and-drop, ZIP download, pattern resolution | General file-history UI and user-driven append-version/restore |
|
||||||
| Sharing | User/group/tenant/campaign grant or permission update through API; campaign linkage display | Share revocation/expiry and complete general share-management UI ([#37](https://git.add-ideas.de/add-ideas/govoplan-files/issues/37)) |
|
| Sharing | User/group/tenant/campaign grant or permission update through API; campaign linkage display | Share revocation/expiry and complete general share-management UI ([#37](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/37)) |
|
||||||
| Deletion/retention | Soft-delete assets/folders/spaces; immediate audited connector-secret scrubbing | File restore API, hard purge, retention policy, legal hold, and blob GC ([#38](https://git.add-ideas.de/add-ideas/govoplan-files/issues/38)) |
|
| Deletion/retention | Soft-delete assets/folders/spaces; immediate audited connector-secret scrubbing | File restore API, hard purge, retention policy, legal hold, and blob GC ([#38](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/38)) |
|
||||||
| Connector governance | Scoped profiles/credentials/policies, effective source explanation, separate credentials, linked user/group spaces | Provider-owned external secret lifecycle; API `secret_ref` remains rejected |
|
| Connector governance | Scoped profiles/credentials/policies, effective source explanation, separate credentials, linked user/group spaces | Provider-owned external secret lifecycle; API `secret_ref` remains rejected |
|
||||||
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers |
|
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers |
|
||||||
| SMB and S3 connectors | Provider descriptors and browse/import logic | Live access only after SDK connections plus DFS referrals/redirects are validated and pinned ([SMB #35](https://git.add-ideas.de/add-ideas/govoplan-files/issues/35), [S3 #34](https://git.add-ideas.de/add-ideas/govoplan-files/issues/34)) |
|
| SMB and S3 connectors | Provider descriptors and browse/import logic | Live access only after SDK connections plus DFS referrals/redirects are validated and pinned ([SMB #35](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/35), [S3 #34](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/34)) |
|
||||||
| Other providers | Reserved SharePoint/OneDrive keys and NFS/local descriptors | Graph/OAuth/provider paging, NFS deployment integration, DMS connectors |
|
| Other providers | Reserved SharePoint/OneDrive keys and NFS/local descriptors | Graph/OAuth/provider paging, NFS deployment integration, DMS connectors |
|
||||||
| Connector spaces | User/group link, browse, manual selected-file sync, edit/disable/delete | Background sync, remote writes/deletes, full conflict-reporting jobs |
|
| Connector spaces | User/group link, browse, manual selected-file sync, edit/disable/delete | Background sync, remote writes/deletes, full conflict-reporting jobs |
|
||||||
| Profile capabilities | Stored and displayed | Enforce capability flags as an independent operation gate |
|
| Profile capabilities | Stored and displayed | Enforce capability flags as an independent operation gate |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from govoplan_core.core.modules import (
|
|||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
@@ -22,6 +23,7 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
||||||
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
|
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
|
||||||
@@ -138,6 +140,43 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary_batch(session, tenant_ids) -> dict[str, dict[str, int]]:
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileConnectorCredential, FileConnectorPolicy, FileConnectorProfile, FileConnectorSpace
|
||||||
|
|
||||||
|
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
counts: dict[str, dict[str, int]] = {
|
||||||
|
tenant_id: {
|
||||||
|
"files": 0,
|
||||||
|
"connector_credentials": 0,
|
||||||
|
"connector_policies": 0,
|
||||||
|
"connector_profiles": 0,
|
||||||
|
"connector_spaces": 0,
|
||||||
|
}
|
||||||
|
for tenant_id in ids
|
||||||
|
}
|
||||||
|
models = (
|
||||||
|
("files", FileAsset),
|
||||||
|
("connector_credentials", FileConnectorCredential),
|
||||||
|
("connector_policies", FileConnectorPolicy),
|
||||||
|
("connector_profiles", FileConnectorProfile),
|
||||||
|
("connector_spaces", FileConnectorSpace),
|
||||||
|
)
|
||||||
|
for count_key, model in models:
|
||||||
|
rows = (
|
||||||
|
session.query(model.tenant_id, func.count(model.id))
|
||||||
|
.filter(model.tenant_id.in_(ids))
|
||||||
|
.group_by(model.tenant_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for tenant_id, count in rows:
|
||||||
|
counts[tenant_id][count_key] = int(count)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def _veto_group_delete(session, tenant_id: str, group_id: str) -> None:
|
def _veto_group_delete(session, tenant_id: str, group_id: str) -> None:
|
||||||
from govoplan_files.backend.db.models import FileAsset, FileConnectorSpace, FileFolder, FileShare
|
from govoplan_files.backend.db.models import FileAsset, FileConnectorSpace, FileFolder, FileShare
|
||||||
|
|
||||||
@@ -187,12 +226,29 @@ manifest = ModuleManifest(
|
|||||||
route_factory=_files_router,
|
route_factory=_files_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
||||||
delete_veto_providers={"group": (_veto_group_delete,)},
|
delete_veto_providers={"group": (_veto_group_delete,)},
|
||||||
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="files",
|
module_id="files",
|
||||||
package_name="@govoplan/files-webui",
|
package_name="@govoplan/files-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/files",
|
||||||
|
component="FilesPage",
|
||||||
|
required_any=("files:file:read",),
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(id="files.admin.system-connectors", module_id="files", kind="section", label="System file connections", order=75),
|
||||||
|
ViewSurface(id="files.admin.tenant-connectors", module_id="files", kind="section", label="Tenant 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.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=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
|
|||||||
1315
src/govoplan_files/backend/route_support.py
Normal file
1315
src/govoplan_files/backend/route_support.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1
src/govoplan_files/backend/routes/__init__.py
Normal file
1
src/govoplan_files/backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Focused HTTP route modules for the Files API."""
|
||||||
134
src/govoplan_files/backend/routes/assets.py
Normal file
134
src/govoplan_files/backend/routes/assets.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
BulkDeleteRequest,
|
||||||
|
BulkDeleteResponse,
|
||||||
|
FileAssetResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.files import (
|
||||||
|
get_asset_for_user,
|
||||||
|
read_asset_bytes,
|
||||||
|
soft_delete_assets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_asset_response,
|
||||||
|
_attachment_disposition,
|
||||||
|
_audit_connector_event,
|
||||||
|
_http_error,
|
||||||
|
_is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_id}", response_model=FileAssetResponse)
|
||||||
|
def get_file(
|
||||||
|
file_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
asset = get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
return _asset_response(session, asset, include_shares=True)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_id}/download")
|
||||||
|
def download_file(
|
||||||
|
file_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:download")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
asset = get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
data, version, blob = read_asset_bytes(session, asset)
|
||||||
|
_audit_connector_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="files.connector.accessed",
|
||||||
|
asset=asset,
|
||||||
|
version=version,
|
||||||
|
blob=blob,
|
||||||
|
operation="download",
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
headers = {"Content-Disposition": _attachment_disposition(asset.filename)}
|
||||||
|
return StreamingResponse(
|
||||||
|
BytesIO(data),
|
||||||
|
media_type=blob.content_type or "application/octet-stream",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{file_id}", response_model=BulkDeleteResponse)
|
||||||
|
def delete_file(
|
||||||
|
file_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:delete")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
asset = get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
require_write=True,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
count = soft_delete_assets(session, [asset])
|
||||||
|
session.commit()
|
||||||
|
return BulkDeleteResponse(deleted_count=count)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-delete", response_model=BulkDeleteResponse)
|
||||||
|
def bulk_delete_files(
|
||||||
|
payload: BulkDeleteRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:delete")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
assets = [
|
||||||
|
get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
require_write=True,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
for file_id in payload.file_ids
|
||||||
|
]
|
||||||
|
count = soft_delete_assets(session, assets)
|
||||||
|
session.commit()
|
||||||
|
return BulkDeleteResponse(deleted_count=count)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
252
src/govoplan_files/backend/routes/connector_io.py
Normal file
252
src/govoplan_files/backend/routes/connector_io.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_any_scope, require_scope
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileConnectorBrowseItem,
|
||||||
|
FileConnectorBrowseResponse,
|
||||||
|
FileConnectorImportRequest,
|
||||||
|
FileConnectorSyncResponse,
|
||||||
|
FileUploadResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.paths import UnsafeFilePathError
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_browse import (
|
||||||
|
ConnectorBrowseError,
|
||||||
|
ConnectorBrowseUnsupported,
|
||||||
|
browse_connector_profile,
|
||||||
|
normalize_connector_browse_path,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_imports import (
|
||||||
|
ConnectorImportError,
|
||||||
|
ConnectorImportUnsupported,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_deployment import (
|
||||||
|
connector_effective_endpoint_url,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_policy import (
|
||||||
|
ConnectorAccessRequest,
|
||||||
|
ConnectorPolicyDenied,
|
||||||
|
connector_policy_decision,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.files import (
|
||||||
|
create_file_asset,
|
||||||
|
sync_file_asset_from_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_asset_response,
|
||||||
|
_audit_connector_imports,
|
||||||
|
_audit_connector_sync,
|
||||||
|
_connector_browse_next_token,
|
||||||
|
_connector_policy_error,
|
||||||
|
_download_connector_payload,
|
||||||
|
_ensure_campaign_file_access,
|
||||||
|
_http_error,
|
||||||
|
_is_admin,
|
||||||
|
_visible_connector_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/connectors/profiles/{profile_id}/import", response_model=FileUploadResponse
|
||||||
|
)
|
||||||
|
def import_connector_file(
|
||||||
|
profile_id: str,
|
||||||
|
payload: FileConnectorImportRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:upload")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if payload.campaign_id:
|
||||||
|
_ensure_campaign_file_access(session, principal, payload.campaign_id)
|
||||||
|
profile = _visible_connector_profile(
|
||||||
|
session, principal, profile_id, campaign_id=payload.campaign_id
|
||||||
|
)
|
||||||
|
_source_path, downloaded, metadata = _download_connector_payload(
|
||||||
|
profile, payload, operation="import"
|
||||||
|
)
|
||||||
|
target_owner = payload.owner_id or principal.user.id
|
||||||
|
stored = create_file_asset(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
owner_type=payload.owner_type,
|
||||||
|
owner_id=target_owner,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
filename=downloaded.filename,
|
||||||
|
data=downloaded.data,
|
||||||
|
folder=payload.target_folder,
|
||||||
|
display_path=payload.target_path,
|
||||||
|
content_type=downloaded.content_type,
|
||||||
|
metadata=metadata,
|
||||||
|
campaign_id=payload.campaign_id,
|
||||||
|
conflict_strategy=payload.conflict_strategy,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
_audit_connector_imports(session, principal, [stored.asset])
|
||||||
|
session.commit()
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except ConnectorImportUnsupported as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
except (
|
||||||
|
ConnectorImportError,
|
||||||
|
FileStorageError,
|
||||||
|
UnsafeFilePathError,
|
||||||
|
ValueError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return FileUploadResponse(
|
||||||
|
files=[_asset_response(session, stored.asset, include_shares=True)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/connectors/profiles/{profile_id}/sync", response_model=FileConnectorSyncResponse
|
||||||
|
)
|
||||||
|
def sync_connector_file(
|
||||||
|
profile_id: str,
|
||||||
|
payload: FileConnectorImportRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:upload")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if payload.campaign_id:
|
||||||
|
_ensure_campaign_file_access(session, principal, payload.campaign_id)
|
||||||
|
profile = _visible_connector_profile(
|
||||||
|
session, principal, profile_id, campaign_id=payload.campaign_id
|
||||||
|
)
|
||||||
|
_source_path, downloaded, metadata = _download_connector_payload(
|
||||||
|
profile, payload, operation="sync"
|
||||||
|
)
|
||||||
|
target_owner = payload.owner_id or principal.user.id
|
||||||
|
stored, sync_action, previous_version_id = sync_file_asset_from_source(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
owner_type=payload.owner_type,
|
||||||
|
owner_id=target_owner,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
filename=downloaded.filename,
|
||||||
|
data=downloaded.data,
|
||||||
|
folder=payload.target_folder,
|
||||||
|
display_path=payload.target_path,
|
||||||
|
content_type=downloaded.content_type,
|
||||||
|
metadata=metadata,
|
||||||
|
campaign_id=payload.campaign_id,
|
||||||
|
conflict_strategy=payload.conflict_strategy,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
_audit_connector_sync(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
stored.asset,
|
||||||
|
sync_action=sync_action,
|
||||||
|
previous_version_id=previous_version_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except ConnectorImportUnsupported as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
except (
|
||||||
|
ConnectorImportError,
|
||||||
|
FileStorageError,
|
||||||
|
UnsafeFilePathError,
|
||||||
|
ValueError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return FileConnectorSyncResponse(
|
||||||
|
file=_asset_response(session, stored.asset, include_shares=True),
|
||||||
|
action=sync_action,
|
||||||
|
previous_version_id=previous_version_id,
|
||||||
|
current_version_id=stored.version.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/connectors/profiles/{profile_id}/browse",
|
||||||
|
response_model=FileConnectorBrowseResponse,
|
||||||
|
)
|
||||||
|
def browse_connector_profile_items(
|
||||||
|
profile_id: str,
|
||||||
|
path: str | None = None,
|
||||||
|
library_id: str | None = None,
|
||||||
|
continuation_token: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:upload",
|
||||||
|
"files:file:download",
|
||||||
|
"files:file:admin",
|
||||||
|
"system:settings:read",
|
||||||
|
"admin:settings:read",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
profile = _visible_connector_profile(
|
||||||
|
session, principal, profile_id, campaign_id=campaign_id
|
||||||
|
)
|
||||||
|
browse_path = normalize_connector_browse_path(path)
|
||||||
|
decision = connector_policy_decision(
|
||||||
|
ConnectorAccessRequest(
|
||||||
|
connector_id=profile.id,
|
||||||
|
credential_id=profile.credential_profile_id,
|
||||||
|
provider=profile.provider,
|
||||||
|
external_path=browse_path,
|
||||||
|
external_url=connector_effective_endpoint_url(
|
||||||
|
provider=profile.provider,
|
||||||
|
endpoint_url=profile.endpoint_url,
|
||||||
|
metadata=profile.metadata,
|
||||||
|
),
|
||||||
|
operation="browse",
|
||||||
|
),
|
||||||
|
profile.policy_sources,
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
raise ConnectorPolicyDenied(decision)
|
||||||
|
items = browse_connector_profile(
|
||||||
|
profile,
|
||||||
|
path=browse_path,
|
||||||
|
library_id=library_id,
|
||||||
|
continuation_token=continuation_token,
|
||||||
|
)
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except ConnectorBrowseUnsupported as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
except (ConnectorBrowseError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return FileConnectorBrowseResponse(
|
||||||
|
profile_id=profile.id,
|
||||||
|
provider=profile.provider,
|
||||||
|
path=browse_path,
|
||||||
|
library_id=library_id,
|
||||||
|
next_continuation_token=_connector_browse_next_token(items),
|
||||||
|
has_more=any(bool(item.metadata.get("listing_truncated")) for item in items),
|
||||||
|
decision=decision.to_dict(),
|
||||||
|
items=[FileConnectorBrowseItem(**item.to_response()) for item in items],
|
||||||
|
)
|
||||||
261
src/govoplan_files/backend/routes/connector_profiles.py
Normal file
261
src/govoplan_files/backend/routes/connector_profiles.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||||
|
from govoplan_files.backend.change_tracking import (
|
||||||
|
FILES_CONNECTOR_PROFILES_COLLECTION,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileConnectorProfileResponse,
|
||||||
|
FileConnectorProfileUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_credential_deletion import (
|
||||||
|
delete_connector_profile_row,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_deployment import (
|
||||||
|
connector_effective_endpoint_url,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_profile_store import (
|
||||||
|
connector_profile_from_row,
|
||||||
|
get_connector_profile_row,
|
||||||
|
update_connector_profile_row,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_policy import (
|
||||||
|
ConnectorPolicyDenied,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
FILES_CONNECTOR_PROFILE_RESOURCE,
|
||||||
|
_can_read_disabled_connector_profiles,
|
||||||
|
_connector_policy_error,
|
||||||
|
_credential_row_for_profile,
|
||||||
|
_ensure_connector_configuration_allowed,
|
||||||
|
_ensure_connector_local_policy_allowed,
|
||||||
|
_http_error,
|
||||||
|
_record_connector_settings_change,
|
||||||
|
_require_connector_profile_write,
|
||||||
|
_visible_connector_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/connectors/profiles/{profile_id}", response_model=FileConnectorProfileResponse
|
||||||
|
)
|
||||||
|
def get_connector_profile(
|
||||||
|
profile_id: str,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:upload",
|
||||||
|
"files:file:download",
|
||||||
|
"files:file:admin",
|
||||||
|
"system:settings:read",
|
||||||
|
"admin:settings:read",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
profile = _visible_connector_profile(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
profile_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
include_disabled=include_disabled
|
||||||
|
and _can_read_disabled_connector_profiles(principal),
|
||||||
|
include_effective_policy=False,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return FileConnectorProfileResponse(**profile.to_response())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/connectors/profiles/{profile_id}", response_model=FileConnectorProfileResponse
|
||||||
|
)
|
||||||
|
def update_connector_profile(
|
||||||
|
profile_id: str,
|
||||||
|
payload: FileConnectorProfileUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
row = get_connector_profile_row(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
include_disabled=True,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
_require_connector_profile_write(principal, row.scope_type)
|
||||||
|
credentials = payload.credentials
|
||||||
|
try:
|
||||||
|
credential_profile_id = (
|
||||||
|
payload.credential_profile_id
|
||||||
|
if payload.credential_profile_id is not None
|
||||||
|
else row.credential_profile_id
|
||||||
|
)
|
||||||
|
provider = payload.provider if payload.provider is not None else row.provider
|
||||||
|
credential_row = _credential_row_for_profile(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
credential_profile_id=credential_profile_id,
|
||||||
|
provider=provider,
|
||||||
|
profile_id=row.id,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
include_disabled=True,
|
||||||
|
)
|
||||||
|
_ensure_connector_configuration_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
connector_id=row.id,
|
||||||
|
credential_id=credential_profile_id,
|
||||||
|
provider=provider,
|
||||||
|
endpoint_url=connector_effective_endpoint_url(
|
||||||
|
provider=provider,
|
||||||
|
endpoint_url=payload.endpoint_url
|
||||||
|
if payload.endpoint_url is not None
|
||||||
|
else row.endpoint_url,
|
||||||
|
metadata=payload.metadata
|
||||||
|
if payload.metadata is not None
|
||||||
|
else row.metadata_,
|
||||||
|
),
|
||||||
|
base_path=payload.base_path
|
||||||
|
if payload.base_path is not None
|
||||||
|
else row.base_path,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
operation="configure",
|
||||||
|
)
|
||||||
|
if payload.policy is not None:
|
||||||
|
_ensure_connector_local_policy_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
policy=payload.policy,
|
||||||
|
)
|
||||||
|
update_connector_profile_row(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
label=payload.label,
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=payload.endpoint_url,
|
||||||
|
base_path=payload.base_path,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
credential_profile_id=payload.credential_profile_id,
|
||||||
|
credential_mode=payload.credential_mode,
|
||||||
|
username=credentials.username if credentials else None,
|
||||||
|
password=credentials.password if credentials else None,
|
||||||
|
token=credentials.token if credentials else None,
|
||||||
|
password_env=credentials.password_env if credentials else None,
|
||||||
|
token_env=credentials.token_env if credentials else None,
|
||||||
|
secret_ref=credentials.secret_ref if credentials else None,
|
||||||
|
clear_password=payload.clear_password,
|
||||||
|
clear_token=payload.clear_token,
|
||||||
|
capabilities=payload.capabilities,
|
||||||
|
policy=payload.policy,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_PROFILES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_PROFILE_RESOURCE,
|
||||||
|
resource_id=row.id,
|
||||||
|
operation="updated",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"provider": row.provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return FileConnectorProfileResponse(
|
||||||
|
**connector_profile_from_row(
|
||||||
|
row, credential_row=credential_row
|
||||||
|
).to_response()
|
||||||
|
)
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/connectors/profiles/{profile_id}", response_model=FileConnectorProfileResponse
|
||||||
|
)
|
||||||
|
def deactivate_connector_profile(
|
||||||
|
profile_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
row = get_connector_profile_row(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
include_disabled=True,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
_require_connector_profile_write(principal, row.scope_type)
|
||||||
|
try:
|
||||||
|
changed = delete_connector_profile_row(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
deletion_reason="api_delete",
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key.id if principal.api_key else None,
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_PROFILES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_PROFILE_RESOURCE,
|
||||||
|
resource_id=row.id,
|
||||||
|
operation="deleted",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"provider": row.provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return FileConnectorProfileResponse(
|
||||||
|
**connector_profile_from_row(row).to_response()
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
852
src/govoplan_files/backend/routes/connector_settings.py
Normal file
852
src/govoplan_files/backend/routes/connector_settings.py
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||||
|
from govoplan_files.backend.change_tracking import (
|
||||||
|
FILES_CONNECTOR_CREDENTIALS_COLLECTION,
|
||||||
|
FILES_CONNECTOR_POLICIES_COLLECTION,
|
||||||
|
FILES_CONNECTOR_PROFILES_COLLECTION,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileConnectorCredentialCreateRequest,
|
||||||
|
FileConnectorCredentialResponse,
|
||||||
|
FileConnectorCredentialsResponse,
|
||||||
|
FileConnectorCredentialUpdateRequest,
|
||||||
|
FileConnectorDiscoveryRequest,
|
||||||
|
FileConnectorDiscoveryResponse,
|
||||||
|
FileConnectorSettingsDeltaResponse,
|
||||||
|
FileConnectorPolicyEvaluateRequest,
|
||||||
|
FileConnectorPolicyEvaluateResponse,
|
||||||
|
FileConnectorPolicyResponse,
|
||||||
|
FileConnectorPolicyUpdateRequest,
|
||||||
|
FileConnectorProfileCreateRequest,
|
||||||
|
FileConnectorProfileResponse,
|
||||||
|
FileConnectorProfilesResponse,
|
||||||
|
FileConnectorProviderResponse,
|
||||||
|
FileConnectorProvidersResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_credential_store import (
|
||||||
|
create_connector_credential_row,
|
||||||
|
get_connector_credential_row,
|
||||||
|
list_database_connector_credentials,
|
||||||
|
update_connector_credential_row,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_credential_deletion import (
|
||||||
|
delete_connector_credential_row,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_browse import (
|
||||||
|
ConnectorBrowseError,
|
||||||
|
ConnectorBrowseUnsupported,
|
||||||
|
browse_connector_profile,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_deployment import (
|
||||||
|
connector_effective_endpoint_url,
|
||||||
|
reject_api_controlled_deployment_references,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_profile_store import (
|
||||||
|
connector_profile_from_row,
|
||||||
|
create_connector_profile_row,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_providers import (
|
||||||
|
connector_provider_descriptors,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_policy import (
|
||||||
|
ConnectorAccessRequest,
|
||||||
|
ConnectorPolicyDenied,
|
||||||
|
connector_policy_decision,
|
||||||
|
connector_policy_sources_from_payload,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_policy_store import (
|
||||||
|
connector_policy_response,
|
||||||
|
set_connector_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
FILES_CONNECTOR_CREDENTIAL_RESOURCE,
|
||||||
|
FILES_CONNECTOR_POLICY_RESOURCE,
|
||||||
|
FILES_CONNECTOR_PROFILE_RESOURCE,
|
||||||
|
_audit_connector_discovery_attempt,
|
||||||
|
_can_read_disabled_connector_profiles,
|
||||||
|
_connector_credential_response,
|
||||||
|
_connector_policy_error,
|
||||||
|
_credential_row_for_profile,
|
||||||
|
_discovery_profile_from_payload,
|
||||||
|
_ensure_campaign_file_access,
|
||||||
|
_ensure_connector_configuration_allowed,
|
||||||
|
_ensure_connector_credential_configuration_allowed,
|
||||||
|
_ensure_connector_local_policy_allowed,
|
||||||
|
_file_connector_policy_resource_id,
|
||||||
|
_file_connector_settings_entries,
|
||||||
|
_http_error,
|
||||||
|
_record_connector_settings_change,
|
||||||
|
_require_connector_credential_write,
|
||||||
|
_require_connector_policy_read,
|
||||||
|
_require_connector_profile_write,
|
||||||
|
_same_endpoint,
|
||||||
|
_visible_connector_profiles,
|
||||||
|
_webdav_discovery_candidates,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.services.connector_settings_delta import (
|
||||||
|
_full_file_connector_settings_delta_response,
|
||||||
|
_incremental_file_connector_settings_delta_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/connector-policy/evaluate", response_model=FileConnectorPolicyEvaluateResponse
|
||||||
|
)
|
||||||
|
def evaluate_connector_policy(
|
||||||
|
payload: FileConnectorPolicyEvaluateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("files:file:read", "files:file:upload", "files:file:download")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
sources = connector_policy_sources_from_payload(
|
||||||
|
[item.model_dump(mode="json") for item in payload.policy_sources]
|
||||||
|
)
|
||||||
|
request = ConnectorAccessRequest.from_provenance(
|
||||||
|
payload.source_provenance.model_dump(mode="json", exclude_none=True),
|
||||||
|
operation=payload.operation,
|
||||||
|
)
|
||||||
|
return FileConnectorPolicyEvaluateResponse(
|
||||||
|
decision=connector_policy_decision(request, sources).to_dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/connectors/settings/delta", response_model=FileConnectorSettingsDeltaResponse
|
||||||
|
)
|
||||||
|
def connector_settings_delta(
|
||||||
|
scope_type: str = Query(default="tenant"),
|
||||||
|
scope_id: str | None = Query(default=None),
|
||||||
|
provider: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
owner_type: Literal["user", "group"] | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
since: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:read", "admin:settings:read"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
scope_type = scope_type.strip().casefold()
|
||||||
|
_require_connector_policy_read(principal, scope_type)
|
||||||
|
try:
|
||||||
|
if since is None:
|
||||||
|
return _full_file_connector_settings_delta_response(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
provider=provider,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
|
entries, has_more = _file_connector_settings_entries(
|
||||||
|
session, tenant_id=principal.tenant_id, since=since, limit=limit
|
||||||
|
)
|
||||||
|
if entries is None:
|
||||||
|
return _full_file_connector_settings_delta_response(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
provider=provider,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
|
return _incremental_file_connector_settings_delta_response(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
entries=entries,
|
||||||
|
has_more=has_more,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
provider=provider,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connectors/providers", response_model=FileConnectorProvidersResponse)
|
||||||
|
def list_connector_providers(
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("files:file:read", "files:file:upload", "files:file:admin")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
return FileConnectorProvidersResponse(
|
||||||
|
providers=[
|
||||||
|
FileConnectorProviderResponse(**item.to_response())
|
||||||
|
for item in connector_provider_descriptors()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connectors/discover", response_model=FileConnectorDiscoveryResponse)
|
||||||
|
def discover_connector_endpoint(
|
||||||
|
payload: FileConnectorDiscoveryRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
reject_api_controlled_deployment_references(
|
||||||
|
password_env=payload.credentials.password_env,
|
||||||
|
token_env=payload.credentials.token_env,
|
||||||
|
secret_ref=payload.credentials.secret_ref,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
if payload.provider not in {"webdav", "nextcloud"}:
|
||||||
|
return FileConnectorDiscoveryResponse(
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=None,
|
||||||
|
base_path=payload.base_path,
|
||||||
|
status="unsupported",
|
||||||
|
message=f"Discovery is not implemented for {payload.provider} connectors yet",
|
||||||
|
)
|
||||||
|
candidates: list[dict[str, str]] = []
|
||||||
|
for endpoint_url in _webdav_discovery_candidates(payload):
|
||||||
|
profile = _discovery_profile_from_payload(
|
||||||
|
payload, endpoint_url, principal=principal
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_ensure_connector_configuration_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
connector_id=None,
|
||||||
|
credential_id=None,
|
||||||
|
provider=profile.provider,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
|
base_path=profile.base_path,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=principal.tenant_id,
|
||||||
|
operation="discover",
|
||||||
|
)
|
||||||
|
_audit_connector_discovery_attempt(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
provider=profile.provider,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
|
base_path=profile.base_path,
|
||||||
|
)
|
||||||
|
browse_connector_profile(profile, path=payload.base_path or "")
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (
|
||||||
|
ConnectorBrowseError,
|
||||||
|
ConnectorBrowseUnsupported,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
) as exc:
|
||||||
|
message = str(exc)
|
||||||
|
if "credentials were rejected" in message.casefold():
|
||||||
|
if payload.require_valid_credentials:
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"endpoint_url": endpoint_url,
|
||||||
|
"status": "credentials_rejected",
|
||||||
|
"message": "The endpoint exists, but the credentials were rejected.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return FileConnectorDiscoveryResponse(
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
|
base_path=payload.base_path,
|
||||||
|
status="credentials_rejected",
|
||||||
|
message="The endpoint was found, but login failed with these credentials.",
|
||||||
|
candidates=candidates,
|
||||||
|
metadata={"discovered_by": "webdav-auth-challenge"},
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"endpoint_url": endpoint_url,
|
||||||
|
"status": "found",
|
||||||
|
"message": "The endpoint exists, but credentials are required or were rejected.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return FileConnectorDiscoveryResponse(
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
|
base_path=payload.base_path,
|
||||||
|
status="found",
|
||||||
|
message="The endpoint was found. Add working credentials before saving or testing the connection.",
|
||||||
|
candidates=candidates,
|
||||||
|
metadata={"discovered_by": "webdav-auth-challenge"},
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
{"endpoint_url": endpoint_url, "status": "failed", "message": message}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
status_value = (
|
||||||
|
"usable" if _same_endpoint(endpoint_url, payload.endpoint_url) else "found"
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
"The supplied URL is directly usable."
|
||||||
|
if status_value == "usable"
|
||||||
|
else "A usable connector endpoint was discovered."
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
{"endpoint_url": endpoint_url, "status": status_value, "message": message}
|
||||||
|
)
|
||||||
|
return FileConnectorDiscoveryResponse(
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
|
base_path=payload.base_path,
|
||||||
|
status=status_value,
|
||||||
|
message=message,
|
||||||
|
candidates=candidates,
|
||||||
|
metadata={"discovered_by": "webdav-propfind"},
|
||||||
|
)
|
||||||
|
return FileConnectorDiscoveryResponse(
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=None,
|
||||||
|
base_path=payload.base_path,
|
||||||
|
status="not_found",
|
||||||
|
message="No usable WebDAV endpoint was found for this server URL.",
|
||||||
|
candidates=candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connectors/credentials", response_model=FileConnectorCredentialsResponse)
|
||||||
|
def list_connector_credentials(
|
||||||
|
provider: str | None = None,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:read", "admin:settings:read"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
provider_norm = provider.strip().casefold() if provider else None
|
||||||
|
credentials = list_database_connector_credentials(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
include_disabled=include_disabled
|
||||||
|
and _can_read_disabled_connector_profiles(principal),
|
||||||
|
)
|
||||||
|
return FileConnectorCredentialsResponse(
|
||||||
|
credentials=[
|
||||||
|
FileConnectorCredentialResponse(**credential.to_response())
|
||||||
|
for credential in credentials
|
||||||
|
if provider_norm is None or credential.provider in {None, provider_norm}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/connectors/policies/{scope_type}", response_model=FileConnectorPolicyResponse
|
||||||
|
)
|
||||||
|
def read_connector_policy(
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:read", "admin:settings:read"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_require_connector_policy_read(principal, scope_type)
|
||||||
|
try:
|
||||||
|
return FileConnectorPolicyResponse(
|
||||||
|
**connector_policy_response(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/connectors/policies/{scope_type}", response_model=FileConnectorPolicyResponse
|
||||||
|
)
|
||||||
|
def write_connector_policy(
|
||||||
|
scope_type: str,
|
||||||
|
payload: FileConnectorPolicyUpdateRequest,
|
||||||
|
scope_id: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_require_connector_profile_write(principal, scope_type)
|
||||||
|
try:
|
||||||
|
set_connector_policy(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
policy=payload.policy,
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_POLICIES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_POLICY_RESOURCE,
|
||||||
|
resource_id=_file_connector_policy_resource_id(scope_type, scope_id),
|
||||||
|
operation="updated",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=None
|
||||||
|
if scope_type.strip().casefold() == "system"
|
||||||
|
else principal.tenant_id,
|
||||||
|
payload={"scope_type": scope_type.strip().casefold(), "scope_id": scope_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return FileConnectorPolicyResponse(
|
||||||
|
**connector_policy_response(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/connectors/credentials",
|
||||||
|
response_model=FileConnectorCredentialResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_connector_credential(
|
||||||
|
payload: FileConnectorCredentialCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_require_connector_credential_write(principal, payload.scope_type)
|
||||||
|
credentials = payload.credentials
|
||||||
|
try:
|
||||||
|
_ensure_connector_credential_configuration_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
credential_id=payload.id,
|
||||||
|
provider=payload.provider,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
operation="configure_credentials",
|
||||||
|
)
|
||||||
|
_ensure_connector_local_policy_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
policy=payload.policy,
|
||||||
|
)
|
||||||
|
row = create_connector_credential_row(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
credential_id=payload.id,
|
||||||
|
label=payload.label,
|
||||||
|
provider=payload.provider,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
credential_mode=payload.credential_mode,
|
||||||
|
username=credentials.username,
|
||||||
|
password=credentials.password,
|
||||||
|
token=credentials.token,
|
||||||
|
password_env=credentials.password_env,
|
||||||
|
token_env=credentials.token_env,
|
||||||
|
secret_ref=credentials.secret_ref,
|
||||||
|
policy=payload.policy,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_CREDENTIALS_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_CREDENTIAL_RESOURCE,
|
||||||
|
resource_id=row.id,
|
||||||
|
operation="created",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"provider": row.provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return _connector_credential_response(row)
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/connectors/credentials/{credential_id}",
|
||||||
|
response_model=FileConnectorCredentialResponse,
|
||||||
|
)
|
||||||
|
def get_connector_credential(
|
||||||
|
credential_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:read", "admin:settings:read"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
row = get_connector_credential_row(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
credential_id=credential_id,
|
||||||
|
include_disabled=include_disabled
|
||||||
|
and _can_read_disabled_connector_profiles(principal),
|
||||||
|
)
|
||||||
|
return _connector_credential_response(row)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/connectors/credentials/{credential_id}",
|
||||||
|
response_model=FileConnectorCredentialResponse,
|
||||||
|
)
|
||||||
|
def update_connector_credential(
|
||||||
|
credential_id: str,
|
||||||
|
payload: FileConnectorCredentialUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
row = get_connector_credential_row(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
credential_id=credential_id,
|
||||||
|
include_disabled=True,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
_require_connector_credential_write(principal, row.scope_type)
|
||||||
|
credentials = payload.credentials
|
||||||
|
try:
|
||||||
|
provider = payload.provider if payload.provider is not None else row.provider
|
||||||
|
_ensure_connector_credential_configuration_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
credential_id=row.id,
|
||||||
|
provider=provider,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
operation="configure_credentials",
|
||||||
|
)
|
||||||
|
if payload.policy is not None:
|
||||||
|
_ensure_connector_local_policy_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
policy=payload.policy,
|
||||||
|
)
|
||||||
|
update_connector_credential_row(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
label=payload.label,
|
||||||
|
provider=payload.provider,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
credential_mode=payload.credential_mode,
|
||||||
|
username=credentials.username if credentials else None,
|
||||||
|
password=credentials.password if credentials else None,
|
||||||
|
token=credentials.token if credentials else None,
|
||||||
|
password_env=credentials.password_env if credentials else None,
|
||||||
|
token_env=credentials.token_env if credentials else None,
|
||||||
|
secret_ref=credentials.secret_ref if credentials else None,
|
||||||
|
clear_password=payload.clear_password,
|
||||||
|
clear_token=payload.clear_token,
|
||||||
|
policy=payload.policy,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_CREDENTIALS_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_CREDENTIAL_RESOURCE,
|
||||||
|
resource_id=row.id,
|
||||||
|
operation="updated",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"provider": row.provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return _connector_credential_response(row)
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/connectors/credentials/{credential_id}",
|
||||||
|
response_model=FileConnectorCredentialResponse,
|
||||||
|
)
|
||||||
|
def deactivate_connector_credential(
|
||||||
|
credential_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
row = get_connector_credential_row(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
credential_id=credential_id,
|
||||||
|
include_disabled=True,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
_require_connector_credential_write(principal, row.scope_type)
|
||||||
|
try:
|
||||||
|
deletion = delete_connector_credential_row(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
deletion_reason="api_delete",
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key.id if principal.api_key else None,
|
||||||
|
)
|
||||||
|
if deletion.changed:
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_CREDENTIALS_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_CREDENTIAL_RESOURCE,
|
||||||
|
resource_id=row.id,
|
||||||
|
operation="deleted",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"provider": row.provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for profile in deletion.affected_profiles:
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_PROFILES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_PROFILE_RESOURCE,
|
||||||
|
resource_id=profile.id,
|
||||||
|
operation="updated",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"scope_id": profile.scope_id,
|
||||||
|
"provider": profile.provider,
|
||||||
|
"reason": "credential_deleted",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return _connector_credential_response(row)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connectors/profiles", response_model=FileConnectorProfilesResponse)
|
||||||
|
def list_connector_profiles(
|
||||||
|
provider: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:upload",
|
||||||
|
"files:file:download",
|
||||||
|
"files:file:admin",
|
||||||
|
"system:settings:read",
|
||||||
|
"admin:settings:read",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if campaign_id:
|
||||||
|
_ensure_campaign_file_access(session, principal, campaign_id)
|
||||||
|
profiles = _visible_connector_profiles(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
provider=provider,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
include_disabled=include_disabled
|
||||||
|
and _can_read_disabled_connector_profiles(principal),
|
||||||
|
include_admin_scopes=_can_read_disabled_connector_profiles(principal),
|
||||||
|
include_effective_policy=False,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return FileConnectorProfilesResponse(
|
||||||
|
profiles=[
|
||||||
|
FileConnectorProfileResponse(**profile.to_response())
|
||||||
|
for profile in profiles
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/connectors/profiles",
|
||||||
|
response_model=FileConnectorProfileResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_connector_profile(
|
||||||
|
payload: FileConnectorProfileCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"files:file:admin", "system:settings:write", "admin:settings:write"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_require_connector_profile_write(principal, payload.scope_type)
|
||||||
|
credentials = payload.credentials
|
||||||
|
try:
|
||||||
|
credential_row = _credential_row_for_profile(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
credential_profile_id=payload.credential_profile_id,
|
||||||
|
provider=payload.provider,
|
||||||
|
profile_id=payload.id,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
)
|
||||||
|
_ensure_connector_configuration_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
connector_id=payload.id,
|
||||||
|
credential_id=payload.credential_profile_id,
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=connector_effective_endpoint_url(
|
||||||
|
provider=payload.provider,
|
||||||
|
endpoint_url=payload.endpoint_url,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
),
|
||||||
|
base_path=payload.base_path,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
operation="configure",
|
||||||
|
)
|
||||||
|
_ensure_connector_local_policy_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
policy=payload.policy,
|
||||||
|
)
|
||||||
|
row = create_connector_profile_row(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
profile_id=payload.id,
|
||||||
|
label=payload.label,
|
||||||
|
provider=payload.provider,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
endpoint_url=payload.endpoint_url,
|
||||||
|
base_path=payload.base_path,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
credential_profile_id=payload.credential_profile_id,
|
||||||
|
credential_mode=payload.credential_mode,
|
||||||
|
username=credentials.username,
|
||||||
|
password=credentials.password,
|
||||||
|
token=credentials.token,
|
||||||
|
password_env=credentials.password_env,
|
||||||
|
token_env=credentials.token_env,
|
||||||
|
secret_ref=credentials.secret_ref,
|
||||||
|
capabilities=payload.capabilities,
|
||||||
|
policy=payload.policy,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_PROFILES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_PROFILE_RESOURCE,
|
||||||
|
resource_id=row.id,
|
||||||
|
operation="created",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"provider": row.provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return FileConnectorProfileResponse(
|
||||||
|
**connector_profile_from_row(
|
||||||
|
row, credential_row=credential_row
|
||||||
|
).to_response()
|
||||||
|
)
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
151
src/govoplan_files/backend/routes/folders.py
Normal file
151
src/govoplan_files/backend/routes/folders.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileFolderCreateRequest,
|
||||||
|
FileFolderDeleteRequest,
|
||||||
|
FileFolderDeleteResponse,
|
||||||
|
FileFolderResponse,
|
||||||
|
FileFoldersResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.paths import UnsafeFilePathError
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.folders import (
|
||||||
|
create_folder,
|
||||||
|
list_folders_for_user,
|
||||||
|
list_folders_for_user_window,
|
||||||
|
soft_delete_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_folder_response,
|
||||||
|
_http_error,
|
||||||
|
_is_admin,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.services.list_queries import (
|
||||||
|
FOLDERS_LIST_CURSOR_SCOPE,
|
||||||
|
_cursor_page_size,
|
||||||
|
_files_delta_watermark,
|
||||||
|
_folder_cursor_values,
|
||||||
|
_folders_list_fingerprint,
|
||||||
|
_next_folder_list_cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/folders", response_model=FileFoldersResponse)
|
||||||
|
def list_file_folders(
|
||||||
|
owner_type: Literal["user", "group"],
|
||||||
|
owner_id: str,
|
||||||
|
page_size: int | None = Query(default=None, ge=1, le=1000),
|
||||||
|
cursor: str | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
watermark = _files_delta_watermark(session, principal.tenant_id)
|
||||||
|
effective_page_size = _cursor_page_size(
|
||||||
|
FOLDERS_LIST_CURSOR_SCOPE, cursor, page_size
|
||||||
|
)
|
||||||
|
if effective_page_size is None:
|
||||||
|
folders = list_folders_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
return FileFoldersResponse(
|
||||||
|
folders=[_folder_response(folder) for folder in folders],
|
||||||
|
watermark=watermark,
|
||||||
|
)
|
||||||
|
fingerprint = _folders_list_fingerprint(
|
||||||
|
principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
page_size=effective_page_size,
|
||||||
|
)
|
||||||
|
after_path, after_id = _folder_cursor_values(cursor, fingerprint=fingerprint)
|
||||||
|
folders, has_more = list_folders_for_user_window(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
page_size=effective_page_size,
|
||||||
|
after_path=after_path,
|
||||||
|
after_id=after_id,
|
||||||
|
)
|
||||||
|
return FileFoldersResponse(
|
||||||
|
folders=[_folder_response(folder) for folder in folders],
|
||||||
|
cursor=cursor,
|
||||||
|
next_cursor=_next_folder_list_cursor(
|
||||||
|
principal,
|
||||||
|
folders,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
page_size=effective_page_size,
|
||||||
|
has_more=has_more,
|
||||||
|
),
|
||||||
|
watermark=watermark,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/folders", response_model=FileFolderResponse)
|
||||||
|
def create_file_folder(
|
||||||
|
payload: FileFolderCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:organize")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
folder = create_folder(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
owner_type=payload.owner_type,
|
||||||
|
owner_id=payload.owner_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
path=payload.path,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _folder_response(folder)
|
||||||
|
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/folders/delete", response_model=FileFolderDeleteResponse)
|
||||||
|
def delete_file_folder(
|
||||||
|
payload: FileFolderDeleteRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:delete")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
deleted_folders, deleted_files = soft_delete_folder(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
owner_type=payload.owner_type,
|
||||||
|
owner_id=payload.owner_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
path=payload.path,
|
||||||
|
recursive=payload.recursive,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return FileFolderDeleteResponse(
|
||||||
|
deleted_folders=deleted_folders, deleted_files=deleted_files
|
||||||
|
)
|
||||||
|
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
167
src/govoplan_files/backend/routes/listing.py
Normal file
167
src/govoplan_files/backend/routes/listing.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileDeltaResponse,
|
||||||
|
FileListResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.files import (
|
||||||
|
count_assets_for_user,
|
||||||
|
list_assets_for_user,
|
||||||
|
list_assets_for_user_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_asset_list_response,
|
||||||
|
_ensure_campaign_file_access,
|
||||||
|
_ensure_list_owner_access,
|
||||||
|
_is_admin,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.services.list_queries import (
|
||||||
|
FILES_LIST_CURSOR_SCOPE,
|
||||||
|
_cursor_page_size,
|
||||||
|
_file_cursor_values,
|
||||||
|
_files_delta_response,
|
||||||
|
_files_delta_watermark,
|
||||||
|
_files_list_fingerprint,
|
||||||
|
_full_file_delta_response,
|
||||||
|
_next_file_list_cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/delta", response_model=FileDeltaResponse)
|
||||||
|
def files_delta(
|
||||||
|
owner_type: Literal["user", "group"] | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
path_prefix: str | None = None,
|
||||||
|
since: str | None = None,
|
||||||
|
limit: int = Query(default=500, ge=1, le=1000),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||||
|
):
|
||||||
|
_ensure_list_owner_access(session, principal, owner_type, owner_id)
|
||||||
|
_ensure_campaign_file_access(session, principal, campaign_id)
|
||||||
|
if since is None:
|
||||||
|
return _full_file_delta_response(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
)
|
||||||
|
return _files_delta_response(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
since=since,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FileListResponse)
|
||||||
|
def list_files(
|
||||||
|
owner_type: Literal["user", "group"] | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: Literal["linked", "unlinked"] | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
|
page_size: int | None = Query(default=None, ge=1, le=1000),
|
||||||
|
cursor: str | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||||
|
):
|
||||||
|
_ensure_list_owner_access(session, principal, owner_type, owner_id)
|
||||||
|
_ensure_campaign_file_access(session, principal, campaign_id)
|
||||||
|
watermark = _files_delta_watermark(session, principal.tenant_id)
|
||||||
|
total = count_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
effective_page_size = _cursor_page_size(FILES_LIST_CURSOR_SCOPE, cursor, page_size)
|
||||||
|
if effective_page_size is not None:
|
||||||
|
fingerprint = _files_list_fingerprint(
|
||||||
|
principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
|
page_size=effective_page_size,
|
||||||
|
)
|
||||||
|
after_display_path, after_updated_at, after_id = _file_cursor_values(
|
||||||
|
cursor, fingerprint=fingerprint
|
||||||
|
)
|
||||||
|
assets, has_more = list_assets_for_user_window(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
page_size=effective_page_size,
|
||||||
|
after_display_path=after_display_path,
|
||||||
|
after_updated_at=after_updated_at,
|
||||||
|
after_id=after_id,
|
||||||
|
)
|
||||||
|
return FileListResponse(
|
||||||
|
files=_asset_list_response(session, assets, include_shares=True),
|
||||||
|
total=total,
|
||||||
|
cursor=cursor,
|
||||||
|
next_cursor=_next_file_list_cursor(
|
||||||
|
principal,
|
||||||
|
assets,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
|
page_size=effective_page_size,
|
||||||
|
has_more=has_more,
|
||||||
|
),
|
||||||
|
watermark=watermark,
|
||||||
|
)
|
||||||
|
assets = list_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
return FileListResponse(
|
||||||
|
files=_asset_list_response(session, assets, include_shares=True),
|
||||||
|
total=total,
|
||||||
|
watermark=watermark,
|
||||||
|
)
|
||||||
116
src/govoplan_files/backend/routes/shares.py
Normal file
116
src/govoplan_files/backend/routes/shares.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
BulkFileShareRequest,
|
||||||
|
BulkFileShareResponse,
|
||||||
|
FileShareRequest,
|
||||||
|
FileShareResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.files import (
|
||||||
|
get_asset_for_user,
|
||||||
|
share_file,
|
||||||
|
share_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_http_error,
|
||||||
|
_is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{file_id}/shares", response_model=FileShareResponse)
|
||||||
|
def create_share(
|
||||||
|
file_id: str,
|
||||||
|
payload: FileShareRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:share")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
asset = get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
require_write=True,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
share = share_file(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
asset=asset,
|
||||||
|
target_type=payload.target_type,
|
||||||
|
target_id=payload.target_id,
|
||||||
|
permission=payload.permission,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return FileShareResponse(
|
||||||
|
id=share.id,
|
||||||
|
target_type=share.target_type,
|
||||||
|
target_id=share.target_id,
|
||||||
|
permission=share.permission,
|
||||||
|
created_at=share.created_at.isoformat(),
|
||||||
|
revoked_at=share.revoked_at.isoformat() if share.revoked_at else None,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-shares", response_model=BulkFileShareResponse)
|
||||||
|
def create_bulk_shares(
|
||||||
|
payload: BulkFileShareRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:share")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
file_ids = list(dict.fromkeys(payload.file_ids))
|
||||||
|
assets = [
|
||||||
|
get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
require_write=True,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
for file_id in file_ids
|
||||||
|
]
|
||||||
|
shares = share_files(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
assets=assets,
|
||||||
|
target_type=payload.target_type,
|
||||||
|
target_id=payload.target_id,
|
||||||
|
permission=payload.permission,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return BulkFileShareResponse(
|
||||||
|
shared_count=len(shares),
|
||||||
|
shares=[
|
||||||
|
FileShareResponse(
|
||||||
|
id=share.id,
|
||||||
|
target_type=share.target_type,
|
||||||
|
target_id=share.target_id,
|
||||||
|
permission=share.permission,
|
||||||
|
created_at=share.created_at.isoformat(),
|
||||||
|
revoked_at=share.revoked_at.isoformat()
|
||||||
|
if share.revoked_at
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for share in shares
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
292
src/govoplan_files/backend/routes/spaces.py
Normal file
292
src/govoplan_files/backend/routes/spaces.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_files.backend.change_tracking import (
|
||||||
|
FILES_CONNECTOR_SPACES_COLLECTION,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileConnectorSpaceCreateRequest,
|
||||||
|
FileConnectorSpaceResponse,
|
||||||
|
FileConnectorSpacesResponse,
|
||||||
|
FileConnectorSpaceUpdateRequest,
|
||||||
|
FileSpaceResponse,
|
||||||
|
FileSpacesResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.access import group_refs_for_ids, user_group_ids
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_spaces import (
|
||||||
|
connector_space_owner_id,
|
||||||
|
create_connector_space,
|
||||||
|
get_connector_space_for_user,
|
||||||
|
list_connector_spaces_for_user,
|
||||||
|
soft_delete_connector_space,
|
||||||
|
update_connector_space,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_policy import (
|
||||||
|
ConnectorPolicyDenied,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
FILES_CONNECTOR_SPACE_RESOURCE,
|
||||||
|
_connector_policy_error,
|
||||||
|
_connector_space_file_space_response,
|
||||||
|
_connector_space_policy_decision,
|
||||||
|
_connector_space_response,
|
||||||
|
_http_error,
|
||||||
|
_is_admin,
|
||||||
|
_record_connector_settings_change,
|
||||||
|
_visible_connector_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/spaces", response_model=FileSpacesResponse)
|
||||||
|
def list_file_spaces(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||||
|
):
|
||||||
|
spaces = [
|
||||||
|
FileSpaceResponse(
|
||||||
|
id=f"user:{principal.user.id}",
|
||||||
|
label="My files",
|
||||||
|
owner_type="user",
|
||||||
|
owner_id=principal.user.id,
|
||||||
|
description="Files owned by your user account.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
group_ids = user_group_ids(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
include_admin_groups=_is_admin(principal),
|
||||||
|
)
|
||||||
|
if group_ids:
|
||||||
|
groups = group_refs_for_ids(tenant_id=principal.tenant_id, group_ids=group_ids)
|
||||||
|
spaces.extend(
|
||||||
|
FileSpaceResponse(
|
||||||
|
id=f"group:{group.id}",
|
||||||
|
label=f"{group.name} files",
|
||||||
|
owner_type="group",
|
||||||
|
owner_id=group.id,
|
||||||
|
description="Files owned by this group.",
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
)
|
||||||
|
connector_spaces = list_connector_spaces_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
spaces.extend(
|
||||||
|
_connector_space_file_space_response(space) for space in connector_spaces
|
||||||
|
)
|
||||||
|
return FileSpacesResponse(spaces=spaces)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connector-spaces", response_model=FileConnectorSpacesResponse)
|
||||||
|
def list_file_connector_spaces(
|
||||||
|
owner_type: Literal["user", "group"] | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
spaces = list_connector_spaces_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
include_inactive=include_inactive and _is_admin(principal),
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
return FileConnectorSpacesResponse(
|
||||||
|
spaces=[_connector_space_response(space) for space in spaces]
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/connector-spaces",
|
||||||
|
response_model=FileConnectorSpaceResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_file_connector_space(
|
||||||
|
payload: FileConnectorSpaceCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:organize")),
|
||||||
|
):
|
||||||
|
if payload.owner_type == "group" and not payload.owner_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="owner_id is required for group connector spaces",
|
||||||
|
)
|
||||||
|
target_owner = payload.owner_id or principal.user.id
|
||||||
|
try:
|
||||||
|
profile = _visible_connector_profile(
|
||||||
|
session, principal, payload.connector_profile_id
|
||||||
|
)
|
||||||
|
decision = _connector_space_policy_decision(
|
||||||
|
profile,
|
||||||
|
library_id=payload.library_id,
|
||||||
|
remote_path=payload.remote_path,
|
||||||
|
operation="link",
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
raise ConnectorPolicyDenied(decision)
|
||||||
|
space = create_connector_space(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
owner_type=payload.owner_type,
|
||||||
|
owner_id=target_owner,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
label=payload.label,
|
||||||
|
profile=profile,
|
||||||
|
library_id=payload.library_id,
|
||||||
|
remote_path=payload.remote_path,
|
||||||
|
sync_mode=payload.sync_mode,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_SPACES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_SPACE_RESOURCE,
|
||||||
|
resource_id=space.id,
|
||||||
|
operation="created",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=space.tenant_id,
|
||||||
|
payload={
|
||||||
|
"owner_type": space.owner_type,
|
||||||
|
"owner_id": connector_space_owner_id(space),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _connector_space_response(space)
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/connector-spaces/{space_id}", response_model=FileConnectorSpaceResponse)
|
||||||
|
def update_file_connector_space(
|
||||||
|
space_id: str,
|
||||||
|
payload: FileConnectorSpaceUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:organize")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
space = get_connector_space_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
space_id=space_id,
|
||||||
|
include_inactive=_is_admin(principal),
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
try:
|
||||||
|
if payload.library_id is not None or payload.remote_path is not None:
|
||||||
|
profile = _visible_connector_profile(
|
||||||
|
session, principal, space.connector_profile_id
|
||||||
|
)
|
||||||
|
decision = _connector_space_policy_decision(
|
||||||
|
profile,
|
||||||
|
library_id=payload.library_id
|
||||||
|
if payload.library_id is not None
|
||||||
|
else space.library_id,
|
||||||
|
remote_path=payload.remote_path
|
||||||
|
if payload.remote_path is not None
|
||||||
|
else space.remote_path,
|
||||||
|
operation="link",
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
raise ConnectorPolicyDenied(decision)
|
||||||
|
update_connector_space(
|
||||||
|
session,
|
||||||
|
space,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
label=payload.label,
|
||||||
|
library_id=payload.library_id,
|
||||||
|
remote_path=payload.remote_path,
|
||||||
|
sync_mode=payload.sync_mode,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_SPACES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_SPACE_RESOURCE,
|
||||||
|
resource_id=space.id,
|
||||||
|
operation="updated",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=space.tenant_id,
|
||||||
|
payload={
|
||||||
|
"owner_type": space.owner_type,
|
||||||
|
"owner_id": connector_space_owner_id(space),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _connector_space_response(space)
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/connector-spaces/{space_id}", response_model=FileConnectorSpaceResponse
|
||||||
|
)
|
||||||
|
def delete_file_connector_space(
|
||||||
|
space_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:organize")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
space = get_connector_space_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
space_id=space_id,
|
||||||
|
include_inactive=True,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
soft_delete_connector_space(
|
||||||
|
session, space, user_id=principal.user.id, is_admin=_is_admin(principal)
|
||||||
|
)
|
||||||
|
_record_connector_settings_change(
|
||||||
|
session,
|
||||||
|
collection=FILES_CONNECTOR_SPACES_COLLECTION,
|
||||||
|
resource_type=FILES_CONNECTOR_SPACE_RESOURCE,
|
||||||
|
resource_id=space.id,
|
||||||
|
operation="deleted",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=space.tenant_id,
|
||||||
|
payload={
|
||||||
|
"owner_type": space.owner_type,
|
||||||
|
"owner_id": connector_space_owner_id(space),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _connector_space_response(space)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
213
src/govoplan_files/backend/routes/transfers.py
Normal file
213
src/govoplan_files/backend/routes/transfers.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
ArchiveRequest,
|
||||||
|
PatternMatchResponse,
|
||||||
|
PatternResolveRequest,
|
||||||
|
PatternResolveResponse,
|
||||||
|
RenamePreviewItem,
|
||||||
|
RenameRequest,
|
||||||
|
RenameResponse,
|
||||||
|
TransferRequest,
|
||||||
|
TransferResponse,
|
||||||
|
_conflict_resolutions,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.storage.paths import (
|
||||||
|
UnsafeFilePathError,
|
||||||
|
filename_from_path,
|
||||||
|
normalize_logical_path,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.archives import create_zip_file
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.files import (
|
||||||
|
get_asset_for_user,
|
||||||
|
list_assets_for_user,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.search import resolve_patterns
|
||||||
|
from govoplan_files.backend.storage.transfers import (
|
||||||
|
rename_selection,
|
||||||
|
transfer_selection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_asset_response,
|
||||||
|
_attachment_disposition,
|
||||||
|
_audit_connector_access,
|
||||||
|
_cleanup_temp_file,
|
||||||
|
_ensure_campaign_file_access,
|
||||||
|
_ensure_list_owner_access,
|
||||||
|
_http_error,
|
||||||
|
_is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-rename", response_model=RenameResponse)
|
||||||
|
def bulk_rename(
|
||||||
|
payload: RenameRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:organize")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
plan = rename_selection(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
file_ids=payload.file_ids,
|
||||||
|
folder_paths=payload.folder_paths,
|
||||||
|
owner_type=payload.owner_type,
|
||||||
|
owner_id=payload.owner_id,
|
||||||
|
mode=payload.mode,
|
||||||
|
new_name=payload.new_name,
|
||||||
|
find=payload.find,
|
||||||
|
replacement=payload.replacement,
|
||||||
|
prefix=payload.prefix,
|
||||||
|
suffix=payload.suffix,
|
||||||
|
recursive=payload.recursive,
|
||||||
|
dry_run=payload.dry_run,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
if not payload.dry_run:
|
||||||
|
session.commit()
|
||||||
|
return RenameResponse(
|
||||||
|
dry_run=payload.dry_run,
|
||||||
|
items=[
|
||||||
|
RenamePreviewItem(
|
||||||
|
kind=item.kind,
|
||||||
|
id=item.id,
|
||||||
|
file_id=item.id if item.kind == "file" else None,
|
||||||
|
folder_path=item.old_path if item.kind == "folder" else None,
|
||||||
|
old_path=item.old_path,
|
||||||
|
new_path=item.new_path,
|
||||||
|
)
|
||||||
|
for item in plan
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transfer", response_model=TransferResponse)
|
||||||
|
def transfer_files(
|
||||||
|
payload: TransferRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:organize")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
files, folders = transfer_selection(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
operation=payload.operation,
|
||||||
|
file_ids=payload.file_ids,
|
||||||
|
folder_paths=payload.folder_paths,
|
||||||
|
source_owner_type=payload.source_owner_type,
|
||||||
|
source_owner_id=payload.source_owner_id,
|
||||||
|
target_owner_type=payload.target_owner_type,
|
||||||
|
target_owner_id=payload.target_owner_id,
|
||||||
|
target_folder=payload.target_folder,
|
||||||
|
conflict_strategy=payload.conflict_strategy,
|
||||||
|
conflict_resolutions=_conflict_resolutions(payload.conflict_resolutions),
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return TransferResponse(
|
||||||
|
operation=payload.operation, files=files, folders=folders
|
||||||
|
)
|
||||||
|
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/archive.zip")
|
||||||
|
def download_archive(
|
||||||
|
payload: ArchiveRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:download")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
assets = [
|
||||||
|
get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
for file_id in payload.file_ids
|
||||||
|
]
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
prefix="govoplan-files-", suffix=".zip", delete=False
|
||||||
|
)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
tmp.close()
|
||||||
|
try:
|
||||||
|
create_zip_file(session, assets, tmp_path)
|
||||||
|
except Exception:
|
||||||
|
_cleanup_temp_file(tmp_path)
|
||||||
|
raise
|
||||||
|
_audit_connector_access(session, principal, assets, operation="archive")
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
filename = filename_from_path(
|
||||||
|
normalize_logical_path(payload.filename, fallback_filename="files.zip")
|
||||||
|
)
|
||||||
|
headers = {"Content-Disposition": _attachment_disposition(filename)}
|
||||||
|
return FileResponse(
|
||||||
|
tmp_path,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers=headers,
|
||||||
|
background=BackgroundTask(_cleanup_temp_file, tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resolve-patterns", response_model=PatternResolveResponse)
|
||||||
|
def resolve_file_patterns(
|
||||||
|
payload: PatternResolveRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||||
|
):
|
||||||
|
_ensure_list_owner_access(session, principal, payload.owner_type, payload.owner_id)
|
||||||
|
_ensure_campaign_file_access(session, principal, payload.campaign_id)
|
||||||
|
try:
|
||||||
|
assets = list_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=payload.owner_type,
|
||||||
|
owner_id=payload.owner_id,
|
||||||
|
campaign_id=payload.campaign_id,
|
||||||
|
path_prefix=payload.path_prefix,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
resolved, unmatched = resolve_patterns(
|
||||||
|
assets,
|
||||||
|
payload.patterns,
|
||||||
|
base_path=payload.path_prefix,
|
||||||
|
case_sensitive=payload.case_sensitive,
|
||||||
|
)
|
||||||
|
return PatternResolveResponse(
|
||||||
|
patterns=[
|
||||||
|
PatternMatchResponse(
|
||||||
|
pattern=item.pattern,
|
||||||
|
matches=[_asset_response(session, asset) for asset in item.matches],
|
||||||
|
)
|
||||||
|
for item in resolved
|
||||||
|
],
|
||||||
|
unmatched=[_asset_response(session, asset) for asset in unmatched]
|
||||||
|
if payload.include_unmatched
|
||||||
|
else [],
|
||||||
|
)
|
||||||
|
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
207
src/govoplan_files/backend/routes/uploads.py
Normal file
207
src/govoplan_files/backend/routes/uploads.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, UploadFile
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
ConflictResolutionRequest,
|
||||||
|
FileUploadResponse,
|
||||||
|
_conflict_resolutions,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.db.models import FileAsset
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_files.backend.runtime import settings
|
||||||
|
from govoplan_files.backend.storage.paths import UnsafeFilePathError
|
||||||
|
from govoplan_files.backend.storage.archives import extract_zip_upload
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_policy import (
|
||||||
|
ConnectorPolicyDenied,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.files import (
|
||||||
|
create_file_asset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_asset_response,
|
||||||
|
_audit_connector_imports,
|
||||||
|
_cleanup_temp_file,
|
||||||
|
_connector_policy_error,
|
||||||
|
_enforce_connector_policy,
|
||||||
|
_http_error,
|
||||||
|
_is_admin,
|
||||||
|
_read_limited_upload,
|
||||||
|
_source_metadata_from_form,
|
||||||
|
_spool_limited_upload_to_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=FileUploadResponse)
|
||||||
|
def upload_files(
|
||||||
|
files: list[UploadFile] = FastAPIFile(...),
|
||||||
|
owner_type: Literal["user", "group"] = Form(default="user"),
|
||||||
|
owner_id: str | None = Form(default=None),
|
||||||
|
path: str = Form(default=""),
|
||||||
|
campaign_id: str | None = Form(default=None),
|
||||||
|
unpack_zip: bool = Form(default=False),
|
||||||
|
conflict_strategy: Literal["reject", "overwrite", "rename"] = Form(
|
||||||
|
default="reject"
|
||||||
|
),
|
||||||
|
conflict_resolutions_json: str | None = Form(default=None),
|
||||||
|
source_provenance_json: str | None = Form(default=None),
|
||||||
|
source_revision: str | None = Form(default=None),
|
||||||
|
connector_policy_json: str | None = Form(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:upload")),
|
||||||
|
):
|
||||||
|
target_owner = owner_id or principal.user.id
|
||||||
|
uploaded_assets: list[FileAsset] = []
|
||||||
|
try:
|
||||||
|
raw_resolutions = (
|
||||||
|
json.loads(conflict_resolutions_json) if conflict_resolutions_json else []
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
for upload in files:
|
||||||
|
filename = upload.filename or "file"
|
||||||
|
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 = _spool_limited_upload_to_temp(
|
||||||
|
upload, max_bytes=upload_limit, suffix=".zip"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
extracted = extract_zip_upload(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=target_owner,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
zip_data=zip_path,
|
||||||
|
folder=path,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
conflict_strategy=conflict_strategy,
|
||||||
|
conflict_resolutions=upload_resolutions,
|
||||||
|
metadata=metadata,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
max_file_bytes=settings.file_upload_max_bytes,
|
||||||
|
max_total_bytes=settings.file_upload_zip_max_bytes,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup_temp_file(zip_path)
|
||||||
|
uploaded_assets.extend(item.asset for item in extracted)
|
||||||
|
continue
|
||||||
|
data = _read_limited_upload(upload, max_bytes=upload_limit)
|
||||||
|
stored = create_file_asset(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=target_owner,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
filename=filename,
|
||||||
|
data=data,
|
||||||
|
folder=path,
|
||||||
|
content_type=content_type,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
conflict_strategy=conflict_strategy,
|
||||||
|
conflict_resolutions=upload_resolutions,
|
||||||
|
metadata=metadata,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
uploaded_assets.append(stored.asset)
|
||||||
|
_audit_connector_imports(session, principal, uploaded_assets)
|
||||||
|
session.commit()
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return FileUploadResponse(
|
||||||
|
files=[
|
||||||
|
_asset_response(session, asset, include_shares=True)
|
||||||
|
for asset in uploaded_assets
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-zip", response_model=FileUploadResponse)
|
||||||
|
def upload_zip(
|
||||||
|
file: UploadFile = FastAPIFile(...),
|
||||||
|
owner_type: Literal["user", "group"] = Form(default="user"),
|
||||||
|
owner_id: str | None = Form(default=None),
|
||||||
|
path: str = Form(default=""),
|
||||||
|
campaign_id: str | None = Form(default=None),
|
||||||
|
conflict_strategy: Literal["reject", "overwrite", "rename"] = Form(
|
||||||
|
default="reject"
|
||||||
|
),
|
||||||
|
conflict_resolutions_json: str | None = Form(default=None),
|
||||||
|
source_provenance_json: str | None = Form(default=None),
|
||||||
|
source_revision: str | None = Form(default=None),
|
||||||
|
connector_policy_json: str | None = Form(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:upload")),
|
||||||
|
):
|
||||||
|
target_owner = owner_id or principal.user.id
|
||||||
|
zip_path: str | None = None
|
||||||
|
try:
|
||||||
|
raw_resolutions = (
|
||||||
|
json.loads(conflict_resolutions_json) if conflict_resolutions_json else []
|
||||||
|
)
|
||||||
|
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 = _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,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=target_owner,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
zip_data=zip_path,
|
||||||
|
folder=path,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
conflict_strategy=conflict_strategy,
|
||||||
|
conflict_resolutions=upload_resolutions,
|
||||||
|
metadata=metadata,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
max_file_bytes=settings.file_upload_max_bytes,
|
||||||
|
max_total_bytes=settings.file_upload_zip_max_bytes,
|
||||||
|
)
|
||||||
|
_audit_connector_imports(session, principal, [item.asset for item in extracted])
|
||||||
|
session.commit()
|
||||||
|
except ConnectorPolicyDenied as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _connector_policy_error(exc) from exc
|
||||||
|
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
finally:
|
||||||
|
if zip_path:
|
||||||
|
_cleanup_temp_file(zip_path)
|
||||||
|
return FileUploadResponse(
|
||||||
|
files=[
|
||||||
|
_asset_response(session, item.asset, include_shares=True)
|
||||||
|
for item in extracted
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -154,6 +154,7 @@ class FileFolderDeleteResponse(BaseModel):
|
|||||||
|
|
||||||
class FileListResponse(BaseModel):
|
class FileListResponse(BaseModel):
|
||||||
files: list[FileAssetResponse]
|
files: list[FileAssetResponse]
|
||||||
|
total: int
|
||||||
cursor: str | None = None
|
cursor: str | None = None
|
||||||
next_cursor: str | None = None
|
next_cursor: str | None = None
|
||||||
watermark: str | None = None
|
watermark: str | None = None
|
||||||
|
|||||||
1
src/govoplan_files/backend/services/__init__.py
Normal file
1
src/govoplan_files/backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Query and response assembly services used by Files routes."""
|
||||||
292
src/govoplan_files/backend/services/connector_settings_delta.py
Normal file
292
src/govoplan_files/backend/services/connector_settings_delta.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.core.change_sequence import (
|
||||||
|
ChangeSequenceEntry,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.change_tracking import (
|
||||||
|
FILES_CONNECTOR_CREDENTIALS_COLLECTION,
|
||||||
|
FILES_CONNECTOR_POLICIES_COLLECTION,
|
||||||
|
FILES_CONNECTOR_PROFILES_COLLECTION,
|
||||||
|
FILES_CONNECTOR_SPACES_COLLECTION,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileConnectorCredentialResponse,
|
||||||
|
FileConnectorSettingsDeltaResponse,
|
||||||
|
FileConnectorPolicyResponse,
|
||||||
|
FileConnectorProfileResponse,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.db.models import FileConnectorSpace
|
||||||
|
from govoplan_files.backend.storage.connector_credential_store import (
|
||||||
|
ConnectorCredential,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||||
|
from govoplan_files.backend.storage.connector_policy_store import (
|
||||||
|
connector_policy_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
FILES_CONNECTOR_CREDENTIAL_RESOURCE,
|
||||||
|
FILES_CONNECTOR_PROFILE_RESOURCE,
|
||||||
|
FILES_CONNECTOR_SPACE_RESOURCE,
|
||||||
|
_can_read_disabled_connector_profiles,
|
||||||
|
_connector_deleted_item,
|
||||||
|
_connector_space_response,
|
||||||
|
_ensure_campaign_file_access,
|
||||||
|
_file_connector_settings_response_watermark,
|
||||||
|
_file_connector_settings_watermark,
|
||||||
|
_visible_connector_credentials,
|
||||||
|
_visible_connector_profiles,
|
||||||
|
_visible_connector_spaces,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_file_connector_settings_delta_response(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
provider: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
include_disabled: bool,
|
||||||
|
include_inactive: bool,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
) -> FileConnectorSettingsDeltaResponse:
|
||||||
|
if campaign_id:
|
||||||
|
_ensure_campaign_file_access(session, principal, campaign_id)
|
||||||
|
profiles = _visible_connector_profiles(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
provider=provider,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
include_disabled=include_disabled
|
||||||
|
and _can_read_disabled_connector_profiles(principal),
|
||||||
|
include_admin_scopes=_can_read_disabled_connector_profiles(principal),
|
||||||
|
include_effective_policy=False,
|
||||||
|
)
|
||||||
|
credentials = _visible_connector_credentials(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
provider=provider,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
)
|
||||||
|
spaces = _visible_connector_spaces(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
|
return FileConnectorSettingsDeltaResponse(
|
||||||
|
profiles=[
|
||||||
|
FileConnectorProfileResponse(**profile.to_response())
|
||||||
|
for profile in profiles
|
||||||
|
],
|
||||||
|
credentials=[
|
||||||
|
FileConnectorCredentialResponse(**credential.to_response())
|
||||||
|
for credential in credentials
|
||||||
|
],
|
||||||
|
spaces=[_connector_space_response(space) for space in spaces],
|
||||||
|
policy=FileConnectorPolicyResponse(
|
||||||
|
**connector_policy_response(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
changed_sections=["profiles", "credentials", "spaces", "policy"],
|
||||||
|
deleted=[],
|
||||||
|
watermark=_file_connector_settings_watermark(
|
||||||
|
session, tenant_id=principal.tenant_id
|
||||||
|
),
|
||||||
|
has_more=False,
|
||||||
|
full=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_file_connector_setting_ids(
|
||||||
|
entries: list[ChangeSequenceEntry],
|
||||||
|
) -> tuple[set[str], set[str], set[str], bool]:
|
||||||
|
changed_profile_ids = {
|
||||||
|
entry.resource_id
|
||||||
|
for entry in entries
|
||||||
|
if entry.collection == FILES_CONNECTOR_PROFILES_COLLECTION
|
||||||
|
and entry.resource_type == FILES_CONNECTOR_PROFILE_RESOURCE
|
||||||
|
}
|
||||||
|
changed_credential_ids = {
|
||||||
|
entry.resource_id
|
||||||
|
for entry in entries
|
||||||
|
if entry.collection == FILES_CONNECTOR_CREDENTIALS_COLLECTION
|
||||||
|
and entry.resource_type == FILES_CONNECTOR_CREDENTIAL_RESOURCE
|
||||||
|
}
|
||||||
|
changed_space_ids = {
|
||||||
|
entry.resource_id
|
||||||
|
for entry in entries
|
||||||
|
if entry.collection == FILES_CONNECTOR_SPACES_COLLECTION
|
||||||
|
and entry.resource_type == FILES_CONNECTOR_SPACE_RESOURCE
|
||||||
|
}
|
||||||
|
policy_changed = any(
|
||||||
|
entry.collection == FILES_CONNECTOR_POLICIES_COLLECTION for entry in entries
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
changed_profile_ids,
|
||||||
|
changed_credential_ids,
|
||||||
|
changed_space_ids,
|
||||||
|
policy_changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_connector_settings_changed_sections(
|
||||||
|
*,
|
||||||
|
changed_profile_ids: set[str],
|
||||||
|
changed_credential_ids: set[str],
|
||||||
|
changed_space_ids: set[str],
|
||||||
|
policy_changed: bool,
|
||||||
|
profiles: list[ConnectorProfile],
|
||||||
|
) -> list[str]:
|
||||||
|
changed_sections = []
|
||||||
|
if changed_profile_ids or any(
|
||||||
|
profile.credential_profile_id
|
||||||
|
and profile.credential_profile_id in changed_credential_ids
|
||||||
|
for profile in profiles
|
||||||
|
):
|
||||||
|
changed_sections.append("profiles")
|
||||||
|
if changed_credential_ids:
|
||||||
|
changed_sections.append("credentials")
|
||||||
|
if changed_space_ids:
|
||||||
|
changed_sections.append("spaces")
|
||||||
|
if policy_changed:
|
||||||
|
changed_sections.append("policy")
|
||||||
|
return changed_sections
|
||||||
|
|
||||||
|
|
||||||
|
def _file_connector_settings_deleted_items(
|
||||||
|
entries: list[ChangeSequenceEntry],
|
||||||
|
*,
|
||||||
|
visible_profiles: dict[str, ConnectorProfile],
|
||||||
|
visible_credentials: dict[str, ConnectorCredential],
|
||||||
|
visible_spaces: dict[str, FileConnectorSpace],
|
||||||
|
) -> list[DeltaDeletedItem]:
|
||||||
|
return [
|
||||||
|
_connector_deleted_item(entry)
|
||||||
|
for entry in entries
|
||||||
|
if (
|
||||||
|
entry.resource_type == FILES_CONNECTOR_PROFILE_RESOURCE
|
||||||
|
and entry.resource_id not in visible_profiles
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
entry.resource_type == FILES_CONNECTOR_CREDENTIAL_RESOURCE
|
||||||
|
and entry.resource_id not in visible_credentials
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
entry.resource_type == FILES_CONNECTOR_SPACE_RESOURCE
|
||||||
|
and entry.resource_id not in visible_spaces
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _incremental_file_connector_settings_delta_response(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
entries: list[ChangeSequenceEntry],
|
||||||
|
has_more: bool,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
provider: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
include_disabled: bool,
|
||||||
|
include_inactive: bool,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
) -> FileConnectorSettingsDeltaResponse:
|
||||||
|
changed_profile_ids, changed_credential_ids, changed_space_ids, policy_changed = (
|
||||||
|
_changed_file_connector_setting_ids(entries)
|
||||||
|
)
|
||||||
|
profiles = _visible_connector_profiles(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
provider=provider,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
include_disabled=include_disabled
|
||||||
|
and _can_read_disabled_connector_profiles(principal),
|
||||||
|
include_admin_scopes=_can_read_disabled_connector_profiles(principal),
|
||||||
|
include_effective_policy=False,
|
||||||
|
)
|
||||||
|
visible_profiles = {
|
||||||
|
profile.id: profile
|
||||||
|
for profile in profiles
|
||||||
|
if profile.id in changed_profile_ids
|
||||||
|
or (
|
||||||
|
profile.credential_profile_id
|
||||||
|
and profile.credential_profile_id in changed_credential_ids
|
||||||
|
)
|
||||||
|
}
|
||||||
|
credentials = _visible_connector_credentials(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
provider=provider,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
)
|
||||||
|
visible_credentials = {
|
||||||
|
credential.id: credential
|
||||||
|
for credential in credentials
|
||||||
|
if credential.id in changed_credential_ids
|
||||||
|
}
|
||||||
|
spaces = _visible_connector_spaces(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
|
visible_spaces = {
|
||||||
|
space.id: space for space in spaces if space.id in changed_space_ids
|
||||||
|
}
|
||||||
|
return FileConnectorSettingsDeltaResponse(
|
||||||
|
profiles=[
|
||||||
|
FileConnectorProfileResponse(**profile.to_response())
|
||||||
|
for profile in visible_profiles.values()
|
||||||
|
],
|
||||||
|
credentials=[
|
||||||
|
FileConnectorCredentialResponse(**credential.to_response())
|
||||||
|
for credential in visible_credentials.values()
|
||||||
|
],
|
||||||
|
spaces=[_connector_space_response(space) for space in visible_spaces.values()],
|
||||||
|
policy=FileConnectorPolicyResponse(
|
||||||
|
**connector_policy_response(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if policy_changed
|
||||||
|
else None,
|
||||||
|
changed_sections=_file_connector_settings_changed_sections(
|
||||||
|
changed_profile_ids=changed_profile_ids,
|
||||||
|
changed_credential_ids=changed_credential_ids,
|
||||||
|
changed_space_ids=changed_space_ids,
|
||||||
|
policy_changed=policy_changed,
|
||||||
|
profiles=profiles,
|
||||||
|
),
|
||||||
|
deleted=_file_connector_settings_deleted_items(
|
||||||
|
entries,
|
||||||
|
visible_profiles=visible_profiles,
|
||||||
|
visible_credentials=visible_credentials,
|
||||||
|
visible_spaces=visible_spaces,
|
||||||
|
),
|
||||||
|
watermark=_file_connector_settings_response_watermark(
|
||||||
|
session, tenant_id=principal.tenant_id, entries=entries, has_more=has_more
|
||||||
|
),
|
||||||
|
has_more=has_more,
|
||||||
|
full=False,
|
||||||
|
)
|
||||||
619
src/govoplan_files/backend/services/list_queries.py
Normal file
619
src/govoplan_files/backend/services/list_queries.py
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.core.change_sequence import (
|
||||||
|
ChangeSequenceEntry,
|
||||||
|
decode_sequence_watermark,
|
||||||
|
encode_sequence_watermark,
|
||||||
|
max_sequence_id,
|
||||||
|
sequence_entries_since,
|
||||||
|
sequence_watermark_is_expired,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.pagination import (
|
||||||
|
KeysetCursorError,
|
||||||
|
decode_keyset_cursor,
|
||||||
|
encode_keyset_cursor,
|
||||||
|
keyset_query_fingerprint,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.change_tracking import (
|
||||||
|
FILES_ASSETS_COLLECTION,
|
||||||
|
FILES_FOLDERS_COLLECTION,
|
||||||
|
FILES_MODULE_ID,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.schemas import (
|
||||||
|
FileDeltaResponse,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||||
|
from govoplan_files.backend.storage.access import user_group_ids
|
||||||
|
from govoplan_files.backend.storage.files import (
|
||||||
|
list_assets_for_user,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.folders import list_folders_for_user
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_files.backend.route_support import (
|
||||||
|
_asset_list_response,
|
||||||
|
_folder_response,
|
||||||
|
_is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FILES_DELTA_COLLECTIONS = (FILES_ASSETS_COLLECTION, FILES_FOLDERS_COLLECTION)
|
||||||
|
FILES_LIST_CURSOR_SCOPE = "files.list.v1"
|
||||||
|
FOLDERS_LIST_CURSOR_SCOPE = "files.folders.list.v1"
|
||||||
|
DEFAULT_FILE_LIST_PAGE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_http_error(exc: Exception) -> HTTPException:
|
||||||
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_page_size(
|
||||||
|
scope: str, cursor: str | None, explicit_page_size: int | None
|
||||||
|
) -> int | None:
|
||||||
|
if explicit_page_size is not None:
|
||||||
|
return explicit_page_size
|
||||||
|
if not cursor:
|
||||||
|
return DEFAULT_FILE_LIST_PAGE_SIZE
|
||||||
|
try:
|
||||||
|
values = decode_keyset_cursor(scope, cursor)
|
||||||
|
except KeysetCursorError as exc:
|
||||||
|
raise _cursor_http_error(exc) from exc
|
||||||
|
raw_page_size = values.get("page_size")
|
||||||
|
if not isinstance(raw_page_size, int) or raw_page_size < 1 or raw_page_size > 1000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor"
|
||||||
|
)
|
||||||
|
return raw_page_size
|
||||||
|
|
||||||
|
|
||||||
|
def _files_list_fingerprint(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
campaign_usage: Literal["linked", "unlinked"] | None,
|
||||||
|
audit_relevant: bool | None,
|
||||||
|
page_size: int,
|
||||||
|
) -> str:
|
||||||
|
return keyset_query_fingerprint(
|
||||||
|
FILES_LIST_CURSOR_SCOPE,
|
||||||
|
{
|
||||||
|
"tenant_id": principal.tenant_id,
|
||||||
|
"actor": "admin" if _is_admin(principal) else principal.user.id,
|
||||||
|
"owner_type": owner_type,
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"campaign_id": campaign_id,
|
||||||
|
"path_prefix": path_prefix or "",
|
||||||
|
"campaign_usage": campaign_usage or "",
|
||||||
|
"audit_relevant": audit_relevant,
|
||||||
|
"sort": "display_path.asc,updated_at.desc,id.asc",
|
||||||
|
"page_size": page_size,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _folders_list_fingerprint(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
owner_type: Literal["user", "group"],
|
||||||
|
owner_id: str,
|
||||||
|
page_size: int,
|
||||||
|
) -> str:
|
||||||
|
return keyset_query_fingerprint(
|
||||||
|
FOLDERS_LIST_CURSOR_SCOPE,
|
||||||
|
{
|
||||||
|
"tenant_id": principal.tenant_id,
|
||||||
|
"actor": "admin" if _is_admin(principal) else principal.user.id,
|
||||||
|
"owner_type": owner_type,
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"sort": "path.asc,id.asc",
|
||||||
|
"page_size": page_size,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_cursor_values(
|
||||||
|
cursor: str | None, *, fingerprint: str
|
||||||
|
) -> tuple[str | None, datetime | None, str | None]:
|
||||||
|
try:
|
||||||
|
values = decode_keyset_cursor(
|
||||||
|
FILES_LIST_CURSOR_SCOPE, cursor, fingerprint=fingerprint
|
||||||
|
)
|
||||||
|
except KeysetCursorError as exc:
|
||||||
|
raise _cursor_http_error(exc) from exc
|
||||||
|
if values is None:
|
||||||
|
return None, None, None
|
||||||
|
display_path = values.get("display_path")
|
||||||
|
updated_at = values.get("updated_at")
|
||||||
|
asset_id = values.get("id")
|
||||||
|
if (
|
||||||
|
not isinstance(display_path, str)
|
||||||
|
or not isinstance(updated_at, str)
|
||||||
|
or not isinstance(asset_id, str)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed_updated_at = datetime.fromisoformat(updated_at)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor"
|
||||||
|
) from exc
|
||||||
|
return display_path, parsed_updated_at, asset_id
|
||||||
|
|
||||||
|
|
||||||
|
def _folder_cursor_values(
|
||||||
|
cursor: str | None, *, fingerprint: str
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
try:
|
||||||
|
values = decode_keyset_cursor(
|
||||||
|
FOLDERS_LIST_CURSOR_SCOPE, cursor, fingerprint=fingerprint
|
||||||
|
)
|
||||||
|
except KeysetCursorError as exc:
|
||||||
|
raise _cursor_http_error(exc) from exc
|
||||||
|
if values is None:
|
||||||
|
return None, None
|
||||||
|
folder_path = values.get("path")
|
||||||
|
folder_id = values.get("id")
|
||||||
|
if not isinstance(folder_path, str) or not isinstance(folder_id, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor"
|
||||||
|
)
|
||||||
|
return folder_path, folder_id
|
||||||
|
|
||||||
|
|
||||||
|
def _next_file_list_cursor(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
assets: list[FileAsset],
|
||||||
|
*,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
campaign_usage: Literal["linked", "unlinked"] | None,
|
||||||
|
audit_relevant: bool | None,
|
||||||
|
page_size: int,
|
||||||
|
has_more: bool,
|
||||||
|
) -> str | None:
|
||||||
|
if not has_more or not assets:
|
||||||
|
return None
|
||||||
|
last = assets[-1]
|
||||||
|
return encode_keyset_cursor(
|
||||||
|
FILES_LIST_CURSOR_SCOPE,
|
||||||
|
fingerprint=_files_list_fingerprint(
|
||||||
|
principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
|
page_size=page_size,
|
||||||
|
),
|
||||||
|
values={
|
||||||
|
"display_path": last.display_path,
|
||||||
|
"updated_at": last.updated_at,
|
||||||
|
"id": last.id,
|
||||||
|
"page_size": page_size,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_folder_list_cursor(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
folders: list[FileFolder],
|
||||||
|
*,
|
||||||
|
owner_type: Literal["user", "group"],
|
||||||
|
owner_id: str,
|
||||||
|
page_size: int,
|
||||||
|
has_more: bool,
|
||||||
|
) -> str | None:
|
||||||
|
if not has_more or not folders:
|
||||||
|
return None
|
||||||
|
last = folders[-1]
|
||||||
|
return encode_keyset_cursor(
|
||||||
|
FOLDERS_LIST_CURSOR_SCOPE,
|
||||||
|
fingerprint=_folders_list_fingerprint(
|
||||||
|
principal, owner_type=owner_type, owner_id=owner_id, page_size=page_size
|
||||||
|
),
|
||||||
|
values={"path": last.path, "id": last.id, "page_size": page_size},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _files_delta_watermark(session: Session, tenant_id: str) -> str:
|
||||||
|
return encode_sequence_watermark(
|
||||||
|
max_sequence_id(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id=FILES_MODULE_ID,
|
||||||
|
collections=_FILES_DELTA_COLLECTIONS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_file_delta_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
) -> FileDeltaResponse:
|
||||||
|
assets = list_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
folders = _visible_folders_for_delta(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
)
|
||||||
|
return FileDeltaResponse(
|
||||||
|
files=_asset_list_response(session, assets, include_shares=True),
|
||||||
|
folders=[_folder_response(folder) for folder in folders],
|
||||||
|
deleted=[],
|
||||||
|
watermark=_files_delta_watermark(session, principal.tenant_id),
|
||||||
|
has_more=False,
|
||||||
|
full=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_folders_for_delta(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
) -> list[FileFolder]:
|
||||||
|
if not owner_type or not owner_id:
|
||||||
|
return []
|
||||||
|
folders = list_folders_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
if not path_prefix:
|
||||||
|
return folders
|
||||||
|
normalized = path_prefix.strip().strip("/")
|
||||||
|
if not normalized:
|
||||||
|
return folders
|
||||||
|
return [
|
||||||
|
folder
|
||||||
|
for folder in folders
|
||||||
|
if folder.path == normalized or folder.path.startswith(f"{normalized}/")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_path_matches(
|
||||||
|
entry_payload: dict[str, object], path_prefix: str | None
|
||||||
|
) -> bool:
|
||||||
|
if not path_prefix:
|
||||||
|
return True
|
||||||
|
normalized = path_prefix.strip().strip("/")
|
||||||
|
if not normalized:
|
||||||
|
return True
|
||||||
|
for key in ("path", "previous_path"):
|
||||||
|
value = entry_payload.get(key)
|
||||||
|
if isinstance(value, str) and (
|
||||||
|
value == normalized or value.startswith(f"{normalized}/")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_owner_matches(
|
||||||
|
entry_payload: dict[str, object],
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if not owner_type or not owner_id:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
entry_payload.get("owner_type") == owner_type
|
||||||
|
and entry_payload.get("owner_id") == owner_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_campaign_matches(session: Session, entry, campaign_id: str | None) -> bool:
|
||||||
|
if not campaign_id:
|
||||||
|
return True
|
||||||
|
payload = entry.payload or {}
|
||||||
|
if (
|
||||||
|
payload.get("share_target_type") == "campaign"
|
||||||
|
and payload.get("share_target_id") == campaign_id
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if entry.resource_type != "file":
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
session.query(FileShare)
|
||||||
|
.filter(
|
||||||
|
FileShare.tenant_id == entry.tenant_id,
|
||||||
|
FileShare.file_asset_id == entry.resource_id,
|
||||||
|
FileShare.target_type == "campaign",
|
||||||
|
FileShare.target_id == campaign_id,
|
||||||
|
FileShare.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_group_ids_for_delta(
|
||||||
|
session: Session, principal: ApiPrincipal
|
||||||
|
) -> set[str]:
|
||||||
|
cache = session.info.setdefault("files_delta_group_ids", {})
|
||||||
|
key = (principal.tenant_id, principal.user.id)
|
||||||
|
if key not in cache:
|
||||||
|
cache[key] = set(
|
||||||
|
user_group_ids(
|
||||||
|
session, tenant_id=principal.tenant_id, user_id=principal.user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_subject_matches_principal(
|
||||||
|
session: Session, principal: ApiPrincipal, entry_payload: dict[str, object]
|
||||||
|
) -> bool:
|
||||||
|
if _is_admin(principal):
|
||||||
|
return True
|
||||||
|
owner_type = entry_payload.get("owner_type")
|
||||||
|
owner_id = entry_payload.get("owner_id")
|
||||||
|
if owner_type == "user" and owner_id == principal.user.id:
|
||||||
|
return True
|
||||||
|
if owner_type == "group" and isinstance(owner_id, str):
|
||||||
|
if owner_id in _principal_group_ids_for_delta(session, principal):
|
||||||
|
return True
|
||||||
|
share_target_type = entry_payload.get("share_target_type")
|
||||||
|
share_target_id = entry_payload.get("share_target_id")
|
||||||
|
if share_target_type == "user" and share_target_id == principal.user.id:
|
||||||
|
return True
|
||||||
|
if share_target_type == "tenant" and share_target_id == principal.tenant_id:
|
||||||
|
return True
|
||||||
|
if share_target_type == "group" and isinstance(share_target_id, str):
|
||||||
|
if share_target_id in _principal_group_ids_for_delta(session, principal):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_matches_delta_scope(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
entry,
|
||||||
|
*,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
) -> bool:
|
||||||
|
payload = entry.payload or {}
|
||||||
|
if (
|
||||||
|
not owner_type
|
||||||
|
and not campaign_id
|
||||||
|
and not _entry_subject_matches_principal(session, principal, payload)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
_entry_owner_matches(payload, owner_type, owner_id)
|
||||||
|
and _entry_path_matches(payload, path_prefix)
|
||||||
|
and _entry_campaign_matches(session, entry, campaign_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_files_delta_watermark(since: str) -> int:
|
||||||
|
try:
|
||||||
|
return decode_sequence_watermark(since)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_delta_resource_ids(
|
||||||
|
entries: list[ChangeSequenceEntry],
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
file_ids = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
entry.resource_id for entry in entries if entry.resource_type == "file"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
folder_ids = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
entry.resource_id for entry in entries if entry.resource_type == "folder"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return file_ids, folder_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_assets_for_delta(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
changed_file_ids: list[str],
|
||||||
|
) -> dict[str, FileAsset]:
|
||||||
|
return {
|
||||||
|
asset.id: asset
|
||||||
|
for asset in list_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
if asset.id in changed_file_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_visible_folders_for_delta(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
changed_folder_ids: list[str],
|
||||||
|
) -> dict[str, FileFolder]:
|
||||||
|
return {
|
||||||
|
folder.id: folder
|
||||||
|
for folder in _visible_folders_for_delta(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
)
|
||||||
|
if folder.id in changed_folder_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _deleted_delta_items(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
entries: list[ChangeSequenceEntry],
|
||||||
|
visible_assets: dict[str, FileAsset],
|
||||||
|
visible_folders: dict[str, FileFolder],
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
) -> list[DeltaDeletedItem]:
|
||||||
|
deleted: dict[tuple[str, str], DeltaDeletedItem] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if entry.resource_type == "file" and entry.resource_id in visible_assets:
|
||||||
|
continue
|
||||||
|
if entry.resource_type == "folder" and entry.resource_id in visible_folders:
|
||||||
|
continue
|
||||||
|
if not _entry_matches_delta_scope(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
entry,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
deleted[(entry.resource_type, entry.resource_id)] = DeltaDeletedItem(
|
||||||
|
id=entry.resource_id,
|
||||||
|
resource_type=entry.resource_type,
|
||||||
|
revision=encode_sequence_watermark(entry.id),
|
||||||
|
deleted_at=entry.created_at if entry.operation == "deleted" else None,
|
||||||
|
)
|
||||||
|
return list(deleted.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _files_delta_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
owner_type: Literal["user", "group"] | None,
|
||||||
|
owner_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
path_prefix: str | None,
|
||||||
|
since: str,
|
||||||
|
limit: int,
|
||||||
|
) -> FileDeltaResponse:
|
||||||
|
since_sequence = _decode_files_delta_watermark(since)
|
||||||
|
if sequence_watermark_is_expired(
|
||||||
|
session,
|
||||||
|
since=since_sequence,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
module_id=FILES_MODULE_ID,
|
||||||
|
collections=_FILES_DELTA_COLLECTIONS,
|
||||||
|
):
|
||||||
|
return _full_file_delta_response(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
entries_plus_one = sequence_entries_since(
|
||||||
|
session,
|
||||||
|
since=since_sequence,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
module_id=FILES_MODULE_ID,
|
||||||
|
collections=_FILES_DELTA_COLLECTIONS,
|
||||||
|
limit=limit + 1,
|
||||||
|
)
|
||||||
|
has_more = len(entries_plus_one) > limit
|
||||||
|
entries = entries_plus_one[:limit]
|
||||||
|
|
||||||
|
changed_file_ids, changed_folder_ids = _changed_delta_resource_ids(entries)
|
||||||
|
visible_assets = _visible_assets_for_delta(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
changed_file_ids=changed_file_ids,
|
||||||
|
)
|
||||||
|
visible_folders = _changed_visible_folders_for_delta(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
changed_folder_ids=changed_folder_ids,
|
||||||
|
)
|
||||||
|
deleted = _deleted_delta_items(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
entries=entries,
|
||||||
|
visible_assets=visible_assets,
|
||||||
|
visible_folders=visible_folders,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
watermark = (
|
||||||
|
encode_sequence_watermark(entries[-1].id)
|
||||||
|
if has_more and entries
|
||||||
|
else _files_delta_watermark(session, principal.tenant_id)
|
||||||
|
)
|
||||||
|
return FileDeltaResponse(
|
||||||
|
files=_asset_list_response(
|
||||||
|
session, list(visible_assets.values()), include_shares=True
|
||||||
|
),
|
||||||
|
folders=[_folder_response(folder) for folder in visible_folders.values()],
|
||||||
|
deleted=deleted,
|
||||||
|
watermark=watermark,
|
||||||
|
has_more=has_more,
|
||||||
|
full=False,
|
||||||
|
)
|
||||||
@@ -5,8 +5,17 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path
|
from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path
|
||||||
|
from govoplan_core.security.credential_envelopes import (
|
||||||
|
CredentialAccessContext,
|
||||||
|
CredentialEnvelope,
|
||||||
|
CredentialEnvelopeError,
|
||||||
|
ResolvedCredentialEnvelope,
|
||||||
|
list_credential_envelopes,
|
||||||
|
resolve_credential_envelope,
|
||||||
|
)
|
||||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
from govoplan_files.backend.db.models import FileConnectorCredential
|
from govoplan_files.backend.db.models import FileConnectorCredential
|
||||||
from govoplan_files.backend.storage.common import FileStorageError
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
@@ -14,6 +23,8 @@ from govoplan_files.backend.storage.connector_deployment import reject_api_contr
|
|||||||
from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload
|
from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload
|
||||||
from govoplan_files.backend.storage.connector_profiles import supported_connector_providers
|
from govoplan_files.backend.storage.connector_profiles import supported_connector_providers
|
||||||
|
|
||||||
|
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ConnectorCredential:
|
class ConnectorCredential:
|
||||||
@@ -33,6 +44,7 @@ class ConnectorCredential:
|
|||||||
policy_sources: tuple[ConnectorPolicySource, ...] = ()
|
policy_sources: tuple[ConnectorPolicySource, ...] = ()
|
||||||
metadata: Mapping[str, Any] | None = None
|
metadata: Mapping[str, Any] | None = None
|
||||||
source_kind: str = "database"
|
source_kind: str = "database"
|
||||||
|
has_secret: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_path(self) -> str:
|
def source_path(self) -> str:
|
||||||
@@ -58,7 +70,7 @@ class ConnectorCredential:
|
|||||||
return True
|
return True
|
||||||
# Environment references are deliberately unavailable to API-managed
|
# Environment references are deliberately unavailable to API-managed
|
||||||
# credential rows. Legacy rows remain visible but fail closed.
|
# credential rows. Legacy rows remain visible but fail closed.
|
||||||
return bool(self.secret_ref or self.password_value or self.token_value)
|
return bool(self.has_secret or self.secret_ref or self.password_value or self.token_value)
|
||||||
|
|
||||||
def to_response(self) -> dict[str, Any]:
|
def to_response(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +97,159 @@ def list_database_connector_credentials(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
) -> list[ConnectorCredential]:
|
) -> list[ConnectorCredential]:
|
||||||
return [connector_credential_from_row(row) for row in list_connector_credential_rows(session, tenant_id=tenant_id, include_disabled=include_disabled)]
|
local = [
|
||||||
|
connector_credential_from_row(row)
|
||||||
|
for row in list_connector_credential_rows(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
*local,
|
||||||
|
*list_reusable_connector_credentials(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def reusable_credential_reference(credential_id: str) -> str:
|
||||||
|
return f"{CORE_CREDENTIAL_ENVELOPE_PREFIX}{credential_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def reusable_credential_id(credential_ref: str | None) -> str | None:
|
||||||
|
if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
|
||||||
|
return None
|
||||||
|
value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def file_credential_context(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
scope_type: str = "tenant",
|
||||||
|
scope_id: str | None = None,
|
||||||
|
administrative: bool = False,
|
||||||
|
) -> CredentialAccessContext:
|
||||||
|
return CredentialAccessContext(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=scope_id if scope_type == "user" else None,
|
||||||
|
group_ids=frozenset({scope_id}) if scope_type == "group" and scope_id else frozenset(),
|
||||||
|
target_scope_type=scope_type,
|
||||||
|
target_scope_id=scope_id or tenant_id,
|
||||||
|
module_id="files",
|
||||||
|
server_ref=f"files:{profile_id}" if profile_id else None,
|
||||||
|
administrative=administrative,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_reusable_connector_credentials(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> list[ConnectorCredential]:
|
||||||
|
if not inspect(session.get_bind()).has_table(CredentialEnvelope.__tablename__):
|
||||||
|
return []
|
||||||
|
context = file_credential_context(tenant_id=tenant_id, administrative=True)
|
||||||
|
return [
|
||||||
|
connector_credential_from_envelope(row)
|
||||||
|
for row in list_credential_envelopes(
|
||||||
|
session,
|
||||||
|
context=context,
|
||||||
|
include_inactive=include_disabled,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_reusable_connector_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
credential_ref: str,
|
||||||
|
profile_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> ConnectorCredential:
|
||||||
|
credential_id = reusable_credential_id(credential_ref)
|
||||||
|
if credential_id is None:
|
||||||
|
raise FileStorageError("Reusable credential reference is invalid")
|
||||||
|
try:
|
||||||
|
resolved = resolve_credential_envelope(
|
||||||
|
session,
|
||||||
|
credential_id=credential_id,
|
||||||
|
context=file_credential_context(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except CredentialEnvelopeError as exc:
|
||||||
|
raise FileStorageError("Reusable credential is unavailable to this file connection") from exc
|
||||||
|
return connector_credential_from_resolved_envelope(
|
||||||
|
resolved,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connector_credential_from_envelope(row: CredentialEnvelope) -> ConnectorCredential:
|
||||||
|
return ConnectorCredential(
|
||||||
|
id=reusable_credential_reference(row.id),
|
||||||
|
label=row.name,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
enabled=row.is_active,
|
||||||
|
credential_mode=_envelope_credential_mode(row.credential_kind, row.secret_keys),
|
||||||
|
username=_clean_public_value(row.public_data, "username"),
|
||||||
|
source_kind="credential_envelope",
|
||||||
|
has_secret=bool(row.secret_data_encrypted),
|
||||||
|
metadata={
|
||||||
|
"credential_envelope_id": row.id,
|
||||||
|
"credential_kind": row.credential_kind,
|
||||||
|
"allowed_modules": list(row.allowed_modules or []),
|
||||||
|
"allowed_server_refs": list(row.allowed_server_refs or []),
|
||||||
|
"inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes),
|
||||||
|
"revision": row.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connector_credential_from_resolved_envelope(
|
||||||
|
row: ResolvedCredentialEnvelope,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> ConnectorCredential:
|
||||||
|
return ConnectorCredential(
|
||||||
|
id=reusable_credential_reference(row.id),
|
||||||
|
label=row.name,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
enabled=True,
|
||||||
|
credential_mode=_envelope_credential_mode(row.credential_kind, row.secret_data),
|
||||||
|
username=_clean_public_value(row.public_data, "username"),
|
||||||
|
password_value=_first_secret(row.secret_data, "password", "secret"),
|
||||||
|
token_value=_first_secret(
|
||||||
|
row.secret_data,
|
||||||
|
"access_token",
|
||||||
|
"bearer_token",
|
||||||
|
"token",
|
||||||
|
"api_key",
|
||||||
|
),
|
||||||
|
source_kind="credential_envelope",
|
||||||
|
has_secret=bool(row.secret_data),
|
||||||
|
metadata={
|
||||||
|
"credential_envelope_id": row.id,
|
||||||
|
"credential_kind": row.credential_kind,
|
||||||
|
"inherit_to_lower_scopes": True,
|
||||||
|
"revision": row.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_connector_credential_rows(
|
def list_connector_credential_rows(
|
||||||
@@ -348,6 +512,27 @@ def _policy_source_response(source: ConnectorPolicySource) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope_credential_mode(kind: str, secret_values: Mapping[str, Any] | list[str]) -> str:
|
||||||
|
keys = {str(key) for key in secret_values}
|
||||||
|
if kind in {"token", "oauth2", "api_key"} or keys.intersection(
|
||||||
|
{"access_token", "bearer_token", "token", "api_key"}
|
||||||
|
):
|
||||||
|
return "token"
|
||||||
|
return "basic"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_public_value(values: Mapping[str, Any] | None, key: str) -> str | None:
|
||||||
|
return _clean((values or {}).get(key))
|
||||||
|
|
||||||
|
|
||||||
|
def _first_secret(values: Mapping[str, Any], *keys: str) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
value = _clean(values.get(key))
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _clean(value: object) -> str | None:
|
def _clean(value: object) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
|||||||
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
||||||
from govoplan_files.backend.storage.common import FileStorageError
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
from govoplan_files.backend.storage.connector_deployment import reject_api_controlled_deployment_references
|
from govoplan_files.backend.storage.connector_deployment import reject_api_controlled_deployment_references
|
||||||
from govoplan_files.backend.storage.connector_credential_store import connector_credential_from_row, credential_rows_by_id
|
from govoplan_files.backend.storage.connector_credential_store import (
|
||||||
|
ConnectorCredential,
|
||||||
|
connector_credential_from_row,
|
||||||
|
credential_rows_by_id,
|
||||||
|
resolve_reusable_connector_credential,
|
||||||
|
reusable_credential_id,
|
||||||
|
)
|
||||||
from govoplan_files.backend.storage.connector_policy import connector_policy_sources_from_payload
|
from govoplan_files.backend.storage.connector_policy import connector_policy_sources_from_payload
|
||||||
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile, supported_connector_providers
|
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile, supported_connector_providers
|
||||||
|
|
||||||
@@ -50,10 +56,28 @@ def select_database_connector_profiles(
|
|||||||
profile_ids = {row.id for row in rows}
|
profile_ids = {row.id for row in rows}
|
||||||
if row_visible is not None:
|
if row_visible is not None:
|
||||||
rows = [row for row in rows if row_visible(row)]
|
rows = [row for row in rows if row_visible(row)]
|
||||||
credential_ids = {_clean(row.credential_profile_id) for row in rows if _clean(row.credential_profile_id)}
|
credential_ids = {
|
||||||
|
_clean(row.credential_profile_id)
|
||||||
|
for row in rows
|
||||||
|
if _clean(row.credential_profile_id) and not reusable_credential_id(row.credential_profile_id)
|
||||||
|
}
|
||||||
credentials = credential_rows_by_id(session, tenant_id=tenant_id, credential_ids={item for item in credential_ids if item}, include_disabled=include_disabled)
|
credentials = credential_rows_by_id(session, tenant_id=tenant_id, credential_ids={item for item in credential_ids if item}, include_disabled=include_disabled)
|
||||||
return (
|
return (
|
||||||
[connector_profile_from_row(row, credential_row=credentials.get(row.credential_profile_id or "")) for row in rows],
|
[
|
||||||
|
connector_profile_from_row(
|
||||||
|
row,
|
||||||
|
credential_row=(
|
||||||
|
_resolve_profile_reusable_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
row=row,
|
||||||
|
)
|
||||||
|
if reusable_credential_id(row.credential_profile_id)
|
||||||
|
else credentials.get(row.credential_profile_id or "")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
profile_ids,
|
profile_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,7 +97,10 @@ def list_connector_profile_rows(
|
|||||||
return query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all()
|
return query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileConnectorCredential | None = None) -> ConnectorProfile:
|
def connector_profile_from_row(
|
||||||
|
row: FileConnectorProfile,
|
||||||
|
credential_row: FileConnectorCredential | ConnectorCredential | None = None,
|
||||||
|
) -> ConnectorProfile:
|
||||||
policy_sources = []
|
policy_sources = []
|
||||||
if row.policy:
|
if row.policy:
|
||||||
policy_sources = connector_policy_sources_from_payload({
|
policy_sources = connector_policy_sources_from_payload({
|
||||||
@@ -82,9 +109,18 @@ def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileCo
|
|||||||
"label": row.label,
|
"label": row.label,
|
||||||
"policy": row.policy,
|
"policy": row.policy,
|
||||||
})
|
})
|
||||||
credential = connector_credential_from_row(credential_row) if credential_row is not None else None
|
credential = (
|
||||||
|
credential_row
|
||||||
|
if isinstance(credential_row, ConnectorCredential)
|
||||||
|
else connector_credential_from_row(credential_row)
|
||||||
|
if credential_row is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
if credential:
|
if credential:
|
||||||
policy_sources.extend(credential.policy_sources)
|
policy_sources.extend(credential.policy_sources)
|
||||||
|
metadata = dict(row.metadata_ or {})
|
||||||
|
if reusable_credential_id(row.credential_profile_id) and credential is None:
|
||||||
|
metadata["credential_unavailable"] = True
|
||||||
return ConnectorProfile(
|
return ConnectorProfile(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
label=row.label,
|
label=row.label,
|
||||||
@@ -105,11 +141,30 @@ def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileCo
|
|||||||
token_value=credential.token_value if credential else decrypt_secret(row.token_encrypted),
|
token_value=credential.token_value if credential else decrypt_secret(row.token_encrypted),
|
||||||
capabilities=tuple(_string_list(row.capabilities)),
|
capabilities=tuple(_string_list(row.capabilities)),
|
||||||
policy_sources=tuple(policy_sources),
|
policy_sources=tuple(policy_sources),
|
||||||
metadata=dict(row.metadata_ or {}),
|
metadata=metadata,
|
||||||
source_kind="database",
|
source_kind="database",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_profile_reusable_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
row: FileConnectorProfile,
|
||||||
|
) -> ConnectorCredential | None:
|
||||||
|
try:
|
||||||
|
return resolve_reusable_connector_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
credential_ref=row.credential_profile_id or "",
|
||||||
|
profile_id=row.id,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
)
|
||||||
|
except FileStorageError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_connector_profile_row(
|
def get_connector_profile_row(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import PurePosixPath
|
|||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, exists, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
|
||||||
@@ -352,6 +352,8 @@ def list_assets_for_user(
|
|||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
path_prefix: str | None = None,
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: str | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
) -> list[FileAsset]:
|
) -> list[FileAsset]:
|
||||||
@@ -363,6 +365,8 @@ def list_assets_for_user(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
include_deleted=include_deleted,
|
include_deleted=include_deleted,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
)
|
||||||
@@ -378,6 +382,8 @@ def list_assets_for_user_window(
|
|||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
path_prefix: str | None = None,
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: str | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
@@ -393,6 +399,8 @@ def list_assets_for_user_window(
|
|||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
path_prefix=path_prefix,
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
include_deleted=include_deleted,
|
include_deleted=include_deleted,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
)
|
||||||
@@ -417,6 +425,8 @@ def _asset_visibility_query_for_user(
|
|||||||
owner_id: str | None = None,
|
owner_id: str | None = None,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
path_prefix: str | None = None,
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: str | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
):
|
):
|
||||||
@@ -451,7 +461,64 @@ def _asset_visibility_query_for_user(
|
|||||||
prefix = normalize_folder(path_prefix)
|
prefix = normalize_folder(path_prefix)
|
||||||
if prefix:
|
if prefix:
|
||||||
query = query.filter(FileAsset.display_path.like(f"{prefix}/%"))
|
query = query.filter(FileAsset.display_path.like(f"{prefix}/%"))
|
||||||
return query
|
campaign_share_exists = exists().where(
|
||||||
|
FileShare.tenant_id == tenant_id,
|
||||||
|
FileShare.file_asset_id == FileAsset.id,
|
||||||
|
FileShare.target_type == "campaign",
|
||||||
|
FileShare.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
campaign_use_exists = exists().where(
|
||||||
|
CampaignAttachmentUse.tenant_id == tenant_id,
|
||||||
|
CampaignAttachmentUse.file_asset_id == FileAsset.id,
|
||||||
|
)
|
||||||
|
if campaign_usage == "linked":
|
||||||
|
query = query.filter(or_(campaign_share_exists, campaign_use_exists))
|
||||||
|
elif campaign_usage == "unlinked":
|
||||||
|
query = query.filter(~or_(campaign_share_exists, campaign_use_exists))
|
||||||
|
|
||||||
|
if audit_relevant is not None:
|
||||||
|
sent_use_exists = exists().where(
|
||||||
|
CampaignAttachmentUse.tenant_id == tenant_id,
|
||||||
|
CampaignAttachmentUse.file_asset_id == FileAsset.id,
|
||||||
|
CampaignAttachmentUse.use_stage == "sent",
|
||||||
|
)
|
||||||
|
query = query.filter(sent_use_exists if audit_relevant else ~sent_use_exists)
|
||||||
|
return query.distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def count_assets_for_user(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
owner_type: str | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
path_prefix: str | None = None,
|
||||||
|
campaign_usage: str | None = None,
|
||||||
|
audit_relevant: bool | None = None,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> int:
|
||||||
|
query = _asset_visibility_query_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
campaign_usage=campaign_usage,
|
||||||
|
audit_relevant=audit_relevant,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
query.order_by(None)
|
||||||
|
.with_entities(func.count(func.distinct(FileAsset.id)))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]:
|
def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]:
|
||||||
|
|||||||
@@ -185,14 +185,22 @@ class _OutboundPolicyHTTPTransport(httpx.BaseTransport):
|
|||||||
self._connection_pool.close()
|
self._connection_pool.close()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
_CONNECTOR_HTTP_CLIENT = httpx.Client(
|
||||||
def _stream_connector_request(method: str, url: str, **kwargs: Any) -> Iterator[httpx.Response]:
|
|
||||||
with httpx.Client(
|
|
||||||
transport=_OutboundPolicyHTTPTransport(),
|
transport=_OutboundPolicyHTTPTransport(),
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
timeout=kwargs.pop("timeout", 15.0),
|
timeout=15.0,
|
||||||
) as client:
|
)
|
||||||
with client.stream(method, url, **kwargs) as response:
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _stream_connector_request(method: str, url: str, **kwargs: Any) -> Iterator[httpx.Response]:
|
||||||
|
timeout = kwargs.pop("timeout", 15.0)
|
||||||
|
with _CONNECTOR_HTTP_CLIENT.stream(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
timeout=timeout,
|
||||||
|
**kwargs,
|
||||||
|
) as response:
|
||||||
yield response
|
yield response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_access.backend.db.models import Account, Group, User
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
|
from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
|
||||||
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||||
|
from govoplan_files.backend.storage.files import count_assets_for_user, list_assets_for_user
|
||||||
|
|
||||||
|
|
||||||
TENANT_ID = "tenant-1"
|
TENANT_ID = "tenant-1"
|
||||||
@@ -75,10 +77,114 @@ class FilesAccessProviderTests(unittest.TestCase):
|
|||||||
self.assertTrue(any(item.kind == "owner" and item.id == GROUP_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)
|
self.assertEqual("files.not_found", missing_items[0].source)
|
||||||
|
|
||||||
|
def test_file_property_filters_cover_campaign_and_audit_usage(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
self.addCleanup(_close_session, session)
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
assets = [
|
||||||
|
FileAsset(
|
||||||
|
id=f"file-{index}",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_type="user",
|
||||||
|
owner_user_id=USER_ID,
|
||||||
|
display_path=f"{index}.pdf",
|
||||||
|
filename=f"{index}.pdf",
|
||||||
|
)
|
||||||
|
for index in range(1, 4)
|
||||||
|
]
|
||||||
|
session.add_all([
|
||||||
|
*assets,
|
||||||
|
FileShare(
|
||||||
|
id="share-campaign",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
file_asset_id=assets[0].id,
|
||||||
|
target_type="campaign",
|
||||||
|
target_id="campaign-1",
|
||||||
|
permission="read",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO campaign_attachment_uses "
|
||||||
|
"(id, tenant_id, file_asset_id, use_stage) "
|
||||||
|
"VALUES (:id, :tenant_id, :file_asset_id, :use_stage)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": "attachment-use-1",
|
||||||
|
"tenant_id": TENANT_ID,
|
||||||
|
"file_asset_id": assets[1].id,
|
||||||
|
"use_stage": "sent",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
linked = list_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
user_id=USER_ID,
|
||||||
|
owner_type="user",
|
||||||
|
owner_id=USER_ID,
|
||||||
|
campaign_usage="linked",
|
||||||
|
)
|
||||||
|
unlinked = list_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
user_id=USER_ID,
|
||||||
|
owner_type="user",
|
||||||
|
owner_id=USER_ID,
|
||||||
|
campaign_usage="unlinked",
|
||||||
|
)
|
||||||
|
audit_relevant = list_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
user_id=USER_ID,
|
||||||
|
owner_type="user",
|
||||||
|
owner_id=USER_ID,
|
||||||
|
audit_relevant=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([asset.id for asset in linked], ["file-1", "file-2"])
|
||||||
|
self.assertEqual([asset.id for asset in unlinked], ["file-3"])
|
||||||
|
self.assertEqual([asset.id for asset in audit_relevant], ["file-2"])
|
||||||
|
self.assertEqual(
|
||||||
|
count_assets_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
user_id=USER_ID,
|
||||||
|
owner_type="user",
|
||||||
|
owner_id=USER_ID,
|
||||||
|
campaign_usage="unlinked",
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _session():
|
def _session():
|
||||||
engine = create_engine("sqlite:///:memory:", future=True)
|
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__])
|
Base.metadata.create_all(
|
||||||
|
bind=engine,
|
||||||
|
tables=[
|
||||||
|
Account.__table__,
|
||||||
|
User.__table__,
|
||||||
|
Group.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
FileAsset.__table__,
|
||||||
|
FileFolder.__table__,
|
||||||
|
FileShare.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE campaign_attachment_uses ("
|
||||||
|
"id VARCHAR(36) PRIMARY KEY, "
|
||||||
|
"tenant_id VARCHAR(36) NOT NULL, "
|
||||||
|
"file_asset_id VARCHAR(36) NOT NULL, "
|
||||||
|
"use_stage VARCHAR(20) NOT NULL"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
return sessionmaker(bind=engine, future=True)()
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.security.secrets import encrypt_secret
|
from govoplan_core.security.secrets import encrypt_secret
|
||||||
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
||||||
from govoplan_files.backend.router import deactivate_connector_credential, deactivate_connector_profile
|
from govoplan_files.backend.routes.connector_profiles import deactivate_connector_profile
|
||||||
|
from govoplan_files.backend.routes.connector_settings import deactivate_connector_credential
|
||||||
|
|
||||||
|
|
||||||
class Principal:
|
class Principal:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from govoplan_files.backend.router import discover_connector_endpoint
|
from govoplan_files.backend.routes.connector_settings import discover_connector_endpoint
|
||||||
from govoplan_files.backend.schemas import FileConnectorDiscoveryRequest
|
from govoplan_files.backend.schemas import FileConnectorDiscoveryRequest
|
||||||
from govoplan_files.backend.storage.connector_browse import ConnectorBrowseError, _profile_password, _s3_verify
|
from govoplan_files.backend.storage.connector_browse import ConnectorBrowseError, _profile_password, _s3_verify
|
||||||
from govoplan_files.backend.storage.connector_deployment import (
|
from govoplan_files.backend.storage.connector_deployment import (
|
||||||
@@ -182,7 +182,7 @@ class ConnectorDiscoveryBoundaryTests(unittest.TestCase):
|
|||||||
credential_mode="basic",
|
credential_mode="basic",
|
||||||
credentials={"username": "admin", "password_env": "MASTER_KEY_B64"},
|
credentials={"username": "admin", "password_env": "MASTER_KEY_B64"},
|
||||||
)
|
)
|
||||||
with patch("govoplan_files.backend.router.browse_connector_profile") as browse, self.assertRaises(
|
with patch("govoplan_files.backend.routes.connector_settings.browse_connector_profile") as browse, self.assertRaises(
|
||||||
HTTPException
|
HTTPException
|
||||||
) as raised:
|
) as raised:
|
||||||
discover_connector_endpoint(payload, session=object(), principal=self._principal()) # type: ignore[arg-type]
|
discover_connector_endpoint(payload, session=object(), principal=self._principal()) # type: ignore[arg-type]
|
||||||
@@ -212,10 +212,10 @@ class ConnectorDiscoveryBoundaryTests(unittest.TestCase):
|
|||||||
events.append("io")
|
events.append("io")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
with patch("govoplan_files.backend.router._ensure_connector_configuration_allowed", side_effect=ensure), patch(
|
with patch("govoplan_files.backend.routes.connector_settings._ensure_connector_configuration_allowed", side_effect=ensure), patch(
|
||||||
"govoplan_files.backend.router._audit_connector_discovery_attempt",
|
"govoplan_files.backend.routes.connector_settings._audit_connector_discovery_attempt",
|
||||||
side_effect=audit,
|
side_effect=audit,
|
||||||
), patch("govoplan_files.backend.router.browse_connector_profile", side_effect=browse):
|
), patch("govoplan_files.backend.routes.connector_settings.browse_connector_profile", side_effect=browse):
|
||||||
response = discover_connector_endpoint(payload, session=object(), principal=self._principal()) # type: ignore[arg-type]
|
response = discover_connector_endpoint(payload, session=object(), principal=self._principal()) # type: ignore[arg-type]
|
||||||
|
|
||||||
self.assertEqual("usable", response.status)
|
self.assertEqual("usable", response.status)
|
||||||
|
|||||||
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()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - resolve Files user foreign keys
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - resolve Files user foreign keys
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.security.credential_envelopes import CredentialEnvelope
|
||||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
from govoplan_files.backend.db.models import FileConnectorProfile
|
from govoplan_files.backend.db.models import FileConnectorProfile
|
||||||
from govoplan_files.backend.storage.common import FileStorageError
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
@@ -20,7 +22,13 @@ from govoplan_files.backend.storage.connector_profile_store import (
|
|||||||
class ConnectorProfileStoreUpdateTests(unittest.TestCase):
|
class ConnectorProfileStoreUpdateTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(self.engine, tables=[FileConnectorProfile.__table__])
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
FileConnectorProfile.__table__,
|
||||||
|
CredentialEnvelope.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
self.session = sessionmaker(bind=self.engine)()
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
self.row = FileConnectorProfile(
|
self.row = FileConnectorProfile(
|
||||||
id="profile-1",
|
id="profile-1",
|
||||||
@@ -171,6 +179,50 @@ class ConnectorProfileStoreUpdateTests(unittest.TestCase):
|
|||||||
self.assertEqual([self.row.id], [profile.id for profile in profiles])
|
self.assertEqual([self.row.id], [profile.id for profile in profiles])
|
||||||
self.assertEqual("original-password", profiles[0].password_value)
|
self.assertEqual("original-password", profiles[0].password_value)
|
||||||
|
|
||||||
|
def test_reusable_credential_resolves_and_revocation_keeps_profile_visible(self) -> None:
|
||||||
|
credential = CredentialEnvelope(
|
||||||
|
id="shared-credential",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Shared WebDAV login",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={"username": "ada"},
|
||||||
|
secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})),
|
||||||
|
secret_keys=["password"],
|
||||||
|
allowed_modules=["files"],
|
||||||
|
allowed_server_refs=[],
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
is_active=True,
|
||||||
|
revision="revision-1",
|
||||||
|
)
|
||||||
|
self.row.credential_profile_id = "credential-envelope:shared-credential"
|
||||||
|
self.row.username = None
|
||||||
|
self.row.password_encrypted = None
|
||||||
|
self.row.token_encrypted = None
|
||||||
|
self.session.add(credential)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
profile = list_database_connector_profiles(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual("ada", profile.username)
|
||||||
|
self.assertEqual("secret", profile.password_value)
|
||||||
|
self.assertTrue(profile.credentials_configured)
|
||||||
|
|
||||||
|
credential.is_active = False
|
||||||
|
self.session.commit()
|
||||||
|
profile = list_database_connector_profiles(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertIsNone(profile.password_value)
|
||||||
|
self.assertFalse(profile.credentials_configured)
|
||||||
|
self.assertTrue(profile.metadata["credential_unavailable"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,11 +1,57 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from collections import Counter
|
||||||
|
from inspect import signature
|
||||||
|
|
||||||
from govoplan_files.backend.router import router
|
from govoplan_files.backend.router import router
|
||||||
|
from govoplan_files.backend.routes.assets import router as assets_router
|
||||||
|
from govoplan_files.backend.routes.connector_io import router as connector_io_router
|
||||||
|
from govoplan_files.backend.routes.connector_profiles import router as connector_profiles_router
|
||||||
|
from govoplan_files.backend.routes.connector_settings import router as connector_settings_router
|
||||||
|
from govoplan_files.backend.routes.folders import router as folders_router
|
||||||
|
from govoplan_files.backend.routes.listing import router as listing_router
|
||||||
|
from govoplan_files.backend.routes.shares import router as shares_router
|
||||||
|
from govoplan_files.backend.routes.spaces import router as spaces_router
|
||||||
|
from govoplan_files.backend.routes.transfers import router as transfers_router
|
||||||
|
from govoplan_files.backend.routes.uploads import router as uploads_router
|
||||||
|
|
||||||
|
|
||||||
class FilesRouterContractTests(unittest.TestCase):
|
class FilesRouterContractTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _operation_keys(candidate_router) -> list[tuple[str, str]]:
|
||||||
|
return [
|
||||||
|
(method, route.path)
|
||||||
|
for route in candidate_router.routes
|
||||||
|
for method in sorted(route.methods or ())
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_composed_router_contains_every_workflow_operation_once(self) -> None:
|
||||||
|
workflow_routers = (
|
||||||
|
spaces_router,
|
||||||
|
folders_router,
|
||||||
|
listing_router,
|
||||||
|
uploads_router,
|
||||||
|
connector_settings_router,
|
||||||
|
connector_io_router,
|
||||||
|
connector_profiles_router,
|
||||||
|
assets_router,
|
||||||
|
shares_router,
|
||||||
|
transfers_router,
|
||||||
|
)
|
||||||
|
expected = [
|
||||||
|
operation
|
||||||
|
for workflow_router in workflow_routers
|
||||||
|
for operation in self._operation_keys(workflow_router)
|
||||||
|
]
|
||||||
|
actual = self._operation_keys(router)
|
||||||
|
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
self.assertEqual(41, len(actual))
|
||||||
|
self.assertFalse(
|
||||||
|
[operation for operation, count in Counter(actual).items() if count > 1]
|
||||||
|
)
|
||||||
|
|
||||||
def test_connector_routes_keep_existing_api_paths(self) -> None:
|
def test_connector_routes_keep_existing_api_paths(self) -> None:
|
||||||
routes = {(tuple(sorted(route.methods or ())), route.path) for route in router.routes}
|
routes = {(tuple(sorted(route.methods or ())), route.path) for route in router.routes}
|
||||||
|
|
||||||
@@ -31,6 +77,18 @@ class FilesRouterContractTests(unittest.TestCase):
|
|||||||
self.assertIn((("POST",), "/files/bulk-rename"), routes)
|
self.assertIn((("POST",), "/files/bulk-rename"), routes)
|
||||||
self.assertIn((("POST",), "/files/transfer"), routes)
|
self.assertIn((("POST",), "/files/transfer"), routes)
|
||||||
|
|
||||||
|
def test_file_listing_exposes_structured_property_filters(self) -> None:
|
||||||
|
route = next(
|
||||||
|
route
|
||||||
|
for route in listing_router.routes
|
||||||
|
if route.path == "/files" and "GET" in (route.methods or ())
|
||||||
|
)
|
||||||
|
parameters = signature(route.endpoint).parameters
|
||||||
|
|
||||||
|
self.assertIn("campaign_usage", parameters)
|
||||||
|
self.assertIn("audit_relevant", parameters)
|
||||||
|
self.assertIn("total", route.response_model.model_fields)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
108
tests/test_tenant_summary_batch.py
Normal file
108
tests/test_tenant_summary_batch.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_files.backend.db.models import (
|
||||||
|
FileAsset,
|
||||||
|
FileConnectorCredential,
|
||||||
|
FileConnectorPolicy,
|
||||||
|
FileConnectorProfile,
|
||||||
|
FileConnectorSpace,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.manifest import _tenant_summary_batch
|
||||||
|
|
||||||
|
|
||||||
|
class FilesTenantSummaryBatchTests(unittest.TestCase):
|
||||||
|
def test_batch_summary_uses_one_grouped_query_per_owned_table(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
engine,
|
||||||
|
tables=[
|
||||||
|
Account.__table__,
|
||||||
|
User.__table__,
|
||||||
|
Group.__table__,
|
||||||
|
FileAsset.__table__,
|
||||||
|
FileConnectorCredential.__table__,
|
||||||
|
FileConnectorPolicy.__table__,
|
||||||
|
FileConnectorProfile.__table__,
|
||||||
|
FileConnectorSpace.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
FileAsset(
|
||||||
|
id="file-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
owner_type="tenant",
|
||||||
|
display_path="/one.txt",
|
||||||
|
filename="one.txt",
|
||||||
|
),
|
||||||
|
FileConnectorCredential(
|
||||||
|
id="credential-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
label="Credential",
|
||||||
|
),
|
||||||
|
FileConnectorPolicy(
|
||||||
|
id="policy-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
),
|
||||||
|
FileConnectorProfile(
|
||||||
|
id="profile-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
label="Profile",
|
||||||
|
provider="webdav",
|
||||||
|
),
|
||||||
|
FileConnectorSpace(
|
||||||
|
id="space-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
owner_type="tenant",
|
||||||
|
label="Space",
|
||||||
|
connector_profile_id="profile-1",
|
||||||
|
provider="webdav",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
query_count = 0
|
||||||
|
|
||||||
|
def count_query(*_args: object) -> None:
|
||||||
|
nonlocal query_count
|
||||||
|
query_count += 1
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", count_query)
|
||||||
|
try:
|
||||||
|
counts = _tenant_summary_batch(
|
||||||
|
session,
|
||||||
|
["tenant-1", "tenant-empty"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", count_query)
|
||||||
|
|
||||||
|
self.assertEqual(5, query_count)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"files": 1,
|
||||||
|
"connector_credentials": 1,
|
||||||
|
"connector_policies": 1,
|
||||||
|
"connector_profiles": 1,
|
||||||
|
"connector_spaces": 1,
|
||||||
|
},
|
||||||
|
counts["tenant-1"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, counts["tenant-empty"]["files"])
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"./styles/file-manager.css": "./src/styles/file-manager.css"
|
"./styles/file-manager.css": "./src/styles/file-manager.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs"
|
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs",
|
||||||
|
"test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -22,7 +23,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"
|
||||||
},
|
},
|
||||||
|
|||||||
36
webui/scripts/test-file-property-filters-structure.mjs
Normal file
36
webui/scripts/test-file-property-filters-structure.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const page = readFileSync(new URL("../src/features/files/FilesPage.tsx", import.meta.url), "utf8").replace(/\s+/g, " ");
|
||||||
|
const api = readFileSync(new URL("../src/api/files.ts", import.meta.url), "utf8").replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
function assertIncludes(source, fragment, message) {
|
||||||
|
if (!source.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertIncludes(
|
||||||
|
api,
|
||||||
|
"campaign_usage?: FileCampaignUsageFilter; audit_relevant?: boolean;",
|
||||||
|
"the Files API must expose structured campaign and audit filters"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
api,
|
||||||
|
"do { const response = await listFiles",
|
||||||
|
"property filtering must traverse every server result page"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
page,
|
||||||
|
"if (propertyFiltersActive && propertyFilterResults) return propertyFilterResults;",
|
||||||
|
"server-filtered rows must replace, not narrow, the currently loaded explorer window"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
page,
|
||||||
|
'<option value="unlinked">Not linked or used</option>',
|
||||||
|
"the high-priority unlinked campaign-file filter must remain available"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
page,
|
||||||
|
"setPropertyFilterTotal(response.total);",
|
||||||
|
"the UI must retain the exact server-side filtered count"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Files property filters cover the complete server result window.");
|
||||||
@@ -297,7 +297,8 @@ export type ManagedFile = {
|
|||||||
shares?: FileShare[];
|
shares?: FileShare[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileListResponse = {files: ManagedFile[];cursor?: string | null;next_cursor?: string | null;watermark?: string | null;};
|
export type FileCampaignUsageFilter = "linked" | "unlinked";
|
||||||
|
export type FileListResponse = {files: ManagedFile[];total: number;cursor?: string | null;next_cursor?: string | null;watermark?: string | null;};
|
||||||
export type FileDeltaDeletedItem = {id: string;resource_type?: string | null;revision?: string | null;deleted_at?: string | null;};
|
export type FileDeltaDeletedItem = {id: string;resource_type?: string | null;revision?: string | null;deleted_at?: string | null;};
|
||||||
export type FileDeltaResponse = {
|
export type FileDeltaResponse = {
|
||||||
files: ManagedFile[];
|
files: ManagedFile[];
|
||||||
@@ -383,15 +384,43 @@ payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?:
|
|||||||
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
|
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;page_size?: number;cursor?: string | null;} = {}): Promise<FileListResponse> {
|
export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;page_size?: number;cursor?: string | null;} = {}): Promise<FileListResponse> {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
if (value) search.set(key, String(value));
|
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
|
||||||
}
|
}
|
||||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
|
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listFilesByProperties(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {
|
||||||
|
owner_type: "user" | "group";
|
||||||
|
owner_id: string;
|
||||||
|
path_prefix?: string;
|
||||||
|
campaign_usage?: FileCampaignUsageFilter;
|
||||||
|
audit_relevant?: boolean;
|
||||||
|
page_size?: number;
|
||||||
|
})
|
||||||
|
: Promise<{files: ManagedFile[];total: number;}> {
|
||||||
|
const pageSize = params.page_size ?? DEFAULT_MANAGED_FILE_WINDOW_SIZE;
|
||||||
|
let cursor: string | null | undefined = null;
|
||||||
|
let total = 0;
|
||||||
|
let files: ManagedFile[] = [];
|
||||||
|
do {
|
||||||
|
const response = await listFiles(settings, {
|
||||||
|
...params,
|
||||||
|
page_size: pageSize,
|
||||||
|
cursor
|
||||||
|
});
|
||||||
|
files = files.concat(response.files);
|
||||||
|
total = response.total;
|
||||||
|
cursor = response.next_cursor;
|
||||||
|
} while (cursor);
|
||||||
|
return { files, total };
|
||||||
|
}
|
||||||
|
|
||||||
export function listFilesDelta(
|
export function listFilesDelta(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {})
|
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {})
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -225,6 +235,79 @@ export default function FileConnectorSettingsPanel({
|
|||||||
() => credentials.filter((credential) => connectorBelongsToEditableScope(credential, scopeType, activeScopeId)),
|
() => credentials.filter((credential) => connectorBelongsToEditableScope(credential, scopeType, activeScopeId)),
|
||||||
[activeScopeId, credentials, scopeType]
|
[activeScopeId, credentials, scopeType]
|
||||||
);
|
);
|
||||||
|
const selectableCredentials = useMemo(
|
||||||
|
() => credentials.filter((credential) =>
|
||||||
|
connectorBelongsToEditableScope(credential, scopeType, activeScopeId) ||
|
||||||
|
credential.source_kind === "credential_envelope" &&
|
||||||
|
reusableCredentialAvailableAtScope(credential, scopeType, activeScopeId)
|
||||||
|
),
|
||||||
|
[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);
|
||||||
@@ -424,7 +507,7 @@ export default function FileConnectorSettingsPanel({
|
|||||||
endpoint_url: cleanOrNull(draft.endpointUrl),
|
endpoint_url: cleanOrNull(draft.endpointUrl),
|
||||||
base_path: cleanOrNull(draft.basePath),
|
base_path: cleanOrNull(draft.basePath),
|
||||||
enabled: draft.enabled,
|
enabled: draft.enabled,
|
||||||
credential_profile_id: cleanOrNull(credentialProfileIdForDraft(draft, scopedCredentials)),
|
credential_profile_id: cleanOrNull(credentialProfileIdForDraft(draft, selectableCredentials)),
|
||||||
credential_mode: draft.credentialMode,
|
credential_mode: draft.credentialMode,
|
||||||
capabilities,
|
capabilities,
|
||||||
policy: policyFromDraft(draft),
|
policy: policyFromDraft(draft),
|
||||||
@@ -694,7 +777,7 @@ export default function FileConnectorSettingsPanel({
|
|||||||
const panelTitle = title ?? i18nMessage("i18n:govoplan-files.value_file_connections", { value0: scopeLabel(scopeType) });
|
const panelTitle = title ?? i18nMessage("i18n:govoplan-files.value_file_connections", { value0: scopeLabel(scopeType) });
|
||||||
const selectedCredentialIds = new Set(scopedProfiles.map((profile) => profile.credential_profile_id).filter((value): value is string => Boolean(value)));
|
const selectedCredentialIds = new Set(scopedProfiles.map((profile) => profile.credential_profile_id).filter((value): value is string => Boolean(value)));
|
||||||
const firstCompatibleProfileByCredential = new Map<string, string>();
|
const firstCompatibleProfileByCredential = new Map<string, string>();
|
||||||
for (const credential of scopedCredentials) {
|
for (const credential of selectableCredentials) {
|
||||||
const selectedProfile = scopedProfiles.find((profile) => profile.credential_profile_id === credential.id);
|
const selectedProfile = scopedProfiles.find((profile) => profile.credential_profile_id === credential.id);
|
||||||
const fallbackProfile = selectedProfile ?? scopedProfiles.find((profile) => !selectedCredentialIds.has(credential.id) && credentialMatchesProfile(credential, profile));
|
const fallbackProfile = selectedProfile ?? scopedProfiles.find((profile) => !selectedCredentialIds.has(credential.id) && credentialMatchesProfile(credential, profile));
|
||||||
if (fallbackProfile) firstCompatibleProfileByCredential.set(credential.id, fallbackProfile.id);
|
if (fallbackProfile) firstCompatibleProfileByCredential.set(credential.id, fallbackProfile.id);
|
||||||
@@ -717,8 +800,8 @@ export default function FileConnectorSettingsPanel({
|
|||||||
const credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud");
|
const credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud");
|
||||||
const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported;
|
const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported;
|
||||||
const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported);
|
const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported);
|
||||||
const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(scopedCredentials, draft) : [];
|
const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(selectableCredentials, draft) : [];
|
||||||
const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, scopedCredentials) : "";
|
const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : "";
|
||||||
const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [
|
const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [
|
||||||
{
|
{
|
||||||
id: "connection",
|
id: "connection",
|
||||||
@@ -755,7 +838,7 @@ export default function FileConnectorSettingsPanel({
|
|||||||
|
|
||||||
function connectorChildren(row: ConnectorTreeRow): ConnectorTreeRow[] {
|
function connectorChildren(row: ConnectorTreeRow): ConnectorTreeRow[] {
|
||||||
if (row.kind !== "profile") return [];
|
if (row.kind !== "profile") return [];
|
||||||
return scopedCredentials.
|
return selectableCredentials.
|
||||||
filter((credential) => firstCompatibleProfileByCredential.get(credential.id) === row.profile.id).
|
filter((credential) => firstCompatibleProfileByCredential.get(credential.id) === row.profile.id).
|
||||||
map((credential) => ({ kind: "credential" as const, id: `credential:${row.profile.id}:${credential.id}`, credential, profile: row.profile }));
|
map((credential) => ({ kind: "credential" as const, id: `credential:${row.profile.id}:${credential.id}`, credential, profile: row.profile }));
|
||||||
}
|
}
|
||||||
@@ -857,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/*"} />
|
||||||
@@ -887,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 })} />
|
||||||
@@ -1432,10 +1577,39 @@ function connectorCredentialDraftKey(draft: ConnectorCredentialDraft): string {
|
|||||||
function credentialOptionsForProfileDraft(credentials: FileConnectorCredential[], draft: ConnectorProfileDraft): FileConnectorCredential[] {
|
function credentialOptionsForProfileDraft(credentials: FileConnectorCredential[], draft: ConnectorProfileDraft): FileConnectorCredential[] {
|
||||||
return credentials.filter((credential) =>
|
return credentials.filter((credential) =>
|
||||||
credential.id === draft.credentialProfileId ||
|
credential.id === draft.credentialProfileId ||
|
||||||
credential.enabled && (!credential.provider || credential.provider === draft.provider)
|
credential.enabled &&
|
||||||
|
(!credential.provider || credential.provider === draft.provider) &&
|
||||||
|
reusableCredentialAllowsProfile(credential, draft.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reusableCredentialAvailableAtScope(
|
||||||
|
credential: FileConnectorCredential,
|
||||||
|
scopeType: ConnectorScope,
|
||||||
|
scopeId: string | null
|
||||||
|
): boolean {
|
||||||
|
if (connectorBelongsToEditableScope(credential, scopeType, scopeId)) return true;
|
||||||
|
if (credential.source_kind !== "credential_envelope") return false;
|
||||||
|
const inherited = credential.metadata?.inherit_to_lower_scopes === true;
|
||||||
|
if (!inherited) return false;
|
||||||
|
if (credential.scope_type === "system") return scopeType !== "system";
|
||||||
|
if (credential.scope_type === "tenant") {
|
||||||
|
return scopeType === "group" || scopeType === "user";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reusableCredentialAllowsProfile(
|
||||||
|
credential: FileConnectorCredential,
|
||||||
|
profileId: string
|
||||||
|
): boolean {
|
||||||
|
if (credential.source_kind !== "credential_envelope") return true;
|
||||||
|
const refs = Array.isArray(credential.metadata?.allowed_server_refs)
|
||||||
|
? credential.metadata.allowed_server_refs.filter((value): value is string => typeof value === "string")
|
||||||
|
: [];
|
||||||
|
return refs.length === 0 || refs.includes(`files:${profileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
function credentialProfileIdForDraft(draft: ConnectorProfileDraft, credentials: FileConnectorCredential[]): string {
|
function credentialProfileIdForDraft(draft: ConnectorProfileDraft, credentials: FileConnectorCredential[]): string {
|
||||||
const options = credentialOptionsForProfileDraft(credentials, draft);
|
const options = credentialOptionsForProfileDraft(credentials, draft);
|
||||||
if (draft.credentialProfileId && options.some((credential) => credential.id === draft.credentialProfileId)) {
|
if (draft.credentialProfileId && options.some((credential) => credential.id === draft.credentialProfileId)) {
|
||||||
@@ -1656,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,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
|
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, KeyRound, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
|
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
downloadFilesAsZip,
|
downloadFilesAsZip,
|
||||||
fetchResourceAccessExplanation,
|
fetchResourceAccessExplanation,
|
||||||
listFilesDelta,
|
listFilesDelta,
|
||||||
|
listFilesByProperties,
|
||||||
listFileConnectorProfiles,
|
listFileConnectorProfiles,
|
||||||
listFileSpaces,
|
listFileSpaces,
|
||||||
listManagedFileSnapshot,
|
listManagedFileSnapshot,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
type FileConnectorBrowseItem,
|
type FileConnectorBrowseItem,
|
||||||
type FileConnectorProfile,
|
type FileConnectorProfile,
|
||||||
type FileDeltaResponse,
|
type FileDeltaResponse,
|
||||||
|
type FileCampaignUsageFilter,
|
||||||
type FileFolder,
|
type FileFolder,
|
||||||
type FileSpace,
|
type FileSpace,
|
||||||
type ManagedFile,
|
type ManagedFile,
|
||||||
@@ -88,6 +90,7 @@ import { useFileDialogs } from "./hooks/useFileDialogs";
|
|||||||
import { useFileDragDropState } from "./hooks/useFileDragDropState";
|
import { useFileDragDropState } from "./hooks/useFileDragDropState";
|
||||||
|
|
||||||
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
|
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
|
||||||
|
type AuditRelevantFilter = "" | "true" | "false";
|
||||||
type FileAccessExplanationTarget = {
|
type FileAccessExplanationTarget = {
|
||||||
resourceType: "file" | "folder";
|
resourceType: "file" | "folder";
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@@ -118,6 +121,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
||||||
const [searchActive, setSearchActive] = useState(false);
|
const [searchActive, setSearchActive] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<ManagedFile[] | null>(null);
|
const [searchResults, setSearchResults] = useState<ManagedFile[] | null>(null);
|
||||||
|
const [campaignUsageFilter, setCampaignUsageFilter] = useState<"" | FileCampaignUsageFilter>("");
|
||||||
|
const [auditRelevantFilter, setAuditRelevantFilter] = useState<AuditRelevantFilter>("");
|
||||||
|
const [propertyFiltersActive, setPropertyFiltersActive] = useState(false);
|
||||||
|
const [propertyFilterResults, setPropertyFilterResults] = useState<ManagedFile[] | null>(null);
|
||||||
|
const [propertyFilterTotal, setPropertyFilterTotal] = useState<number | null>(null);
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||||
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
|
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
|
||||||
@@ -176,11 +184,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
const [renameSuffix, setRenameSuffix] = useState("");
|
const [renameSuffix, setRenameSuffix] = useState("");
|
||||||
const [renameRecursive, setRenameRecursive] = useState(false);
|
const [renameRecursive, setRenameRecursive] = useState(false);
|
||||||
const [renamePreview, setRenamePreview] = useState<RenameResponse | null>(null);
|
const [renamePreview, setRenamePreview] = useState<RenameResponse | null>(null);
|
||||||
const visibleFiles = searchActive && searchResults ? searchResults : files;
|
const visibleFiles = useMemo(() => {
|
||||||
|
if (searchActive && searchResults && propertyFiltersActive && propertyFilterResults) {
|
||||||
|
const ids = new Set(propertyFilterResults.map((file) => file.id));
|
||||||
|
return searchResults.filter((file) => ids.has(file.id));
|
||||||
|
}
|
||||||
|
if (propertyFiltersActive && propertyFilterResults) return propertyFilterResults;
|
||||||
|
if (searchActive && searchResults) return searchResults;
|
||||||
|
return files;
|
||||||
|
}, [files, propertyFilterResults, propertyFiltersActive, searchActive, searchResults]);
|
||||||
const explorerEntries = useMemo(() => {
|
const explorerEntries = useMemo(() => {
|
||||||
const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive);
|
const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive || propertyFiltersActive);
|
||||||
return sortExplorerEntries(entries, sortColumn, sortDirection);
|
return sortExplorerEntries(entries, sortColumn, sortDirection);
|
||||||
}, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]);
|
}, [visibleFiles, folders, currentFolder, searchActive, propertyFiltersActive, sortColumn, sortDirection]);
|
||||||
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
|
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
|
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
|
||||||
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(new Set());
|
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(new Set());
|
||||||
@@ -285,7 +301,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
viewport.scrollTop = 0;
|
viewport.scrollTop = 0;
|
||||||
setFileListScrollTop(0);
|
setFileListScrollTop(0);
|
||||||
}, [activeSpaceId, currentFolder, searchActive, searchResults, sortColumn, sortDirection]);
|
}, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]);
|
||||||
|
|
||||||
async function loadSpaces() {
|
async function loadSpaces() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -373,6 +389,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSearchActive(false);
|
setSearchActive(false);
|
||||||
setSearchPattern("");
|
setSearchPattern("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
setCampaignUsageFilter("");
|
||||||
|
setAuditRelevantFilter("");
|
||||||
|
setPropertyFiltersActive(false);
|
||||||
|
setPropertyFilterResults(null);
|
||||||
|
setPropertyFilterTotal(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -410,6 +431,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSearchPattern("");
|
setSearchPattern("");
|
||||||
setSearchActive(false);
|
setSearchActive(false);
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
setCampaignUsageFilter("");
|
||||||
|
setAuditRelevantFilter("");
|
||||||
|
setPropertyFiltersActive(false);
|
||||||
|
setPropertyFilterResults(null);
|
||||||
|
setPropertyFilterTotal(null);
|
||||||
setSelectedFileIds(new Set());
|
setSelectedFileIds(new Set());
|
||||||
setSelectedFolderPaths(new Set());
|
setSelectedFolderPaths(new Set());
|
||||||
setConnectorSpaceSelectedItem(null);
|
setConnectorSpaceSelectedItem(null);
|
||||||
@@ -431,6 +457,11 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSearchActive(false);
|
setSearchActive(false);
|
||||||
setSearchPattern("");
|
setSearchPattern("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
setCampaignUsageFilter("");
|
||||||
|
setAuditRelevantFilter("");
|
||||||
|
setPropertyFiltersActive(false);
|
||||||
|
setPropertyFilterResults(null);
|
||||||
|
setPropertyFilterTotal(null);
|
||||||
}
|
}
|
||||||
setUnmatchedCount(null);
|
setUnmatchedCount(null);
|
||||||
setRenamePreview(null);
|
setRenamePreview(null);
|
||||||
@@ -1001,6 +1032,50 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
setSelectedFolderPaths(new Set());
|
setSelectedFolderPaths(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runPropertyFilters(
|
||||||
|
campaignUsage: "" | FileCampaignUsageFilter,
|
||||||
|
auditRelevant: AuditRelevantFilter
|
||||||
|
) {
|
||||||
|
if (!activeSpace || activeSpaceIsConnector) return;
|
||||||
|
if (!campaignUsage && !auditRelevant) {
|
||||||
|
clearPropertyFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await listFilesByProperties(settings, {
|
||||||
|
owner_type: activeSpace.owner_type,
|
||||||
|
owner_id: activeSpace.owner_id,
|
||||||
|
path_prefix: currentFolder,
|
||||||
|
campaign_usage: campaignUsage || undefined,
|
||||||
|
audit_relevant: auditRelevant ? auditRelevant === "true" : undefined
|
||||||
|
});
|
||||||
|
setCampaignUsageFilter(campaignUsage);
|
||||||
|
setAuditRelevantFilter(auditRelevant);
|
||||||
|
setPropertyFilterResults(response.files);
|
||||||
|
setPropertyFilterTotal(response.total);
|
||||||
|
setPropertyFiltersActive(true);
|
||||||
|
setSelectedFileIds(new Set());
|
||||||
|
setSelectedFolderPaths(new Set());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPropertyFilters() {
|
||||||
|
setCampaignUsageFilter("");
|
||||||
|
setAuditRelevantFilter("");
|
||||||
|
setPropertyFiltersActive(false);
|
||||||
|
setPropertyFilterResults(null);
|
||||||
|
setPropertyFilterTotal(null);
|
||||||
|
setSelectedFileIds(new Set());
|
||||||
|
setSelectedFolderPaths(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
function deleteSelected(
|
function deleteSelected(
|
||||||
fileIds: Set<string> = selectedFileIds,
|
fileIds: Set<string> = selectedFileIds,
|
||||||
folderPaths: Set<string> = selectedFolderPaths,
|
folderPaths: Set<string> = selectedFolderPaths,
|
||||||
@@ -2067,6 +2142,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
disabled={!activeSpace || activeSpaceIsConnector}
|
disabled={!activeSpace || activeSpaceIsConnector}
|
||||||
onSearch={(patternValue, caseSensitiveValue) => void runPatternSearch(patternValue, caseSensitiveValue)}
|
onSearch={(patternValue, caseSensitiveValue) => void runPatternSearch(patternValue, caseSensitiveValue)}
|
||||||
onClear={() => void clearSearch()} />
|
onClear={() => void clearSearch()} />
|
||||||
|
<FilePropertyFilterRow
|
||||||
|
campaignUsage={campaignUsageFilter}
|
||||||
|
auditRelevant={auditRelevantFilter}
|
||||||
|
busy={busy}
|
||||||
|
active={propertyFiltersActive}
|
||||||
|
disabled={!activeSpace || activeSpaceIsConnector}
|
||||||
|
total={propertyFilterTotal}
|
||||||
|
onApply={(campaignUsage, auditRelevant) => void runPropertyFilters(campaignUsage, auditRelevant)}
|
||||||
|
onClear={clearPropertyFilters} />
|
||||||
|
|
||||||
<div className="file-list-meta">
|
<div className="file-list-meta">
|
||||||
<span>{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary}</span>
|
<span>{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary}</span>
|
||||||
@@ -2509,6 +2593,72 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilePropertyFilterRow({
|
||||||
|
campaignUsage,
|
||||||
|
auditRelevant,
|
||||||
|
busy,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
total,
|
||||||
|
onApply,
|
||||||
|
onClear
|
||||||
|
}: {
|
||||||
|
campaignUsage: "" | FileCampaignUsageFilter;
|
||||||
|
auditRelevant: AuditRelevantFilter;
|
||||||
|
busy: boolean;
|
||||||
|
active: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
total: number | null;
|
||||||
|
onApply: (campaignUsage: "" | FileCampaignUsageFilter, auditRelevant: AuditRelevantFilter) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const [campaignUsageDraft, setCampaignUsageDraft] = useState(campaignUsage);
|
||||||
|
const [auditRelevantDraft, setAuditRelevantDraft] = useState(auditRelevant);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCampaignUsageDraft(campaignUsage);
|
||||||
|
}, [campaignUsage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAuditRelevantDraft(auditRelevant);
|
||||||
|
}, [auditRelevant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-property-filter-row">
|
||||||
|
<ListFilter size={17} aria-hidden="true" />
|
||||||
|
<label className="file-property-filter-field">
|
||||||
|
<span>Campaign use</span>
|
||||||
|
<select
|
||||||
|
value={campaignUsageDraft}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onChange={(event) => setCampaignUsageDraft(event.target.value as "" | FileCampaignUsageFilter)}>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="linked">Linked or used</option>
|
||||||
|
<option value="unlinked">Not linked or used</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="file-property-filter-field">
|
||||||
|
<span>Audit evidence</span>
|
||||||
|
<select
|
||||||
|
value={auditRelevantDraft}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onChange={(event) => setAuditRelevantDraft(event.target.value as AuditRelevantFilter)}>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="true">Audit relevant</option>
|
||||||
|
<option value="false">Not audit relevant</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
onClick={() => onApply(campaignUsageDraft, auditRelevantDraft)}
|
||||||
|
disabled={busy || disabled || !campaignUsageDraft && !auditRelevantDraft}>
|
||||||
|
Apply filters
|
||||||
|
</Button>
|
||||||
|
{active && <Button onClick={onClear} disabled={busy}>Clear filters</Button>}
|
||||||
|
{active && total !== null && <span className="file-property-filter-count">{total} matching file{total === 1 ? "" : "s"}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FileSearchRow({
|
function FileSearchRow({
|
||||||
value,
|
value,
|
||||||
caseSensitive,
|
caseSensitive,
|
||||||
|
|||||||
@@ -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,10 +22,55 @@ 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: [
|
||||||
{
|
{
|
||||||
id: "system-file-connectors",
|
id: "system-file-connectors",
|
||||||
|
surfaceId: "files.admin.system-connectors",
|
||||||
label: "i18n:govoplan-files.file_connections.1e362326",
|
label: "i18n:govoplan-files.file_connections.1e362326",
|
||||||
group: "SYSTEM",
|
group: "SYSTEM",
|
||||||
order: 75,
|
order: 75,
|
||||||
@@ -30,6 +83,7 @@ const fileConnectorAdminSections: AdminSectionsUiCapability = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tenant-file-connectors",
|
id: "tenant-file-connectors",
|
||||||
|
surfaceId: "files.admin.tenant-connectors",
|
||||||
label: "i18n:govoplan-files.file_connections.1e362326",
|
label: "i18n:govoplan-files.file_connections.1e362326",
|
||||||
group: "TENANT",
|
group: "TENANT",
|
||||||
order: 65,
|
order: 65,
|
||||||
@@ -49,6 +103,14 @@ export const filesModule: PlatformWebModule = {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "files.admin.system-connectors", moduleId: "files", kind: "section", label: "System file connections", order: 75 },
|
||||||
|
{ 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.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.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: [
|
||||||
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }],
|
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }],
|
||||||
@@ -67,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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,31 @@
|
|||||||
padding: 4px 0 10px 14px;
|
padding: 4px 0 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-property-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 0 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-property-filter-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-property-filter-field > span,
|
||||||
|
.file-property-filter-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-property-filter-count {
|
||||||
|
align-self: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.file-list-drop-target.is-active,
|
.file-list-drop-target.is-active,
|
||||||
.file-list-drop-target.is-drop-target {
|
.file-list-drop-target.is-drop-target {
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
@@ -565,6 +590,15 @@
|
|||||||
.file-search-row {
|
.file-search-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-property-filter-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-property-filter-count {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-conflict-summary {
|
.file-conflict-summary {
|
||||||
|
|||||||
Reference in New Issue
Block a user