from __future__ import annotations from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta import hashlib import json import secrets from sqlalchemy.orm import Session from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory from govoplan_core.core.form_evidence import ( FormEvidenceContractError, FormEvidenceGrant, FormEvidenceGrantRequest, FormEvidenceInspection, FormEvidenceInspectionRequest, FormEvidenceState, ) from govoplan_core.core.institutional import EvidenceReference from govoplan_core.core.modules import ModuleContext from govoplan_files.backend.db.models import ( FileAsset, FileBlob, FileFormEvidenceGrant, FileVersion, ) from govoplan_files.backend.runtime import configure_runtime PROVIDER_ID = "files" CAPABILITY_FORM_EVIDENCE_FILES = "forms_runtime.evidence.files" MAX_GRANT_TTL = timedelta(minutes=15) class FilesFormEvidenceProvider: provider_id = PROVIDER_ID def __init__(self, registry: object | None, settings: object) -> None: self._registry = registry self._settings = settings def supported_kinds(self) -> Sequence[str]: return ("document",) def create_upload_grant( self, session: object, principal: object, *, request: FormEvidenceGrantRequest, ) -> FormEvidenceGrant: db = _session(session) _assert_tenant(principal, request.tenant_id) if request.evidence_kind != "document": raise FormEvidenceContractError( "Files can accept only document evidence for Forms Runtime." ) custodian_user_id = _custodian_user_id(request.custodian_ref) self._assert_active_custodian( tenant_id=request.tenant_id, user_id=custodian_user_id, ) now = datetime.now(UTC) if request.expires_at <= now: raise FormEvidenceContractError( "Form evidence upload grant expiry must be in the future." ) allowed_content_types = _content_types(request.allowed_content_types) configured_max = int(getattr(self._settings, "file_upload_max_bytes")) max_size_bytes = min(request.max_size_bytes or configured_max, configured_max) request_sha256 = _request_sha256( request, custodian_user_id=custodian_user_id, max_size_bytes=max_size_bytes, allowed_content_types=allowed_content_types, ) existing = ( db.query(FileFormEvidenceGrant) .filter( FileFormEvidenceGrant.tenant_id == request.tenant_id, FileFormEvidenceGrant.idempotency_key == request.idempotency_key, ) .one_or_none() ) if existing is not None: if not secrets.compare_digest(existing.request_sha256, request_sha256): raise FormEvidenceContractError( "Form evidence grant idempotency conflict." ) if ( existing.status in {"expired", "revoked"} or _aware(existing.expires_at) <= now ): raise FormEvidenceContractError( "The existing Form evidence upload grant is no longer usable; " "request a new grant with a new idempotency key." ) return _grant_response(existing, upload_token=None, replayed=True) metadata = _bounded_metadata(request.metadata) remaining_attachments = metadata.get("remaining_attachments") if isinstance(remaining_attachments, int): existing_attachment_ids = set(metadata.get("existing_attachment_ids", ())) active_grants = ( db.query(FileFormEvidenceGrant) .filter( FileFormEvidenceGrant.tenant_id == request.tenant_id, FileFormEvidenceGrant.form_instance_id == request.instance_id, FileFormEvidenceGrant.form_definition_id == request.definition_ref.object_id, FileFormEvidenceGrant.form_definition_revision == str(request.definition_ref.version), FileFormEvidenceGrant.status.in_(("issued", "uploaded")), FileFormEvidenceGrant.expires_at > now, ) .with_for_update() .all() ) outstanding = sum( 1 for item in active_grants if not item.file_asset_id or item.file_asset_id not in existing_attachment_ids ) if outstanding >= remaining_attachments: raise FormEvidenceContractError( "This Form already has the maximum number of active attachment uploads." ) upload_token = secrets.token_urlsafe(32) grant = FileFormEvidenceGrant( tenant_id=request.tenant_id, form_instance_id=request.instance_id, form_definition_id=request.definition_ref.object_id, form_definition_revision=str(request.definition_ref.version), token_sha256=_token_sha256(upload_token), idempotency_key=request.idempotency_key, request_sha256=request_sha256, custodian_user_id=custodian_user_id, evidence_kind=request.evidence_kind, purpose=request.purpose, status="issued", expires_at=min(request.expires_at, now + MAX_GRANT_TTL), max_size_bytes=max_size_bytes, allowed_content_types=list(allowed_content_types), metadata_=metadata, ) db.add(grant) db.flush() return _grant_response(grant, upload_token=upload_token, replayed=False) def inspect_evidence( self, session: object, principal: object, *, request: FormEvidenceInspectionRequest, ) -> FormEvidenceInspection: db = _session(session) _assert_tenant(principal, request.tenant_id) now = datetime.now(UTC) if request.evidence.owner_module != PROVIDER_ID: return _inspection( request.evidence, state="rejected", observed_at=now, reason="The evidence owner does not match the Files provider.", ) if request.evidence.kind != "document" or not request.evidence.version: return _inspection( request.evidence, state="rejected", observed_at=now, reason="Files evidence requires an exact document version.", ) grant = ( db.query(FileFormEvidenceGrant) .filter( FileFormEvidenceGrant.tenant_id == request.tenant_id, FileFormEvidenceGrant.form_instance_id == request.instance_id, FileFormEvidenceGrant.form_definition_id == request.definition_ref.object_id, FileFormEvidenceGrant.form_definition_revision == str(request.definition_ref.version), FileFormEvidenceGrant.file_asset_id == request.evidence.evidence_id, FileFormEvidenceGrant.file_version_id == request.evidence.version, ) .one_or_none() ) if grant is None: return _inspection( request.evidence, state="rejected", observed_at=now, reason="The document was not captured for this exact Form submission.", ) if grant.status == "revoked": return _inspection( request.evidence, state="revoked", observed_at=now, reason="The Form evidence grant was revoked.", ) if grant.status != "uploaded": return _inspection( request.evidence, state="pending", observed_at=now, retryable=True, reason="The Form evidence upload has not completed.", ) asset = db.get(FileAsset, grant.file_asset_id) version = db.get(FileVersion, grant.file_version_id) blob = db.get(FileBlob, version.blob_id) if version is not None else None if asset is None or version is None or blob is None: return _inspection( request.evidence, state="unavailable", observed_at=now, retryable=True, reason="The managed document cannot currently be reconstructed.", ) if ( asset.tenant_id != request.tenant_id or version.tenant_id != request.tenant_id or blob.tenant_id != request.tenant_id or version.file_asset_id != asset.id ): return _inspection( request.evidence, state="rejected", observed_at=now, reason="The managed document crosses an evidence ownership boundary.", ) if asset.deleted_at is not None: return _inspection( request.evidence, state="revoked", observed_at=now, reason="The managed document is no longer active.", ) if blob.quarantined_at is not None or blob.integrity_status == "quarantined": return _inspection( request.evidence, state="rejected", observed_at=now, reason="The managed document failed its integrity gate.", ) if blob.integrity_status != "verified": return _inspection( request.evidence, state="pending", observed_at=now, retryable=True, reason="The managed document is awaiting integrity verification.", ) if ( not request.evidence.checksum or not secrets.compare_digest( request.evidence.checksum, version.checksum_sha256, ) or not secrets.compare_digest( version.checksum_sha256, blob.checksum_sha256, ) ): return _inspection( request.evidence, state="rejected", observed_at=now, reason="The managed document checksum does not match the evidence.", ) return _inspection( request.evidence, state="accepted", observed_at=now, metadata={ "content_type": version.content_type, "size_bytes": version.size_bytes, "integrity_status": blob.integrity_status, "grant_id": grant.id, }, ) def _assert_active_custodian(self, *, tenant_id: str, user_id: str) -> None: registry = self._registry if registry is None or not hasattr(registry, "has_capability"): raise FormEvidenceContractError( "The Access directory is unavailable for Form evidence custody." ) if not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY): raise FormEvidenceContractError( "The Access directory is unavailable for Form evidence custody." ) directory = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY) if not isinstance(directory, AccessDirectory): raise FormEvidenceContractError( "The Access directory capability is invalid." ) user = directory.get_user(user_id) if user is None or user.tenant_id != tenant_id or user.status != "active": raise FormEvidenceContractError( "Form evidence requires an active same-tenant custodian." ) def create_files_form_evidence_provider( context: ModuleContext, ) -> FilesFormEvidenceProvider: configure_runtime(registry=context.registry, settings=context.settings) return FilesFormEvidenceProvider(context.registry, context.settings) def _grant_response( grant: FileFormEvidenceGrant, *, upload_token: str | None, replayed: bool, ) -> FormEvidenceGrant: return FormEvidenceGrant( provider_id=PROVIDER_ID, grant_id=grant.id, upload_token=upload_token, upload_url="/api/v1/files/form-evidence/upload", expires_at=_aware(grant.expires_at), max_size_bytes=grant.max_size_bytes, allowed_content_types=tuple(grant.allowed_content_types), replayed=replayed, ) def _inspection( reference: EvidenceReference, *, state: FormEvidenceState, observed_at: datetime, retryable: bool = False, reason: str | None = None, metadata: Mapping[str, object] | None = None, ) -> FormEvidenceInspection: return FormEvidenceInspection( provider_id=PROVIDER_ID, reference=reference, state=state, observed_at=observed_at, retryable=retryable, reason=reason, metadata=dict(metadata or {}), ) def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("Files Form evidence requires a SQLAlchemy session.") return value def _assert_tenant(principal: object, tenant_id: str) -> None: principal_tenant = str(getattr(principal, "tenant_id", "") or "").strip() if not principal_tenant or principal_tenant != tenant_id: raise PermissionError("Form evidence cannot cross tenants.") def _custodian_user_id(value: str | None) -> str: clean = str(value or "").strip() if not clean.startswith("user:") or len(clean) <= len("user:"): raise FormEvidenceContractError( "Files Form evidence requires a user custodian." ) return clean.removeprefix("user:") def _content_types(values: Sequence[str]) -> tuple[str, ...]: cleaned = tuple( dict.fromkeys( value.split(";", 1)[0].strip().casefold() for value in values if value.strip() ) ) if len(cleaned) > 50 or any(len(value) > 255 for value in cleaned): raise FormEvidenceContractError( "Form evidence content-type restrictions are too large." ) return cleaned def _bounded_metadata(value: Mapping[str, object]) -> dict[str, object]: remaining = value.get("remaining_attachments") result: dict[str, object] = {} if isinstance(remaining, int) and remaining >= 0: result["remaining_attachments"] = remaining raw_ids = value.get("existing_attachment_ids") if isinstance(raw_ids, (list, tuple)): clean_ids = tuple( dict.fromkeys( item.strip() for item in raw_ids if isinstance(item, str) and item.strip() and len(item.strip()) <= 255 ) ) if len(clean_ids) <= 1000: result["existing_attachment_ids"] = clean_ids return result def _request_sha256( request: FormEvidenceGrantRequest, *, custodian_user_id: str, max_size_bytes: int, allowed_content_types: Sequence[str], ) -> str: return hashlib.sha256( json.dumps( { "tenant_id": request.tenant_id, "instance_id": request.instance_id, "definition_ref": request.definition_ref.to_dict(), "evidence_kind": request.evidence_kind, "purpose": request.purpose, "expires_at": request.expires_at.isoformat(), "custodian_user_id": custodian_user_id, "max_size_bytes": max_size_bytes, "allowed_content_types": list(allowed_content_types), "metadata": _bounded_metadata(request.metadata), }, sort_keys=True, separators=(",", ":"), ).encode("utf-8") ).hexdigest() def _token_sha256(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _aware(value: datetime) -> datetime: return value if value.tzinfo is not None else value.replace(tzinfo=UTC) __all__ = [ "CAPABILITY_FORM_EVIDENCE_FILES", "FilesFormEvidenceProvider", "PROVIDER_ID", "create_files_form_evidence_provider", ]