Files
govoplan-forms-runtime/src/govoplan_forms_runtime/backend/intake.py
T

809 lines
26 KiB
Python

from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import hashlib
import json
import secrets
from typing import Literal
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
EvidenceReference,
FormDefinition,
FormDefinitionProvider,
InstitutionalReference,
)
from govoplan_forms_runtime.backend.db.models import (
FormIntakeProfile,
FormIntakeSession,
)
from govoplan_forms_runtime.backend.domain import FormInstance
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
IntakeMode = Literal["anonymous", "invitation"]
INTAKE_MODES = frozenset({"anonymous", "invitation"})
DEFAULT_DRAFT_TTL_SECONDS = 30 * 24 * 60 * 60
DEFAULT_INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60
DEFAULT_RATE_LIMIT_PER_MINUTE = 60
class FormIntakeError(FormRuntimeError):
pass
@dataclass(frozen=True, slots=True)
class PublicIntakePrincipal:
tenant_id: str
account_id: str
auth_method: str
profile_id: str
identity_id: str | None = None
def has(self, scope: str) -> bool:
del scope
return False
@dataclass(frozen=True, slots=True)
class IntakeSessionResult:
session_id: str
mode: str
status: str
expires_at: datetime
instance: FormInstance | None
token: str | None = None
replayed: bool = False
def to_dict(self) -> dict[str, object]:
return {
"session_id": self.session_id,
"mode": self.mode,
"status": self.status,
"expires_at": self.expires_at.isoformat(),
"instance": self.instance.to_dict() if self.instance else None,
"token": self.token,
"replayed": self.replayed,
}
class FormIntakeService:
def __init__(self, registry: object | None) -> None:
self._registry = registry
self._runtime = FormRuntimeService(registry)
def create_profile(
self,
session: Session,
principal: object,
*,
definition_ref: InstitutionalReference,
mode: IntakeMode,
custodian_ref: str,
draft_ttl_seconds: int = DEFAULT_DRAFT_TTL_SECONDS,
invitation_ttl_seconds: int = DEFAULT_INVITATION_TTL_SECONDS,
rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE,
metadata: Mapping[str, object] | None = None,
recorded_at: datetime,
) -> FormIntakeProfile:
tenant_id = _principal_tenant(principal)
actor_id = _principal_actor(principal)
_require_aware(recorded_at, "Intake profile recorded_at")
if mode not in INTAKE_MODES:
raise FormIntakeError(f"Unsupported public intake mode: {mode!r}.")
if not custodian_ref.strip():
raise FormIntakeError("A public intake profile requires a custodian.")
if not 60 <= draft_ttl_seconds <= 365 * 24 * 60 * 60:
raise FormIntakeError(
"Draft expiry must be between one minute and one year."
)
if not 60 <= invitation_ttl_seconds <= 90 * 24 * 60 * 60:
raise FormIntakeError(
"Invitation expiry must be between one minute and ninety days."
)
if not 1 <= rate_limit_per_minute <= 10_000:
raise FormIntakeError(
"Public intake rate limit must be between 1 and 10000."
)
definition = self._definition(
session,
principal,
reference=definition_ref,
effective_at=recorded_at,
)
if definition.publication_state != "published":
raise FormIntakeError("Only a published Form can receive public intake.")
profile = FormIntakeProfile(
tenant_id=tenant_id,
profile_id=str(uuid.uuid4()),
public_id=secrets.token_urlsafe(24),
definition_id=definition.reference.object_id,
definition_revision=str(definition.reference.version),
mode=mode,
enabled=True,
revision=1,
custodian_ref=custodian_ref.strip(),
draft_ttl_seconds=draft_ttl_seconds,
invitation_ttl_seconds=invitation_ttl_seconds,
rate_limit_per_minute=rate_limit_per_minute,
rate_window_started_at=None,
rate_window_count=0,
created_by=actor_id,
updated_by=actor_id,
details={
"identity_claim_allowed": False,
"pseudonymous": False,
**dict(metadata or {}),
},
)
session.add(profile)
session.flush()
return profile
def list_profiles(
self,
session: Session,
principal: object,
) -> tuple[FormIntakeProfile, ...]:
return tuple(
session.query(FormIntakeProfile)
.filter(FormIntakeProfile.tenant_id == _principal_tenant(principal))
.order_by(
FormIntakeProfile.definition_id.asc(),
FormIntakeProfile.mode.asc(),
)
.all()
)
def list_available_definitions(
self,
session: Session,
principal: object,
*,
query: str = "",
limit: int = 200,
) -> tuple[FormDefinition, ...]:
provider = _capability(self._registry, CAPABILITY_FORM_DEFINITIONS)
if not isinstance(provider, FormDefinitionProvider):
raise FormIntakeError("The Forms definition provider is unavailable.")
definitions = provider.list_form_definitions(
session,
principal,
tenant_id=_principal_tenant(principal),
query=query.strip(),
limit=min(max(limit, 1), 200),
)
return tuple(
item
for item in definitions
if item.publication_state == "published" and item.reference.version
)
def set_profile_enabled(
self,
session: Session,
principal: object,
*,
profile_id: str,
expected_revision: int,
enabled: bool,
) -> FormIntakeProfile:
profile = self._profile_for_admin(
session,
principal,
profile_id=profile_id,
lock=True,
)
if profile.revision != expected_revision:
raise FormIntakeError(
"Intake profile revision conflict: the expected revision is stale."
)
profile.enabled = bool(enabled)
profile.revision += 1
profile.updated_by = _principal_actor(principal)
session.add(profile)
session.flush()
return profile
def issue_invitation(
self,
session: Session,
principal: object,
*,
profile_id: str,
idempotency_key: str,
recorded_at: datetime,
expires_at: datetime | None = None,
metadata: Mapping[str, object] | None = None,
) -> IntakeSessionResult:
profile = self._profile_for_admin(
session,
principal,
profile_id=profile_id,
lock=True,
)
if profile.mode != "invitation" or not profile.enabled:
raise FormIntakeError("This intake profile cannot issue invitations.")
_require_aware(recorded_at, "Invitation recorded_at")
effective_expiry = expires_at or (
recorded_at + timedelta(seconds=profile.invitation_ttl_seconds)
)
_require_aware(effective_expiry, "Invitation expires_at")
if effective_expiry <= recorded_at:
raise FormIntakeError("Invitation expiry must be in the future.")
request = {
"operation": "issue_invitation",
"profile_id": profile.profile_id,
"expires_at": effective_expiry.isoformat(),
"metadata": dict(metadata or {}),
}
replay = self._session_replay(
session,
tenant_id=profile.tenant_id,
idempotency_key=idempotency_key,
request_sha256=_hash(request),
)
if replay is not None:
return self._result(session, replay, token=None, replayed=True)
token = secrets.token_urlsafe(32)
intake_session = self._new_session(
profile,
token=token,
idempotency_key=idempotency_key,
request_sha256=_hash(request),
expires_at=effective_expiry,
created_by=_principal_actor(principal),
metadata=metadata,
)
session.add(intake_session)
session.flush()
return self._result(session, intake_session, token=token)
def start_anonymous(
self,
session: Session,
*,
public_id: str,
values: Mapping[str, object],
idempotency_key: str,
recorded_at: datetime,
) -> IntakeSessionResult:
profile = self._public_profile(
session,
public_id=public_id,
expected_mode="anonymous",
now=recorded_at,
)
request = {
"operation": "anonymous_start",
"profile_id": profile.profile_id,
"values": dict(values),
"recorded_at": recorded_at.isoformat(),
}
request_sha256 = _hash(request)
replay = self._session_replay(
session,
tenant_id=profile.tenant_id,
idempotency_key=f"anonymous:{profile.profile_id}:{idempotency_key}",
request_sha256=request_sha256,
)
if replay is not None:
return self._result(session, replay, token=None, replayed=True)
token = secrets.token_urlsafe(32)
intake_session = self._new_session(
profile,
token=token,
idempotency_key=f"anonymous:{profile.profile_id}:{idempotency_key}",
request_sha256=request_sha256,
expires_at=recorded_at + timedelta(seconds=profile.draft_ttl_seconds),
created_by="public",
)
session.add(intake_session)
session.flush()
instance = self._start_instance(
session,
profile,
intake_session,
values=values,
recorded_at=recorded_at,
)
intake_session.instance_id = instance.instance_id
intake_session.status = "active"
intake_session.started_at = recorded_at
session.add(intake_session)
session.flush()
return self._result(session, intake_session, token=token, instance=instance)
def start_invitation(
self,
session: Session,
*,
token: str,
values: Mapping[str, object],
recorded_at: datetime,
) -> IntakeSessionResult:
intake_session, profile = self._public_session(
session,
token=token,
now=recorded_at,
lock=True,
)
if intake_session.mode != "invitation":
raise _public_unavailable()
if intake_session.instance_id:
return self._result(session, intake_session, replayed=True)
instance = self._start_instance(
session,
profile,
intake_session,
values=values,
recorded_at=recorded_at,
)
intake_session.instance_id = instance.instance_id
intake_session.status = "active"
intake_session.started_at = recorded_at
session.add(intake_session)
session.flush()
return self._result(session, intake_session, instance=instance)
def get_public_instance(
self,
session: Session,
*,
token: str,
now: datetime,
) -> tuple[FormInstance, FormDefinition]:
intake_session, profile = self._public_session(
session,
token=token,
now=now,
lock=True,
)
instance = self._instance(session, intake_session)
definition = self._definition(
session,
_public_principal(intake_session, profile),
reference=_profile_definition_ref(profile),
effective_at=instance.recorded_at,
)
return instance, definition
def update_public_draft(
self,
session: Session,
*,
token: str,
expected_revision: int,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
signature_refs: Sequence[EvidenceReference],
idempotency_key: str,
recorded_at: datetime,
change_reason: str,
) -> FormInstance:
intake_session, profile = self._public_session(
session,
token=token,
now=recorded_at,
lock=True,
)
principal = _public_principal(intake_session, profile)
return self._runtime.update_draft(
session,
principal,
instance_id=_session_instance_id(intake_session),
expected_revision=expected_revision,
values=values,
attachment_refs=attachment_refs,
signature_refs=signature_refs,
idempotency_key=f"public:{intake_session.session_id}:{idempotency_key}",
recorded_at=recorded_at,
change_reason=change_reason,
)
def submit_public(
self,
session: Session,
*,
token: str,
expected_revision: int,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
signature_refs: Sequence[EvidenceReference],
idempotency_key: str,
recorded_at: datetime,
) -> FormInstance:
intake_session, profile = self._public_session(
session,
token=token,
now=recorded_at,
lock=True,
allow_submitted=True,
)
principal = _public_principal(intake_session, profile)
instance = self._runtime.submit_instance(
session,
principal,
instance_id=_session_instance_id(intake_session),
expected_revision=expected_revision,
values=values,
attachment_refs=attachment_refs,
signature_refs=signature_refs,
idempotency_key=f"public:{intake_session.session_id}:{idempotency_key}",
recorded_at=recorded_at,
)
intake_session.status = "submitted"
intake_session.submitted_at = recorded_at
session.add(intake_session)
session.flush()
return instance
def session_context(
self,
session: Session,
*,
token: str,
now: datetime,
) -> tuple[FormIntakeSession, FormIntakeProfile, PublicIntakePrincipal]:
intake_session, profile = self._public_session(
session,
token=token,
now=now,
lock=True,
)
return intake_session, profile, _public_principal(intake_session, profile)
def _start_instance(
self,
session: Session,
profile: FormIntakeProfile,
intake_session: FormIntakeSession,
*,
values: Mapping[str, object],
recorded_at: datetime,
) -> FormInstance:
principal = _public_principal(intake_session, profile)
return self._runtime.create_instance(
session,
principal,
definition_ref=_profile_definition_ref(profile),
values=values,
idempotency_key=f"public-start:{intake_session.session_id}",
recorded_at=recorded_at,
metadata={
"intake": {
"profile_id": profile.profile_id,
"mode": profile.mode,
"channel": "public_web",
"authenticated_identity_claimed": False,
"authority_basis": "invitation"
if profile.mode == "invitation"
else "self_submission",
}
},
)
def _definition(
self,
session: Session,
principal: object,
*,
reference: InstitutionalReference,
effective_at: datetime,
) -> FormDefinition:
provider = _capability(self._registry, CAPABILITY_FORM_DEFINITIONS)
if not isinstance(provider, FormDefinitionProvider):
raise FormIntakeError("The Forms definition provider is unavailable.")
definition = provider.get_form_definition(
session,
principal,
reference=reference,
effective_at=effective_at,
)
if definition is None or definition.reference != reference:
raise FormIntakeError("The exact Form definition is unavailable.")
return definition
def _profile_for_admin(
self,
session: Session,
principal: object,
*,
profile_id: str,
lock: bool,
) -> FormIntakeProfile:
query = session.query(FormIntakeProfile).filter(
FormIntakeProfile.tenant_id == _principal_tenant(principal),
FormIntakeProfile.profile_id == profile_id,
)
if lock:
query = query.with_for_update()
profile = query.one_or_none()
if profile is None:
raise LookupError("Form intake profile not found.")
return profile
def _public_profile(
self,
session: Session,
*,
public_id: str,
expected_mode: str,
now: datetime,
) -> FormIntakeProfile:
_require_aware(now, "Public intake time")
profile = (
session.query(FormIntakeProfile)
.filter(FormIntakeProfile.public_id == public_id)
.with_for_update()
.one_or_none()
)
if profile is None or not profile.enabled or profile.mode != expected_mode:
raise _public_unavailable()
_consume_rate_limit(profile, now=now)
session.add(profile)
return profile
def _public_session(
self,
session: Session,
*,
token: str,
now: datetime,
lock: bool,
allow_submitted: bool = False,
) -> tuple[FormIntakeSession, FormIntakeProfile]:
_require_aware(now, "Public intake time")
query = session.query(FormIntakeSession).filter(
FormIntakeSession.token_sha256 == _token_hash(token)
)
if lock:
query = query.with_for_update()
intake_session = query.one_or_none()
if intake_session is None:
raise _public_unavailable()
profile_query = session.query(FormIntakeProfile).filter(
FormIntakeProfile.id == intake_session.profile_id
)
if lock:
profile_query = profile_query.with_for_update()
profile = profile_query.one_or_none()
allowed_states = {"issued", "active"}
if allow_submitted:
allowed_states.add("submitted")
if (
profile is None
or not profile.enabled
or intake_session.status not in allowed_states
or intake_session.revoked_at is not None
or _aware(intake_session.expires_at) <= now
):
raise _public_unavailable()
_consume_rate_limit(profile, now=now)
session.add(profile)
return intake_session, profile
def _session_replay(
self,
session: Session,
*,
tenant_id: str,
idempotency_key: str,
request_sha256: str,
) -> FormIntakeSession | None:
existing = (
session.query(FormIntakeSession)
.filter(
FormIntakeSession.tenant_id == tenant_id,
FormIntakeSession.idempotency_key == idempotency_key,
)
.one_or_none()
)
if existing is not None and existing.request_sha256 != request_sha256:
raise FormIntakeError(
"Public intake idempotency conflict: this key was used for another request."
)
return existing
def _new_session(
self,
profile: FormIntakeProfile,
*,
token: str,
idempotency_key: str,
request_sha256: str,
expires_at: datetime,
created_by: str,
metadata: Mapping[str, object] | None = None,
) -> FormIntakeSession:
session_id = str(uuid.uuid4())
return FormIntakeSession(
tenant_id=profile.tenant_id,
session_id=session_id,
profile_id=profile.id,
token_sha256=_token_hash(token),
mode=profile.mode,
status="issued",
instance_id=None,
actor_id=f"form-public:{session_id}",
idempotency_key=idempotency_key,
request_sha256=request_sha256,
expires_at=expires_at,
created_by=created_by,
details=dict(metadata or {}),
)
def _instance(
self,
session: Session,
intake_session: FormIntakeSession,
) -> FormInstance:
profile = session.get(FormIntakeProfile, intake_session.profile_id)
if profile is None:
raise _public_unavailable()
instance = self._runtime.get_instance(
session,
_public_principal(intake_session, profile),
instance_id=_session_instance_id(intake_session),
)
if instance is None:
raise _public_unavailable()
return instance
def _result(
self,
session: Session,
intake_session: FormIntakeSession,
*,
token: str | None = None,
instance: FormInstance | None = None,
replayed: bool = False,
) -> IntakeSessionResult:
if instance is None and intake_session.instance_id:
instance = self._instance(session, intake_session)
return IntakeSessionResult(
session_id=intake_session.session_id,
mode=intake_session.mode,
status=intake_session.status,
expires_at=_aware(intake_session.expires_at),
instance=instance,
token=token,
replayed=replayed,
)
def profile_payload(profile: FormIntakeProfile) -> dict[str, object]:
return {
"profile_id": profile.profile_id,
"public_id": profile.public_id,
"definition_ref": {
"kind": "form",
"owner_module": "forms",
"object_id": profile.definition_id,
"tenant_id": profile.tenant_id,
"version": profile.definition_revision,
},
"mode": profile.mode,
"enabled": profile.enabled,
"revision": profile.revision,
"draft_ttl_seconds": profile.draft_ttl_seconds,
"invitation_ttl_seconds": profile.invitation_ttl_seconds,
"rate_limit_per_minute": profile.rate_limit_per_minute,
"metadata": dict(profile.details),
}
def _consume_rate_limit(profile: FormIntakeProfile, *, now: datetime) -> None:
window = (
_aware(profile.rate_window_started_at)
if profile.rate_window_started_at is not None
else None
)
if window is None or now - window >= timedelta(minutes=1):
profile.rate_window_started_at = now
profile.rate_window_count = 1
return
if profile.rate_window_count >= profile.rate_limit_per_minute:
raise FormIntakeError("Public Form intake is temporarily rate limited.")
profile.rate_window_count += 1
def _public_principal(
intake_session: FormIntakeSession,
profile: FormIntakeProfile,
) -> PublicIntakePrincipal:
return PublicIntakePrincipal(
tenant_id=intake_session.tenant_id,
account_id=intake_session.actor_id,
auth_method=f"form_public_{intake_session.mode}",
profile_id=profile.profile_id,
)
def _profile_definition_ref(profile: FormIntakeProfile) -> InstitutionalReference:
return InstitutionalReference(
kind="form",
owner_module="forms",
object_id=profile.definition_id,
tenant_id=profile.tenant_id,
version=profile.definition_revision,
)
def _session_instance_id(intake_session: FormIntakeSession) -> str:
if not intake_session.instance_id:
raise _public_unavailable()
return intake_session.instance_id
def _public_unavailable() -> FormIntakeError:
return FormIntakeError("Public Form intake is unavailable.")
def _principal_tenant(principal: object) -> str:
value = str(getattr(principal, "tenant_id", "") or "").strip()
if not value:
raise FormIntakeError("Forms Runtime requires a tenant-bound principal.")
return value
def _principal_actor(principal: object) -> str:
for name in ("account_id", "identity_id", "membership_id"):
value = str(getattr(principal, name, "") or "").strip()
if value:
return value
raise FormIntakeError("Forms Runtime requires an acting identity.")
def _capability(registry: object | None, name: str) -> object | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(name):
return None
if hasattr(registry, "require_capability"):
return registry.require_capability(name)
if hasattr(registry, "capability"):
return registry.capability(name)
return None
def _token_hash(token: str) -> str:
clean = str(token or "").strip()
if len(clean) < 32:
raise _public_unavailable()
return hashlib.sha256(clean.encode("utf-8")).hexdigest()
def _hash(value: Mapping[str, object]) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
"utf-8"
)
).hexdigest()
def _require_aware(value: datetime, label: str) -> None:
if value.tzinfo is None or value.utcoffset() is None:
raise FormIntakeError(f"{label} must include a timezone.")
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
__all__ = [
"DEFAULT_DRAFT_TTL_SECONDS",
"DEFAULT_INVITATION_TTL_SECONDS",
"DEFAULT_RATE_LIMIT_PER_MINUTE",
"FormIntakeError",
"FormIntakeService",
"IntakeSessionResult",
"PublicIntakePrincipal",
"profile_payload",
]