feat: govern file lifecycle and connector writes

This commit is contained in:
2026-08-20 22:06:57 +02:00
parent 95aef18955
commit 6c3cf1c55e
27 changed files with 2721 additions and 65 deletions
@@ -5,11 +5,14 @@ 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_core.audit.logging import audit_from_principal
from govoplan_files.backend.schemas import (
FileConnectorBrowseItem,
FileConnectorBrowseResponse,
FileConnectorImportRequest,
FileConnectorSyncResponse,
FileConnectorWriteRequest,
FileConnectorWriteResponse,
FileUploadResponse,
)
from govoplan_core.db.session import get_session
@@ -35,8 +38,14 @@ from govoplan_files.backend.storage.connector_policy import (
)
from govoplan_files.backend.storage.files import (
create_file_asset,
get_asset_for_user,
read_asset_bytes,
sync_file_asset_from_source,
)
from govoplan_files.backend.storage.connector_spaces import (
get_connector_space_for_user,
)
from govoplan_files.backend.storage.connector_writes import write_connector_file
from govoplan_files.backend.route_support import (
@@ -55,6 +64,112 @@ from govoplan_files.backend.route_support import (
router = APIRouter(prefix="/files", tags=["files"])
@router.post(
"/connector-spaces/{space_id}/write-back",
response_model=FileConnectorWriteResponse,
)
def write_back_connector_file(
space_id: str,
payload: FileConnectorWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:connector:write")),
):
try:
space = get_connector_space_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
space_id=space_id,
is_admin=_is_admin(principal),
)
if space.read_only:
raise FileStorageError("This connector space is read-only")
profile = _visible_connector_profile(
session, principal, space.connector_profile_id
)
requested_path = normalize_connector_browse_path(payload.remote_path)
remote_path = normalize_connector_browse_path(
"/".join(part for part in (space.remote_path, requested_path) if part)
)
decision = connector_policy_decision(
ConnectorAccessRequest(
connector_id=profile.id,
credential_id=profile.credential_profile_id,
provider=profile.provider,
external_path=remote_path,
external_url=connector_effective_endpoint_url(
provider=profile.provider,
endpoint_url=profile.endpoint_url,
metadata=profile.metadata,
),
operation="write",
),
profile.policy_sources,
)
if not decision.allowed:
raise ConnectorPolicyDenied(decision)
asset = get_asset_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
asset_id=payload.file_id,
require_write=True,
is_admin=_is_admin(principal),
)
data, version, blob = read_asset_bytes(session, asset)
connector_library_id = space.library_id
content_type = blob.content_type
# Close the read snapshot before the independent recovery transaction
# records authority for the external effect. This avoids upgrading an
# older SQLite read snapshot after the ledger commit.
session.commit()
result = write_connector_file(
profile,
tenant_id=principal.tenant_id,
library_id=connector_library_id,
remote_path=remote_path,
data=data,
content_type=content_type,
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
audit_from_principal(
session,
principal,
action="files.connector.written",
object_type="file",
object_id=asset.id,
details={
"file_version_id": version.id,
"file_blob_id": blob.id,
"checksum_sha256": blob.checksum_sha256,
"connector_space_id": space.id,
"connector_profile_id": profile.id,
"remote_path": remote_path,
"recovery_operation_id": result.recovery_operation_id,
"recovery_status": result.status,
"revision": result.revision,
},
)
session.commit()
return FileConnectorWriteResponse(
recovery_operation_id=result.recovery_operation_id,
status=result.status,
replayed=result.replayed,
provider=result.provider,
remote_path=result.remote_path,
revision=result.revision,
checksum_sha256=result.checksum_sha256,
size_bytes=result.size_bytes,
)
except ConnectorPolicyDenied as exc:
session.rollback()
raise _connector_policy_error(exc) from exc
except (FileStorageError, ConnectorBrowseError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post(
"/connectors/profiles/{profile_id}/import", response_model=FileUploadResponse
)