450 lines
16 KiB
Python
450 lines
16 KiB
Python
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",
|
|
]
|