feat: harden file sharing and integrity

This commit is contained in:
2026-07-30 14:26:47 +02:00
parent 85606d5580
commit 835eacfc5d
28 changed files with 2714 additions and 102 deletions
+117 -1
View File
@@ -17,6 +17,22 @@ class StorageBackendError(RuntimeError):
pass
class StorageObjectMissing(StorageBackendError):
pass
@dataclass(frozen=True, slots=True)
class StorageObjectInfo:
key: str
size_bytes: int
@dataclass(frozen=True, slots=True)
class StorageObjectPage:
objects: tuple[StorageObjectInfo, ...]
next_cursor: str | None = None
class StorageBackend(Protocol):
name: str
@@ -25,6 +41,14 @@ class StorageBackend(Protocol):
def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: ...
def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ...
def stat(self, key: str) -> StorageObjectInfo: ...
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage: ...
@dataclass(slots=True)
@@ -55,7 +79,7 @@ class LocalFilesystemStorageBackend:
candidate = self._path_for_root(root, key)
if candidate.exists() and candidate.is_file():
return candidate
raise StorageBackendError("Stored object does not exist")
raise StorageObjectMissing("Stored object does not exist")
def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None:
path = self._path(key)
@@ -86,6 +110,33 @@ class LocalFilesystemStorageBackend:
return False
return True
def stat(self, key: str) -> StorageObjectInfo:
path = self._readable_path(key)
return StorageObjectInfo(key=key, size_bytes=path.stat().st_size)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_limit = max(1, min(int(limit), 5000))
candidates: list[StorageObjectInfo] = []
for path in _iter_local_files(self.root):
key = path.relative_to(self.root).as_posix()
if not key.startswith(prefix) or (after is not None and key <= after):
continue
candidates.append(StorageObjectInfo(key=key, size_bytes=path.stat().st_size))
if len(candidates) > normalized_limit:
break
has_more = len(candidates) > normalized_limit
page = tuple(candidates[:normalized_limit])
return StorageObjectPage(
objects=page,
next_cursor=page[-1].key if has_more and page else None,
)
@dataclass(slots=True)
class S3StorageBackend:
@@ -175,6 +226,52 @@ class S3StorageBackend:
except Exception:
return False
def stat(self, key: str) -> StorageObjectInfo:
try:
response = self.client.head_object(Bucket=self.bucket, Key=key)
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
try:
size = int(response.get("ContentLength"))
except (AttributeError, TypeError, ValueError) as exc:
raise StorageBackendError("S3 object metadata did not include a valid size") from exc
return StorageObjectInfo(key=key, size_bytes=size)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_limit = max(1, min(int(limit), 1000))
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Prefix": prefix,
"MaxKeys": normalized_limit,
}
if after:
kwargs["StartAfter"] = after
try:
response = self.client.list_objects_v2(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
objects = tuple(
StorageObjectInfo(
key=str(item["Key"]),
size_bytes=int(item.get("Size") or 0),
)
for item in response.get("Contents", ())
if isinstance(item, dict) and item.get("Key")
)
has_more = bool(response.get("IsTruncated"))
return StorageObjectPage(
objects=objects,
next_cursor=objects[-1].key if has_more and objects else None,
)
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
if not isinstance(obj, dict):
@@ -187,6 +284,25 @@ def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
raise StorageBackendError(f"Stored object exceeds the deployment limit of {max_bytes} bytes")
def _iter_local_files(root: Path):
for entry in sorted(root.iterdir(), key=lambda item: item.name):
if entry.is_dir():
yield from _iter_local_files(entry)
elif entry.is_file():
yield entry
def _s3_missing_error(exc: Exception) -> bool:
response = getattr(exc, "response", None)
if not isinstance(response, dict):
return False
error = response.get("Error")
metadata = response.get("ResponseMetadata")
code = str(error.get("Code") if isinstance(error, dict) else "")
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404
def _fallback_roots() -> tuple[Path, ...]:
raw = getattr(settings, "file_storage_local_fallback_roots", "") or ""
return tuple(Path(item.strip()) for item in str(raw).split(",") if item.strip())
@@ -13,12 +13,17 @@ from typing import Any, Iterator
from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileAsset, FileBlob, FileShare, FileVersion
from govoplan_files.backend.storage.backends import StorageBackend, StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.backends import StorageBackend, get_storage_backend
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import current_versions_and_blobs, get_asset_for_user, list_assets_for_user, share_files
from govoplan_files.backend.storage.access import ensure_owner_access
from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component
from govoplan_files.backend.storage.provenance import source_provenance_from_metadata, source_revision_from_metadata
from govoplan_files.backend.storage.share_state import effective_file_share_clause
from govoplan_files.backend.storage.integrity import (
ensure_blob_is_readable,
read_verified_blob_bytes,
)
MANAGED_SOURCE_PREFIX = "managed:"
@@ -165,7 +170,7 @@ def _campaign_linked_asset_ids(
FileShare.file_asset_id.in_(chunk),
FileShare.target_type == "campaign",
FileShare.target_id == campaign_id,
FileShare.revoked_at.is_(None),
effective_file_share_clause(),
)
.all()
)
@@ -301,11 +306,9 @@ def _materialize_managed_asset(
target = _safe_local_target(local_root, relative_path)
target.parent.mkdir(parents=True, exist_ok=True)
version, blob = version_blobs[asset.id]
ensure_blob_is_readable(blob)
if include_bytes:
try:
data = backend.get_bytes(blob.storage_key) if backend else b""
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
data = read_verified_blob_bytes(blob, backend=backend) if backend else b""
target.write_bytes(data)
else:
target.touch()
+144 -12
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import mimetypes
from datetime import datetime
from pathlib import PurePosixPath
from typing import Any, Iterable
from uuid import uuid4
@@ -13,10 +14,19 @@ from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAc
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion
from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.backends import (
StorageBackendError,
StorageObjectMissing,
get_storage_backend,
)
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component
from govoplan_files.backend.storage.provenance import source_provenance_from_metadata
from govoplan_files.backend.storage.integrity import (
QUARANTINED_BLOB_STATUSES,
read_verified_blob_bytes,
)
from govoplan_files.backend.storage.share_state import effective_file_share_clause
def _campaign_access_provider() -> CampaignAccessProvider:
@@ -72,11 +82,25 @@ def _get_or_create_blob(
)
if blob:
backend = get_storage_backend()
if not backend.exists(blob.storage_key):
repair_required = (
blob.integrity_status in QUARANTINED_BLOB_STATUSES
or blob.quarantined_at is not None
)
try:
backend.stat(blob.storage_key)
except StorageObjectMissing:
repair_required = True
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
if repair_required:
try:
backend.put_bytes(blob.storage_key, data, content_type=content_type)
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
blob.integrity_status = "verified"
blob.integrity_checked_at = utcnow()
blob.integrity_failure = None
blob.quarantined_at = None
blob.ref_count += 1
session.add(blob)
return blob
@@ -96,6 +120,8 @@ def _get_or_create_blob(
size_bytes=size,
content_type=content_type,
ref_count=1,
integrity_status="verified",
integrity_checked_at=utcnow(),
)
session.add(blob)
session.flush()
@@ -328,7 +354,7 @@ def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_
.filter(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset.id,
FileShare.revoked_at.is_(None),
effective_file_share_clause(),
FileShare.permission.in_(permission_values),
or_(
(FileShare.target_type == "user") & (FileShare.target_id == user_id),
@@ -343,6 +369,32 @@ def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_
return asset
def get_asset_for_share_management(
session: Session,
*,
tenant_id: str,
user_id: str,
asset_id: str,
is_admin: bool = False,
) -> FileAsset:
asset = session.get(FileAsset, asset_id)
if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None:
raise FileStorageError("File not found")
if is_admin:
return asset
owns_asset = (
asset.owner_type == "user"
and asset.owner_user_id == user_id
) or (
asset.owner_type == "group"
and asset.owner_group_id
in user_group_ids(session, tenant_id=tenant_id, user_id=user_id)
)
if not owns_asset:
raise FileStorageError("Only file owners and administrators can manage shares")
return asset
def list_assets_for_user(
session: Session,
*,
@@ -444,7 +496,7 @@ def _asset_visibility_query_for_user(
FileShare.tenant_id == tenant_id,
FileShare.target_type == "campaign",
FileShare.target_id == campaign_id,
FileShare.revoked_at.is_(None),
effective_file_share_clause(),
)
elif not is_admin and not owner_type:
group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id)
@@ -452,9 +504,9 @@ def _asset_visibility_query_for_user(
or_(
(FileAsset.owner_type == "user") & (FileAsset.owner_user_id == user_id),
(FileAsset.owner_type == "group") & (FileAsset.owner_group_id.in_(group_ids)),
(FileShare.revoked_at.is_(None)) & (FileShare.target_type == "user") & (FileShare.target_id == user_id),
(FileShare.revoked_at.is_(None)) & (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)),
(FileShare.revoked_at.is_(None)) & (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id),
effective_file_share_clause() & (FileShare.target_type == "user") & (FileShare.target_id == user_id),
effective_file_share_clause() & (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)),
effective_file_share_clause() & (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id),
)
)
if path_prefix:
@@ -465,7 +517,7 @@ def _asset_visibility_query_for_user(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == FileAsset.id,
FileShare.target_type == "campaign",
FileShare.revoked_at.is_(None),
effective_file_share_clause(),
)
campaign_use_exists = exists().where(
CampaignAttachmentUse.tenant_id == tenant_id,
@@ -562,10 +614,7 @@ def current_versions_and_blobs(session: Session, assets: Iterable[FileAsset]) ->
def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]:
version, blob = current_version_and_blob(session, asset)
backend = get_storage_backend()
try:
return backend.get_bytes(blob.storage_key), version, blob
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
return read_verified_blob_bytes(blob, backend=backend), version, blob
def share_file(
@@ -577,6 +626,7 @@ def share_file(
target_id: str,
permission: str,
user_id: str,
expires_at: datetime | None = None,
) -> FileShare:
target_type = target_type.lower().strip()
permission = permission.lower().strip()
@@ -586,6 +636,7 @@ def share_file(
raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission")
_validate_share_expiry(expires_at)
if target_type in {"user", "group", "tenant"}:
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if target_type == "campaign":
@@ -603,6 +654,7 @@ def share_file(
)
if existing:
existing.permission = permission
existing.expires_at = expires_at
session.add(existing)
return existing
share = FileShare(
@@ -612,6 +664,7 @@ def share_file(
target_id=target_id,
permission=permission,
created_by_user_id=user_id,
expires_at=expires_at,
)
session.add(share)
return share
@@ -626,6 +679,7 @@ def share_files(
target_id: str,
permission: str,
user_id: str,
expires_at: datetime | None = None,
) -> list[FileShare]:
target_type = target_type.lower().strip()
permission = permission.lower().strip()
@@ -633,6 +687,7 @@ def share_files(
raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission")
_validate_share_expiry(expires_at)
if target_type in {"user", "group", "tenant"}:
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if target_type == "campaign":
@@ -660,6 +715,7 @@ def share_files(
existing = existing_by_asset.get(asset.id)
if existing:
existing.permission = permission
existing.expires_at = expires_at
session.add(existing)
shares.append(existing)
continue
@@ -670,12 +726,88 @@ def share_files(
target_id=target_id,
permission=permission,
created_by_user_id=user_id,
expires_at=expires_at,
)
session.add(share)
shares.append(share)
return shares
def list_file_shares(
session: Session,
*,
tenant_id: str,
asset_id: str,
include_inactive: bool = False,
) -> list[FileShare]:
query = session.query(FileShare).filter(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset_id,
)
if not include_inactive:
query = query.filter(effective_file_share_clause())
return query.order_by(FileShare.created_at.desc(), FileShare.id.desc()).all()
def revoke_file_share(
session: Session,
*,
tenant_id: str,
asset_id: str,
share_id: str,
user_id: str,
) -> tuple[FileShare, bool]:
share = (
session.query(FileShare)
.filter(
FileShare.id == share_id,
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset_id,
)
.one_or_none()
)
if share is None:
raise FileStorageError("File share not found")
if share.revoked_at is not None:
return share, False
share.revoked_at = utcnow()
share.revoked_by_user_id = user_id
session.add(share)
return share, True
def current_file_share_for_target(
session: Session,
*,
tenant_id: str,
asset_id: str,
target_type: str,
target_id: str,
) -> FileShare | None:
return (
session.query(FileShare)
.filter(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset_id,
FileShare.target_type == target_type.lower().strip(),
FileShare.target_id == target_id,
FileShare.revoked_at.is_(None),
)
.order_by(FileShare.created_at.desc(), FileShare.id.desc())
.first()
)
def _validate_share_expiry(expires_at: datetime | None) -> None:
if expires_at is None:
return
from govoplan_files.backend.storage.share_state import file_share_is_active
candidate = FileShare(expires_at=expires_at)
if not file_share_is_active(candidate):
raise FileStorageError("File share expiry must be in the future")
def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int:
count = 0
@@ -0,0 +1,512 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import (
FileBlob,
FileIntegrityFinding,
FileIntegrityScan,
)
from govoplan_files.backend.storage.backends import (
StorageBackend,
StorageBackendError,
StorageObjectMissing,
get_storage_backend,
)
from govoplan_files.backend.storage.common import FileStorageError, utcnow
TERMINAL_SCAN_STATUSES = {"completed", "cancelled"}
QUARANTINED_BLOB_STATUSES = {
"missing",
"size_mismatch",
"checksum_mismatch",
}
@dataclass(frozen=True, slots=True)
class BlobInspection:
valid: bool
kind: str
observed_size_bytes: int | None = None
observed_checksum_sha256: str | None = None
@dataclass(frozen=True, slots=True)
class IntegrityActionResult:
action: str
changed: bool
dry_run: bool
finding: FileIntegrityFinding
inspection: BlobInspection | None = None
def storage_prefix_for_tenant(tenant_id: str) -> str:
return f"tenants/{tenant_id}/files/"
def create_integrity_scan(
session: Session,
*,
tenant_id: str,
user_id: str,
verify_checksums: bool = True,
batch_size: int = 100,
backend: StorageBackend | None = None,
) -> FileIntegrityScan:
active_backend = backend or get_storage_backend()
scan = FileIntegrityScan(
tenant_id=tenant_id,
storage_backend=active_backend.name,
storage_prefix=storage_prefix_for_tenant(tenant_id),
verify_checksums=verify_checksums,
batch_size=max(1, min(int(batch_size), 1000)),
created_by_user_id=user_id,
)
session.add(scan)
session.flush()
return scan
def run_integrity_scan_batch(
session: Session,
scan: FileIntegrityScan,
*,
backend: StorageBackend | None = None,
) -> FileIntegrityScan:
if scan.status in TERMINAL_SCAN_STATUSES:
return scan
active_backend = backend or get_storage_backend()
if active_backend.name != scan.storage_backend:
raise FileStorageError(
"The configured storage backend changed after this integrity scan started"
)
if scan.started_at is None:
scan.started_at = utcnow()
scan.status = "running"
scan.last_error = None
if scan.phase == "blobs":
_scan_blob_batch(session, scan, active_backend)
elif scan.phase == "objects":
_scan_object_batch(session, scan, active_backend)
else:
scan.phase = "completed"
scan.status = "completed"
scan.completed_at = utcnow()
session.add(scan)
return scan
def mark_integrity_scan_failed(
scan: FileIntegrityScan,
*,
error: Exception,
) -> None:
scan.status = "failed"
scan.last_error = type(error).__name__[:255]
def inspect_blob(
blob: FileBlob,
*,
backend: StorageBackend,
verify_checksum: bool = True,
) -> BlobInspection:
try:
info = backend.stat(blob.storage_key)
except StorageObjectMissing:
return BlobInspection(valid=False, kind="missing")
if info.size_bytes != blob.size_bytes:
return BlobInspection(
valid=False,
kind="size_mismatch",
observed_size_bytes=info.size_bytes,
)
if not verify_checksum:
return BlobInspection(
valid=True,
kind="verified",
observed_size_bytes=info.size_bytes,
)
digest = hashlib.sha256()
observed_size = 0
for chunk in backend.iter_bytes(blob.storage_key):
observed_size += len(chunk)
digest.update(chunk)
observed_checksum = digest.hexdigest()
if observed_size != blob.size_bytes:
return BlobInspection(
valid=False,
kind="size_mismatch",
observed_size_bytes=observed_size,
observed_checksum_sha256=observed_checksum,
)
if observed_checksum != blob.checksum_sha256:
return BlobInspection(
valid=False,
kind="checksum_mismatch",
observed_size_bytes=observed_size,
observed_checksum_sha256=observed_checksum,
)
return BlobInspection(
valid=True,
kind="verified",
observed_size_bytes=observed_size,
observed_checksum_sha256=observed_checksum,
)
def apply_blob_inspection(
session: Session,
blob: FileBlob,
inspection: BlobInspection,
*,
checked_at=None,
) -> None:
timestamp = checked_at or utcnow()
blob.integrity_checked_at = timestamp
if inspection.valid:
blob.integrity_status = "verified"
blob.integrity_failure = None
blob.quarantined_at = None
else:
blob.integrity_status = inspection.kind
blob.integrity_failure = inspection.kind
blob.quarantined_at = blob.quarantined_at or timestamp
session.add(blob)
def ensure_blob_is_readable(blob: FileBlob) -> None:
if blob.integrity_status in QUARANTINED_BLOB_STATUSES or blob.quarantined_at:
raise FileStorageError(
"Managed file content is quarantined because integrity verification failed"
)
def read_verified_blob_bytes(
blob: FileBlob,
*,
backend: StorageBackend,
) -> bytes:
ensure_blob_is_readable(blob)
try:
data = backend.get_bytes(blob.storage_key)
except StorageBackendError as exc:
raise FileStorageError("Managed file content is not available") from exc
if len(data) != blob.size_bytes:
raise FileStorageError(
"Managed file content failed its recorded size verification"
)
if hashlib.sha256(data).hexdigest() != blob.checksum_sha256:
raise FileStorageError(
"Managed file content failed its recorded checksum verification"
)
return data
def recheck_integrity_finding(
session: Session,
finding: FileIntegrityFinding,
*,
user_id: str,
dry_run: bool,
backend: StorageBackend | None = None,
) -> IntegrityActionResult:
if finding.kind == "orphan_object" or not finding.blob_id:
raise FileStorageError("Only blob integrity findings can be rechecked")
blob = session.get(FileBlob, finding.blob_id)
if blob is None or blob.tenant_id != finding.tenant_id:
raise FileStorageError("Integrity finding blob no longer exists")
active_backend = backend or get_storage_backend()
scan = session.get(FileIntegrityScan, finding.scan_id)
if scan is None or scan.storage_backend != active_backend.name:
raise FileStorageError(
"The configured storage backend does not match the integrity finding"
)
inspection = inspect_blob(
blob,
backend=active_backend,
verify_checksum=True,
)
changed = False
if not dry_run:
previous = (
blob.integrity_status,
blob.quarantined_at,
finding.state,
)
apply_blob_inspection(session, blob, inspection)
_update_finding_from_inspection(finding, inspection)
if inspection.valid:
finding.state = "resolved"
finding.resolved_at = utcnow()
finding.resolved_by_user_id = user_id
session.add(finding)
changed = previous != (
blob.integrity_status,
blob.quarantined_at,
finding.state,
)
return IntegrityActionResult(
action="recheck",
changed=changed,
dry_run=dry_run,
finding=finding,
inspection=inspection,
)
def cleanup_orphan_finding(
session: Session,
finding: FileIntegrityFinding,
*,
user_id: str,
dry_run: bool,
backend: StorageBackend | None = None,
) -> IntegrityActionResult:
if finding.kind != "orphan_object":
raise FileStorageError("Only orphan-object findings can be cleaned up")
scan = session.get(FileIntegrityScan, finding.scan_id)
if scan is None or scan.tenant_id != finding.tenant_id:
raise FileStorageError("Integrity scan not found")
if not finding.storage_key.startswith(scan.storage_prefix):
raise FileStorageError("Orphan cleanup is outside the scan storage scope")
referenced = (
session.query(FileBlob.id)
.filter(
FileBlob.tenant_id == finding.tenant_id,
FileBlob.storage_backend == scan.storage_backend,
FileBlob.storage_key == finding.storage_key,
)
.first()
)
if referenced:
if not dry_run and finding.state != "resolved":
finding.state = "resolved"
finding.resolved_at = utcnow()
finding.resolved_by_user_id = user_id
session.add(finding)
return IntegrityActionResult(
action="retained_referenced",
changed=True,
dry_run=False,
finding=finding,
)
return IntegrityActionResult(
action="retained_referenced",
changed=False,
dry_run=dry_run,
finding=finding,
)
if finding.state == "deleted":
return IntegrityActionResult(
action="already_deleted",
changed=False,
dry_run=dry_run,
finding=finding,
)
if dry_run:
return IntegrityActionResult(
action="would_delete",
changed=False,
dry_run=True,
finding=finding,
)
active_backend = backend or get_storage_backend()
if active_backend.name != scan.storage_backend:
raise FileStorageError(
"The configured storage backend does not match the integrity finding"
)
try:
active_backend.stat(finding.storage_key)
except StorageObjectMissing:
action = "already_absent"
else:
active_backend.delete(finding.storage_key)
action = "deleted"
finding.state = "deleted"
finding.resolved_at = utcnow()
finding.resolved_by_user_id = user_id
session.add(finding)
return IntegrityActionResult(
action=action,
changed=True,
dry_run=False,
finding=finding,
)
def _scan_blob_batch(
session: Session,
scan: FileIntegrityScan,
backend: StorageBackend,
) -> None:
query = session.query(FileBlob).filter(
FileBlob.tenant_id == scan.tenant_id,
FileBlob.storage_backend == scan.storage_backend,
)
if scan.blob_cursor:
query = query.filter(FileBlob.id > scan.blob_cursor)
blobs = query.order_by(FileBlob.id.asc()).limit(scan.batch_size).all()
for blob in blobs:
inspection = inspect_blob(
blob,
backend=backend,
verify_checksum=scan.verify_checksums,
)
apply_blob_inspection(session, blob, inspection)
scan.scanned_blob_count += 1
if inspection.valid:
scan.verified_blob_count += 1
_resolve_scan_blob_findings(session, scan, blob)
else:
scan.quarantined_blob_count += 1
_record_blob_finding(session, scan, blob, inspection)
scan.blob_cursor = blob.id
if len(blobs) < scan.batch_size:
scan.phase = "objects"
scan.object_cursor = None
def _scan_object_batch(
session: Session,
scan: FileIntegrityScan,
backend: StorageBackend,
) -> None:
page = backend.list_objects(
prefix=scan.storage_prefix,
after=scan.object_cursor,
limit=scan.batch_size,
)
keys = [item.key for item in page.objects]
referenced_keys = {
row[0]
for row in session.query(FileBlob.storage_key)
.filter(
FileBlob.tenant_id == scan.tenant_id,
FileBlob.storage_backend == scan.storage_backend,
FileBlob.storage_key.in_(keys),
)
.all()
} if keys else set()
for item in page.objects:
scan.scanned_object_count += 1
if item.key in referenced_keys:
continue
scan.orphan_object_count += 1
_record_orphan_finding(
session,
scan,
storage_key=item.key,
size_bytes=item.size_bytes,
)
scan.object_cursor = page.next_cursor
if page.next_cursor is None:
scan.phase = "completed"
scan.status = "completed"
scan.completed_at = utcnow()
def _record_blob_finding(
session: Session,
scan: FileIntegrityScan,
blob: FileBlob,
inspection: BlobInspection,
) -> FileIntegrityFinding:
finding = (
session.query(FileIntegrityFinding)
.filter(
FileIntegrityFinding.scan_id == scan.id,
FileIntegrityFinding.blob_id == blob.id,
FileIntegrityFinding.kind == inspection.kind,
)
.one_or_none()
)
if finding is None:
finding = FileIntegrityFinding(
scan_id=scan.id,
tenant_id=scan.tenant_id,
kind=inspection.kind,
blob_id=blob.id,
storage_key=blob.storage_key,
expected_size_bytes=blob.size_bytes,
expected_checksum_sha256=blob.checksum_sha256,
)
_update_finding_from_inspection(finding, inspection)
session.add(finding)
return finding
def _record_orphan_finding(
session: Session,
scan: FileIntegrityScan,
*,
storage_key: str,
size_bytes: int,
) -> FileIntegrityFinding:
finding = (
session.query(FileIntegrityFinding)
.filter(
FileIntegrityFinding.scan_id == scan.id,
FileIntegrityFinding.kind == "orphan_object",
FileIntegrityFinding.storage_key == storage_key,
)
.one_or_none()
)
if finding is None:
finding = FileIntegrityFinding(
scan_id=scan.id,
tenant_id=scan.tenant_id,
kind="orphan_object",
storage_key=storage_key,
observed_size_bytes=size_bytes,
)
session.add(finding)
return finding
def _resolve_scan_blob_findings(
session: Session,
scan: FileIntegrityScan,
blob: FileBlob,
) -> None:
for finding in (
session.query(FileIntegrityFinding)
.filter(
FileIntegrityFinding.scan_id == scan.id,
FileIntegrityFinding.blob_id == blob.id,
FileIntegrityFinding.state == "open",
)
.all()
):
finding.state = "resolved"
finding.resolved_at = utcnow()
session.add(finding)
def _update_finding_from_inspection(
finding: FileIntegrityFinding,
inspection: BlobInspection,
) -> None:
finding.observed_size_bytes = inspection.observed_size_bytes
finding.observed_checksum_sha256 = inspection.observed_checksum_sha256
__all__ = [
"BlobInspection",
"IntegrityActionResult",
"QUARANTINED_BLOB_STATUSES",
"apply_blob_inspection",
"cleanup_orphan_finding",
"create_integrity_scan",
"ensure_blob_is_readable",
"inspect_blob",
"mark_integrity_scan_failed",
"read_verified_blob_bytes",
"recheck_integrity_finding",
"run_integrity_scan_batch",
"storage_prefix_for_tenant",
]
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import and_, or_
from govoplan_files.backend.db.models import FileShare
from govoplan_files.backend.storage.common import utcnow
def effective_file_share_clause(*, at: datetime | None = None):
checked_at = at or utcnow()
return and_(
FileShare.revoked_at.is_(None),
or_(FileShare.expires_at.is_(None), FileShare.expires_at > checked_at),
)
def file_share_is_active(share: FileShare, *, at: datetime | None = None) -> bool:
if share.revoked_at is not None:
return False
if share.expires_at is None:
return True
checked_at = _aware_utc(at or utcnow())
return _aware_utc(share.expires_at) > checked_at
def _aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = ["effective_file_share_clause", "file_share_is_active"]