Add governed public form intake

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent d4bbdd079f
commit 3c292b8c07
25 changed files with 4715 additions and 47 deletions
@@ -5,6 +5,7 @@ from typing import Any
import uuid
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
@@ -189,9 +190,155 @@ class FormHandoffEffect(Base, TimestampMixin):
)
class FormIntakeProfile(Base, TimestampMixin):
__tablename__ = "form_intake_profiles"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"profile_id",
name="uq_form_intake_profile",
),
UniqueConstraint("public_id", name="uq_form_intake_public_id"),
Index(
"ix_form_intake_definition",
"tenant_id",
"definition_id",
"definition_revision",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
profile_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
public_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
definition_revision: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
custodian_ref: Mapped[str] = mapped_column(String(255), nullable=False)
draft_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
invitation_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False)
rate_window_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
rate_window_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
updated_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class FormIntakeSession(Base, TimestampMixin):
__tablename__ = "form_intake_sessions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"session_id",
name="uq_form_intake_session",
),
UniqueConstraint("token_sha256", name="uq_form_intake_token"),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_intake_session_idempotency",
),
Index(
"ix_form_intake_session_state",
"tenant_id",
"profile_id",
"status",
"expires_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
session_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
profile_id: Mapped[str] = mapped_column(
ForeignKey("form_intake_profiles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
token_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
instance_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
submitted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class FormAcknowledgement(Base, TimestampMixin):
__tablename__ = "form_acknowledgements"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"acknowledgement_id",
name="uq_form_acknowledgement",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_acknowledgement_idempotency",
),
Index(
"ix_form_acknowledgement_instance",
"tenant_id",
"instance_id",
"instance_revision",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
acknowledgement_id: Mapped[str] = mapped_column(
String(36), nullable=False, index=True
)
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
statement_id: Mapped[str] = mapped_column(String(255), nullable=False)
statement_version: Mapped[str] = mapped_column(String(255), nullable=False)
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
accepted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
__all__ = [
"FormAcknowledgement",
"FormInstanceEvent",
"FormHandoffEffect",
"FormIntakeProfile",
"FormIntakeSession",
"FormInstanceIdentity",
"FormInstanceRevision",
]
@@ -0,0 +1,433 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime
import hashlib
import json
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.form_evidence import (
FormEvidenceGrant,
FormEvidenceGrantRequest,
FormEvidenceInspection,
FormEvidenceInspectionRequest,
form_evidence_provider,
)
from govoplan_core.core.institutional import (
EvidenceReference,
FormDefinition,
)
from govoplan_forms_runtime.backend.db.models import (
FormAcknowledgement,
FormInstanceEvent,
)
from govoplan_forms_runtime.backend.domain import FormInstance
class FormEvidenceError(ValueError):
pass
class FormEvidenceCoordinator:
def __init__(self, registry: object | None) -> None:
self._registry = registry
def create_upload_grant(
self,
session: Session,
principal: object,
*,
instance: FormInstance,
definition: FormDefinition,
provider_id: str,
custodian_ref: str,
purpose: str,
idempotency_key: str,
expires_at: datetime,
max_size_bytes: int | None = None,
allowed_content_types: Sequence[str] = (),
attachment_refs: Sequence[EvidenceReference] = (),
) -> FormEvidenceGrant:
if instance.status not in {"started", "draft"}:
raise FormEvidenceError(
"Evidence upload grants are available only while a Form is editable."
)
prospective_attachments = tuple(attachment_refs)
if any(
item.tenant_id != instance.tenant_id for item in prospective_attachments
):
raise FormEvidenceError("Form attachments cannot cross tenants.")
if len(prospective_attachments) >= definition.max_attachments:
raise FormEvidenceError("This Form does not permit another attachment.")
provider = form_evidence_provider(self._registry, provider_id)
if provider is None or "document" not in set(provider.supported_kinds()):
raise FormEvidenceError(
"The selected Form attachment provider is unavailable."
)
grant = provider.create_upload_grant(
session,
principal,
request=FormEvidenceGrantRequest(
tenant_id=instance.tenant_id,
instance_id=instance.instance_id,
definition_ref=definition.reference,
evidence_kind="document",
purpose=purpose,
idempotency_key=idempotency_key,
expires_at=expires_at,
custodian_ref=custodian_ref,
max_size_bytes=max_size_bytes,
allowed_content_types=tuple(allowed_content_types),
metadata={
"remaining_attachments": definition.max_attachments
- len(prospective_attachments),
"existing_attachment_ids": tuple(
item.evidence_id for item in prospective_attachments
),
},
),
)
if grant.provider_id != provider.provider_id:
raise FormEvidenceError(
"The Form evidence provider returned a mismatched grant."
)
return grant
def inspect(
self,
session: Session,
principal: object,
*,
instance: FormInstance,
definition: FormDefinition,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
signature_refs: Sequence[EvidenceReference],
purpose: str,
final: bool,
observed_at: datetime,
) -> tuple[tuple[Mapping[str, object], ...], tuple[Mapping[str, object], ...]]:
diagnostics: list[Mapping[str, object]] = []
snapshots: list[Mapping[str, object]] = []
expected_acknowledgement_digest = acknowledgement_payload_sha256(
instance,
values=values,
attachment_refs=attachment_refs,
)
for reference in (*attachment_refs, *signature_refs):
if reference.owner_module == "forms_runtime":
inspection = self._inspect_acknowledgement(
session,
principal,
instance=instance,
reference=reference,
expected_payload_sha256=expected_acknowledgement_digest,
observed_at=observed_at,
)
else:
provider = form_evidence_provider(
self._registry,
reference.owner_module,
)
if provider is None:
inspection = FormEvidenceInspection(
provider_id=reference.owner_module,
reference=reference,
state="unavailable",
observed_at=observed_at,
retryable=True,
reason="The evidence owner is not installed or available.",
)
else:
inspection = provider.inspect_evidence(
session,
principal,
request=FormEvidenceInspectionRequest(
tenant_id=instance.tenant_id,
instance_id=instance.instance_id,
definition_ref=definition.reference,
evidence=reference,
purpose=purpose,
final=final,
),
)
if (
inspection.provider_id != provider.provider_id
or inspection.reference != reference
):
raise FormEvidenceError(
"The Form evidence provider returned a mismatched inspection."
)
snapshots.append(_inspection_payload(inspection))
if not inspection.accepted:
diagnostics.append(
{
"field": None,
"severity": "error" if final else "warning",
"code": f"evidence.{inspection.state}",
"message": inspection.reason
or "Attached evidence is not currently accepted.",
}
)
if final:
rejected = [item for item in snapshots if item["state"] != "accepted"]
if rejected:
states = ", ".join(sorted({str(item["state"]) for item in rejected}))
raise FormEvidenceError(
f"Form evidence failed final verification: {states}."
)
return tuple(diagnostics), tuple(snapshots)
def create_acknowledgement(
self,
session: Session,
principal: object,
*,
instance: FormInstance,
expected_revision: int,
statement_id: str,
statement_version: str,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
accepted_at: datetime,
idempotency_key: str,
) -> EvidenceReference:
if instance.revision != expected_revision:
raise FormEvidenceError(
"Form acknowledgement revision conflict: the expected revision is stale."
)
if instance.status not in {"started", "draft"}:
raise FormEvidenceError(
"An acknowledgement can be recorded only while the Form is editable."
)
actor_id = _principal_actor(principal)
if actor_id.startswith("form-public:"):
raise FormEvidenceError(
"The native acknowledgement profile requires an authenticated actor."
)
if accepted_at.tzinfo is None or accepted_at.utcoffset() is None:
raise FormEvidenceError(
"Form acknowledgement time must include a timezone."
)
clean_statement_id = _text(statement_id, "Acknowledgement statement", 255)
clean_statement_version = _text(
statement_version,
"Acknowledgement statement version",
255,
)
clean_key = _text(idempotency_key, "Acknowledgement idempotency key", 255)
payload_sha256 = acknowledgement_payload_sha256(
instance,
values=values,
attachment_refs=attachment_refs,
)
request = {
"instance_id": instance.instance_id,
"instance_revision": expected_revision,
"statement_id": clean_statement_id,
"statement_version": clean_statement_version,
"accepted_at": accepted_at.isoformat(),
"payload_sha256": payload_sha256,
}
request_sha256 = _hash(request)
existing = (
session.query(FormAcknowledgement)
.filter(
FormAcknowledgement.tenant_id == instance.tenant_id,
FormAcknowledgement.idempotency_key == clean_key,
)
.one_or_none()
)
if existing is not None:
if (
existing.request_sha256 != request_sha256
or existing.actor_id != actor_id
):
raise FormEvidenceError("Form acknowledgement idempotency conflict.")
return _acknowledgement_reference(existing)
acknowledgement = FormAcknowledgement(
tenant_id=instance.tenant_id,
acknowledgement_id=str(uuid.uuid4()),
instance_id=instance.instance_id,
instance_revision=instance.revision,
statement_id=clean_statement_id,
statement_version=clean_statement_version,
actor_id=actor_id,
accepted_at=accepted_at,
payload_sha256=payload_sha256,
idempotency_key=clean_key,
request_sha256=request_sha256,
details={"profile": "authenticated_acknowledgement_v1"},
)
event_id = str(uuid.uuid4())
event = FormInstanceEvent(
tenant_id=instance.tenant_id,
instance_id=instance.instance_id,
instance_revision=instance.revision,
event_id=event_id,
event_type="forms_runtime.instance.acknowledged",
status=instance.status,
occurred_at=accepted_at,
actor_id=actor_id,
idempotency_key=f"ack:{clean_key}",
request_sha256=request_sha256,
payload={
"acknowledgement_id": acknowledgement.acknowledgement_id,
"statement_id": clean_statement_id,
"statement_version": clean_statement_version,
"payload_sha256": payload_sha256,
},
)
session.add_all((acknowledgement, event))
session.flush()
emit_platform_event(
session,
PlatformEvent(
event_id=event_id,
type=event.event_type,
module_id="forms_runtime",
payload=dict(event.payload),
occurred_at=accepted_at,
actor=EventActorRef(type="account", id=actor_id),
tenant=EventTenantRef(id=instance.tenant_id),
resource=EventObjectRef(
type="form_submission",
id=instance.instance_id,
label=instance.definition_ref.label,
),
classification="confidential",
),
)
return _acknowledgement_reference(acknowledgement)
def _inspect_acknowledgement(
self,
session: Session,
principal: object,
*,
instance: FormInstance,
reference: EvidenceReference,
expected_payload_sha256: str,
observed_at: datetime,
) -> FormEvidenceInspection:
acknowledgement = (
session.query(FormAcknowledgement)
.filter(
FormAcknowledgement.tenant_id == instance.tenant_id,
FormAcknowledgement.acknowledgement_id == reference.evidence_id,
FormAcknowledgement.instance_id == instance.instance_id,
)
.one_or_none()
)
accepted = bool(
acknowledgement is not None
and reference.kind == "signature"
and reference.version == "1"
and reference.checksum == acknowledgement.payload_sha256
and acknowledgement.payload_sha256 == expected_payload_sha256
and acknowledgement.actor_id == _principal_actor(principal)
)
return FormEvidenceInspection(
provider_id="forms_runtime",
reference=reference,
state="accepted" if accepted else "rejected",
observed_at=observed_at,
retryable=False,
reason=(
None
if accepted
else "The acknowledgement does not match this actor and exact Form payload."
),
metadata={"profile": "authenticated_acknowledgement_v1"},
)
def acknowledgement_payload_sha256(
instance: FormInstance,
*,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
) -> str:
return _hash(
{
"tenant_id": instance.tenant_id,
"instance_id": instance.instance_id,
"definition_ref": instance.definition_ref.to_dict(),
"values": dict(values),
"attachment_refs": [item.to_dict() for item in attachment_refs],
}
)
def _acknowledgement_reference(
acknowledgement: FormAcknowledgement,
) -> EvidenceReference:
return EvidenceReference(
kind="signature",
owner_module="forms_runtime",
evidence_id=acknowledgement.acknowledgement_id,
tenant_id=acknowledgement.tenant_id,
version="1",
checksum=acknowledgement.payload_sha256,
source_ref=(
f"form_submission:{acknowledgement.instance_id}:"
f"{acknowledgement.instance_revision}"
),
responsible_actor_ref=acknowledgement.actor_id,
captured_at=acknowledgement.accepted_at,
)
def _inspection_payload(inspection: FormEvidenceInspection) -> Mapping[str, object]:
return {
"provider_id": inspection.provider_id,
"evidence_id": inspection.reference.evidence_id,
"owner_module": inspection.reference.owner_module,
"version": inspection.reference.version,
"state": inspection.state,
"observed_at": inspection.observed_at.isoformat(),
"retryable": inspection.retryable,
"reason": inspection.reason,
"metadata": dict(inspection.metadata),
}
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 FormEvidenceError("Form evidence requires an acting identity.")
def _text(value: str, label: str, maximum: int) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > maximum:
raise FormEvidenceError(
f"{label} is required and limited to {maximum} characters."
)
return clean
def _hash(value: Mapping[str, object]) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
"utf-8"
)
).hexdigest()
__all__ = [
"FormEvidenceCoordinator",
"FormEvidenceError",
"acknowledgement_payload_sha256",
]
@@ -0,0 +1,808 @@
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",
]
+111 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
from pathlib import Path
from govoplan_core.core.access import (
@@ -27,12 +28,21 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
PublicFrontendRoute,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_forms_runtime.backend.db import models as runtime_models
from govoplan_forms_runtime.backend.record_source import (
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME,
create_forms_runtime_record_source,
)
from govoplan_forms_runtime.backend.search_source import (
create_forms_runtime_search_source,
)
from govoplan_forms_runtime.backend.service import (
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
CAPABILITY_FORMS_RUNTIME_REGISTRY,
@@ -57,6 +67,7 @@ OPTIONAL_DEPENDENCIES = (
"cases",
"policy",
"audit",
"records",
)
@@ -93,7 +104,7 @@ PERMISSIONS = (
_permission(
ADMIN_SCOPE,
"Administer Forms Runtime",
"Administer Forms Runtime policy, recovery, and retirement.",
"Administer public intake profiles, Forms Runtime policy, recovery, and retirement.",
),
)
@@ -126,6 +137,38 @@ def _router(context: ModuleContext):
return create_router(context.registry)
def _public_tenant_resolver(request: object, session: object) -> str | None:
if not hasattr(session, "query"):
return None
path = str(getattr(getattr(request, "url", None), "path", ""))
path_params = getattr(request, "path_params", {})
if "/forms-runtime/public/profiles/" in path:
public_id = str(path_params.get("public_id") or "").strip()
if not public_id:
return None
profile = (
session.query(runtime_models.FormIntakeProfile)
.filter(runtime_models.FormIntakeProfile.public_id == public_id)
.one_or_none()
)
return profile.tenant_id if profile is not None else None
if "/forms-runtime/public/intake" not in path:
return None
headers = getattr(request, "headers", {})
token = str(headers.get("X-Form-Intake-Token") or "").strip()
if len(token) < 32:
return None
intake_session = (
session.query(runtime_models.FormIntakeSession)
.filter(
runtime_models.FormIntakeSession.token_sha256
== hashlib.sha256(token.encode("utf-8")).hexdigest()
)
.one_or_none()
)
return intake_session.tenant_id if intake_session is not None else None
def _registry(context: ModuleContext) -> FormRuntimeService:
return FormRuntimeService(context.registry)
@@ -146,6 +189,17 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
("started", "draft", "submitted", "validated", "needs_review")
)
).count(),
"public_intake_profiles": session.query(runtime_models.FormIntakeProfile)
.filter(runtime_models.FormIntakeProfile.tenant_id == tenant_id)
.count(),
"public_intake_sessions": session.query(runtime_models.FormIntakeSession)
.filter(runtime_models.FormIntakeSession.tenant_id == tenant_id)
.count(),
"authenticated_acknowledgements": session.query(
runtime_models.FormAcknowledgement
)
.filter(runtime_models.FormAcknowledgement.tenant_id == tenant_id)
.count(),
}
@@ -169,6 +223,7 @@ manifest = ModuleManifest(
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
public_tenant_resolver=_public_tenant_resolver,
nav_items=(
NavItem(
path="/forms-runtime",
@@ -195,6 +250,18 @@ manifest = ModuleManifest(
order=38,
),
),
public_routes=(
PublicFrontendRoute(
path="/forms/public/:publicId",
component="PublicFormPage",
order=10,
),
PublicFrontendRoute(
path="/forms/intake/:token",
component="PublicFormPage",
order=11,
),
),
nav_items=(
NavItem(
path="/forms-runtime",
@@ -231,6 +298,15 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
ModuleInterfaceProvider(name="forms_runtime.public_intake", version="1.0.0"),
ModuleInterfaceProvider(
name="forms_runtime.authenticated_acknowledgement",
version="1.0.0",
),
ModuleInterfaceProvider(
name=CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME,
version="1.0.0",
),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -260,6 +336,7 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry,
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher,
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME: create_forms_runtime_record_source,
},
capability_documentation={
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
@@ -272,6 +349,11 @@ manifest = ModuleManifest(
summary="Starts a replay-safe Form instance from an exact published Service and Form revision.",
contract_version="0.1.0",
),
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME: CapabilityDocumentation(
label="Form submission record source",
summary="Resolves currently authorized immutable submission revisions for Records filing.",
contract_version="1.0.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -280,6 +362,9 @@ manifest = ModuleManifest(
migration_after=("forms",),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
runtime_models.FormAcknowledgement,
runtime_models.FormIntakeSession,
runtime_models.FormIntakeProfile,
runtime_models.FormHandoffEffect,
runtime_models.FormInstanceEvent,
runtime_models.FormInstanceRevision,
@@ -290,6 +375,9 @@ manifest = ModuleManifest(
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
runtime_models.FormAcknowledgement,
runtime_models.FormIntakeSession,
runtime_models.FormIntakeProfile,
runtime_models.FormHandoffEffect,
runtime_models.FormInstanceIdentity,
runtime_models.FormInstanceRevision,
@@ -297,6 +385,12 @@ manifest = ModuleManifest(
label=MODULE_NAME,
),
),
search_sources=(
SearchSourceProviderRegistration(
id="forms_runtime.submissions",
factory=create_forms_runtime_search_source,
),
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
@@ -306,6 +400,7 @@ manifest = ModuleManifest(
body=(
"Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. "
"Service launches retain the exact Service and binding. Native Case and Workflow handoffs persist intent before execution, use owner capabilities with stable provider keys, and require reconciliation after unknown outcomes. History, receipts, and handoffs are replay-safe and optimistic-concurrency guarded."
" Invitation and explicitly enabled anonymous intake use hash-only expiring tokens, bounded rate limits, and isolated synthetic actors. Files-backed attachments use one-time purpose-bound grants, while authenticated acknowledgements bind an exact actor and payload digest without claiming advanced or qualified signature assurance. When Search is enabled, Forms Runtime contributes a rebuildable metadata-only projection; submitted values and evidence content are excluded, and every candidate receives a current workspace or participant access check."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -323,6 +418,8 @@ manifest = ModuleManifest(
"forms_runtime.navigation",
"forms_runtime.workspace",
"forms_runtime.instance",
"forms_runtime.public-intake",
"forms_runtime.search.result",
"forms_runtime.state.read-only",
"forms_runtime.state.permission-blocked",
],
@@ -330,6 +427,7 @@ manifest = ModuleManifest(
"Form values are returned only through tenant-bound instance permissions and ownership rules.",
"Validation messages expose field-level diagnostics without disclosing unrelated submissions.",
"Handoff rows retain provider references and outcomes but do not bypass target-module authorization.",
"Public intake tokens and Files upload tokens are retained only as cryptographic digests; anonymous submissions cannot later be claimed by an identity.",
],
},
),
@@ -343,7 +441,8 @@ manifest = ModuleManifest(
"Submitting validates values, attachments, signatures, and policy requirements and records an immutable receipt; it is "
"not an editable draft save. A Case or Workflow handoff records intent before calling its optional provider and uses a "
"stable idempotency key. Rejected effects may be retried. Unknown outcomes must be reconciled before retry to avoid a "
"duplicate target. Administrative compensation records verified absence and never deletes a remote target."
"duplicate target. Administrative compensation records verified absence and never deletes a remote target. "
"When Records is enabled, only immutable submitted revisions can be resolved for filing; current Forms Runtime access is rechecked and editable drafts fail closed."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -368,6 +467,11 @@ manifest = ModuleManifest(
"forms_runtime.action.start-handoff",
"forms_runtime.action.reconcile-handoff",
"forms_runtime.action.compensate-handoff",
"forms_runtime.action.create-intake-profile",
"forms_runtime.action.issue-invitation",
"forms_runtime.action.upload-evidence",
"forms_runtime.action.acknowledge",
"records.action.file",
],
"consequence_classes": {
"save_draft": "Creates an immutable draft revision with a change reason.",
@@ -375,6 +479,10 @@ manifest = ModuleManifest(
"start_handoff": "Persists intent before invoking an optional Case or Workflow provider.",
"reconcile": "Resolves an outcome-unknown effect without unsafe duplicate execution.",
"compensate": "Records an administrative proof that no target effect exists.",
"public_intake": "Starts an isolated, expiring invitation or explicitly enabled anonymous submission without granting general platform access.",
"upload_evidence": "Issues a short-lived provider grant; the returned immutable evidence reference must pass owner verification again at submission.",
"acknowledge": "Binds the acting account, statement version, exact Form revision, values, and attachments in an authenticated acknowledgement digest.",
"file_submission": "Resolves the exact immutable submission revision under current access and preserves only a digest-bound reference in Records.",
},
},
),
@@ -386,7 +494,7 @@ manifest = ModuleManifest(
documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
test_ref="tests/test_forms_runtime.py",
known_limits=(
"Anonymous public intake, concrete Files/signature adapters, and target kinds beyond the native Case/Workflow handoffs remain adapter depth; authenticated Portal entry is supported.",
"External advanced or qualified signature providers, scheduled expiry cleanup, and target kinds beyond native Case/Workflow handoffs remain adapter depth. Public links intentionally cannot substitute the native authenticated acknowledgement profile.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
@@ -0,0 +1,179 @@
"""Add governed public intake and acknowledgement evidence.
Revision ID: b4e6f8a0c2d3
Revises: a3d5f7b9c1e2
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b4e6f8a0c2d3"
down_revision = "a3d5f7b9c1e2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"form_intake_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("public_id", sa.String(length=64), nullable=False),
sa.Column("definition_id", sa.String(length=255), nullable=False),
sa.Column("definition_revision", sa.String(length=255), nullable=False),
sa.Column("mode", sa.String(length=30), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("custodian_ref", sa.String(length=255), nullable=False),
sa.Column("draft_ttl_seconds", sa.Integer(), nullable=False),
sa.Column("invitation_ttl_seconds", sa.Integer(), nullable=False),
sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False),
sa.Column("rate_window_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rate_window_count", sa.Integer(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=False),
sa.Column("updated_by", sa.String(length=255), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_intake_profiles")),
sa.UniqueConstraint("public_id", name="uq_form_intake_public_id"),
sa.UniqueConstraint("tenant_id", "profile_id", name="uq_form_intake_profile"),
)
for column in (
"tenant_id",
"profile_id",
"public_id",
"definition_id",
"definition_revision",
):
op.create_index(
op.f(f"ix_form_intake_profiles_{column}"),
"form_intake_profiles",
[column],
unique=False,
)
op.create_index(
"ix_form_intake_definition",
"form_intake_profiles",
["tenant_id", "definition_id", "definition_revision"],
unique=False,
)
op.create_table(
"form_intake_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("session_id", sa.String(length=36), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("token_sha256", sa.String(length=64), nullable=False),
sa.Column("mode", sa.String(length=30), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=True),
sa.Column("actor_id", sa.String(length=255), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["profile_id"],
["form_intake_profiles.id"],
name=op.f("fk_form_intake_sessions_profile_id_form_intake_profiles"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_intake_sessions")),
sa.UniqueConstraint("token_sha256", name="uq_form_intake_token"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_form_intake_session_idempotency"
),
sa.UniqueConstraint("tenant_id", "session_id", name="uq_form_intake_session"),
)
for column in (
"tenant_id",
"session_id",
"profile_id",
"token_sha256",
"mode",
"status",
"instance_id",
"actor_id",
"expires_at",
"started_at",
"submitted_at",
"revoked_at",
"created_by",
):
op.create_index(
op.f(f"ix_form_intake_sessions_{column}"),
"form_intake_sessions",
[column],
unique=False,
)
op.create_index(
"ix_form_intake_session_state",
"form_intake_sessions",
["tenant_id", "profile_id", "status", "expires_at"],
unique=False,
)
op.create_table(
"form_acknowledgements",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("acknowledgement_id", sa.String(length=36), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=False),
sa.Column("instance_revision", sa.Integer(), nullable=False),
sa.Column("statement_id", sa.String(length=255), nullable=False),
sa.Column("statement_version", sa.String(length=255), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("payload_sha256", sa.String(length=64), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_acknowledgements")),
sa.UniqueConstraint(
"tenant_id", "acknowledgement_id", name="uq_form_acknowledgement"
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_acknowledgement_idempotency",
),
)
for column in (
"tenant_id",
"acknowledgement_id",
"instance_id",
"actor_id",
"accepted_at",
):
op.create_index(
op.f(f"ix_form_acknowledgements_{column}"),
"form_acknowledgements",
[column],
unique=False,
)
op.create_index(
"ix_form_acknowledgement_instance",
"form_acknowledgements",
["tenant_id", "instance_id", "instance_revision"],
unique=False,
)
def downgrade() -> None:
op.drop_table("form_acknowledgements")
op.drop_table("form_intake_sessions")
op.drop_table("form_intake_profiles")
@@ -72,13 +72,17 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(
["identity_id"],
["form_instance_identities.id"],
name=op.f("fk_form_instance_revisions_identity_id_form_instance_identities"),
name=op.f(
"fk_form_instance_revisions_identity_id_form_instance_identities"
),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["form_instance_revisions.id"],
name=op.f("fk_form_instance_revisions_previous_revision_id_form_instance_revisions"),
name=op.f(
"fk_form_instance_revisions_previous_revision_id_form_instance_revisions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_revisions")),
@@ -0,0 +1,152 @@
from __future__ import annotations
from collections.abc import Sequence
import hashlib
import json
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.core.records import (
RecordContractError,
RecordSourceLocator,
RecordSourceReference,
)
from govoplan_forms_runtime.backend.db.models import FormInstanceRevision
from govoplan_forms_runtime.backend.service import FormRuntimeService
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME = "records.source.forms_runtime"
_FILEABLE_STATUSES = frozenset(
{
"submitted",
"validated",
"needs_review",
"accepted",
"rejected",
"handed_off",
"archived",
}
)
class FormsRuntimeRecordSource:
provider_id = "forms_runtime"
def resource_types(self) -> Sequence[str]:
return ("form_submission_revision",)
def resolve(
self,
session: object,
principal: object,
*,
locator: RecordSourceLocator,
purpose: str,
) -> RecordSourceReference:
if not isinstance(session, Session):
raise RecordContractError(
"Form submission record references require a database session."
)
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id or locator.tenant_id != tenant_id:
raise RecordContractError(
"Form submission record references cannot cross tenants."
)
if (
locator.source_module != "forms_runtime"
or locator.resource_type != "form_submission_revision"
):
raise RecordContractError("Unsupported Forms Runtime record source type.")
if not str(purpose or "").strip():
raise RecordContractError(
"Form submission record references require a purpose."
)
has_participant_scope = _has(principal, "forms_runtime:submission:participate")
has_tenant_read = _has(principal, "forms_runtime:workspace:read")
if not has_participant_scope and not has_tenant_read:
raise RecordContractError(
"Current Forms Runtime read permission is required."
)
try:
revision = int(locator.source_revision)
except ValueError as exc:
raise RecordContractError(
"Form submission source revisions must be numeric."
) from exc
try:
instance = FormRuntimeService(None).get_instance(
session,
principal,
instance_id=locator.resource_id,
revision=revision,
allow_all=has_tenant_read,
)
except PermissionError as exc:
raise RecordContractError(
"The current principal cannot read this Form submission."
) from exc
if instance is None:
raise RecordContractError(
"The exact Form submission revision does not exist."
)
if instance.status not in _FILEABLE_STATUSES:
raise RecordContractError(
"Editable Form drafts cannot be filed as an institutional record."
)
row = (
session.query(FormInstanceRevision)
.filter(
FormInstanceRevision.tenant_id == tenant_id,
FormInstanceRevision.instance_id == locator.resource_id,
FormInstanceRevision.revision == revision,
)
.one()
)
snapshot_json = json.dumps(
row.snapshot,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
label_id = instance.receipt_id or instance.instance_id
return RecordSourceReference(
locator=locator,
label=f"Form submission {label_id}",
authority_mode="external_authoritative",
content_sha256=hashlib.sha256(snapshot_json).hexdigest(),
content_type="application/vnd.govoplan.form-submission-revision+json",
size_bytes=len(snapshot_json),
valid_from=instance.recorded_at,
recorded_at=instance.recorded_at,
launch_url=f"/forms-runtime/{quote(instance.instance_id, safe='')}",
metadata={
"status": instance.status,
"definition_ref": instance.definition_ref.to_dict(),
"service_ref": (
instance.service_ref.to_dict() if instance.service_ref else None
),
"receipt_id": instance.receipt_id,
"attachment_count": len(instance.attachment_refs),
"signature_count": len(instance.signature_refs),
"handoff_count": len(instance.handoff_refs),
"snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(),
},
)
def create_forms_runtime_record_source(
_context: object,
) -> FormsRuntimeRecordSource:
return FormsRuntimeRecordSource()
def _has(principal: object, scope: str) -> bool:
return bool(hasattr(principal, "has") and principal.has(scope))
__all__ = [
"CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME",
"FormsRuntimeRecordSource",
"create_forms_runtime_record_source",
]
+386 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
@@ -17,7 +19,9 @@ from govoplan_forms_runtime.backend.manifest import (
WRITE_SCOPE,
)
from govoplan_forms_runtime.backend.schemas import (
FormAcknowledgementRequest,
FormDraftUpdateRequest,
FormEvidenceGrantCreateRequest,
FormHandoffRequest,
FormHandoffActionRequest,
FormHandoffCompensateRequest,
@@ -25,11 +29,20 @@ from govoplan_forms_runtime.backend.schemas import (
FormInstanceEventsResponse,
FormInstanceHistoryResponse,
FormInstanceListResponse,
FormIntakeInvitationRequest,
FormIntakeProfileCreateRequest,
FormIntakeProfileStateRequest,
PublicFormStartRequest,
FormSubmitRequest,
FormTransitionRequest,
FormNativeHandoffRequest,
)
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
from govoplan_forms_runtime.backend.intake import (
FormIntakeError,
FormIntakeService,
profile_payload,
)
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
@@ -37,6 +50,232 @@ def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"])
runtime = FormRuntimeService(registry)
handoffs = FormHandoffService(registry)
intake = FormIntakeService(registry)
@router.get("/intake-profiles", response_model=dict[str, object])
def api_list_intake_profiles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
return {
"profiles": [
profile_payload(item)
for item in intake.list_profiles(session, principal)
]
}
@router.get("/intake-profile-definitions", response_model=dict[str, object])
def api_list_intake_profile_definitions(
query: str = Query(default="", max_length=255),
limit: int = Query(default=200, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
try:
definitions = intake.list_available_definitions(
session,
principal,
query=query,
limit=limit,
)
except (FormIntakeError, PermissionError) as exc:
raise _error(exc) from exc
return {"definitions": [item.to_dict() for item in definitions]}
@router.post(
"/intake-profiles",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_create_intake_profile(
payload: FormIntakeProfileCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
try:
profile = intake.create_profile(
session,
principal,
definition_ref=InstitutionalReference.from_mapping(
payload.definition_ref
),
mode=payload.mode,
custodian_ref=_custodian_ref(principal),
draft_ttl_seconds=payload.draft_ttl_seconds,
invitation_ttl_seconds=payload.invitation_ttl_seconds,
rate_limit_per_minute=payload.rate_limit_per_minute,
metadata=payload.metadata,
recorded_at=payload.recorded_at,
)
session.commit()
except (FormIntakeError, InstitutionalContextError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return profile_payload(profile)
@router.patch("/intake-profiles/{profile_id}", response_model=dict[str, object])
def api_set_intake_profile_state(
profile_id: str,
payload: FormIntakeProfileStateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
try:
profile = intake.set_profile_enabled(
session,
principal,
profile_id=profile_id,
expected_revision=payload.expected_revision,
enabled=payload.enabled,
)
session.commit()
except (FormIntakeError, LookupError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return profile_payload(profile)
@router.post(
"/intake-profiles/{profile_id}/invitations",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_issue_intake_invitation(
profile_id: str,
payload: FormIntakeInvitationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
try:
result = intake.issue_invitation(
session,
principal,
profile_id=profile_id,
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
expires_at=payload.expires_at,
metadata=payload.metadata,
)
session.commit()
except (FormIntakeError, LookupError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return result.to_dict()
@router.post(
"/public/profiles/{public_id}/start",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_start_anonymous_intake(
public_id: str,
payload: PublicFormStartRequest,
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
result = intake.start_anonymous(
session,
public_id=public_id,
values=payload.values,
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
)
session.commit()
except (FormIntakeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc) from exc
return result.to_dict()
@router.post(
"/public/intake/start",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_start_invitation_intake(
payload: PublicFormStartRequest,
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
result = intake.start_invitation(
session,
token=x_form_intake_token,
values=payload.values,
recorded_at=payload.recorded_at,
)
session.commit()
except (FormIntakeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc) from exc
return result.to_dict()
@router.get("/public/intake", response_model=dict[str, object])
def api_get_public_intake(
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
instance, definition = intake.get_public_instance(
session,
token=x_form_intake_token,
now=datetime.now(UTC),
)
session.commit()
except (FormIntakeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc) from exc
return {"instance": instance.to_dict(), "definition": definition.to_dict()}
@router.patch("/public/intake", response_model=dict[str, object])
def api_update_public_intake(
payload: FormDraftUpdateRequest,
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
instance = intake.update_public_draft(
session,
token=x_form_intake_token,
expected_revision=payload.expected_revision,
values=payload.values,
attachment_refs=_evidence(payload.attachment_refs),
signature_refs=_evidence(payload.signature_refs),
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc, disclose_valid_request=True) from exc
return instance.to_dict()
@router.post("/public/intake/submit", response_model=dict[str, object])
def api_submit_public_intake(
payload: FormSubmitRequest,
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
instance = intake.submit_public(
session,
token=x_form_intake_token,
expected_revision=payload.expected_revision,
values=payload.values,
attachment_refs=_evidence(payload.attachment_refs),
signature_refs=_evidence(payload.signature_refs),
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc, disclose_valid_request=True) from exc
return instance.to_dict()
@router.get("/instances", response_model=FormInstanceListResponse)
def api_list_instances(
@@ -212,6 +451,110 @@ def create_router(registry: object | None) -> APIRouter:
raise _error(exc) from exc
return item.to_dict()
@router.post(
"/instances/{instance_id}/evidence-grants",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_create_evidence_grant(
instance_id: str,
payload: FormEvidenceGrantCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
try:
grant = runtime.create_evidence_grant(
session,
principal,
instance_id=instance_id,
expected_revision=payload.expected_revision,
provider_id=payload.provider_id,
custodian_ref=_custodian_ref(principal),
purpose=payload.purpose,
idempotency_key=payload.idempotency_key,
expires_at=payload.expires_at,
max_size_bytes=payload.max_size_bytes,
allowed_content_types=payload.allowed_content_types,
attachment_refs=_evidence(payload.attachment_refs),
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return _grant_payload(grant)
@router.post(
"/public/intake/evidence-grants",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_create_public_evidence_grant(
payload: FormEvidenceGrantCreateRequest,
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
intake_session, profile, public_principal = intake.session_context(
session,
token=x_form_intake_token,
now=datetime.now(UTC),
)
if not intake_session.instance_id:
raise FormIntakeError("Public Form intake is unavailable.")
grant = runtime.create_evidence_grant(
session,
public_principal,
instance_id=intake_session.instance_id,
expected_revision=payload.expected_revision,
provider_id=payload.provider_id,
custodian_ref=profile.custodian_ref,
purpose=payload.purpose,
idempotency_key=payload.idempotency_key,
expires_at=payload.expires_at,
max_size_bytes=payload.max_size_bytes,
allowed_content_types=payload.allowed_content_types,
attachment_refs=_evidence(payload.attachment_refs),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc, disclose_valid_request=True) from exc
return _grant_payload(grant)
@router.post(
"/instances/{instance_id}/acknowledgements",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_acknowledge_form(
instance_id: str,
payload: FormAcknowledgementRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
try:
reference = runtime.acknowledge(
session,
principal,
instance_id=instance_id,
expected_revision=payload.expected_revision,
statement_id=payload.statement_id,
statement_version=payload.statement_version,
values=payload.values,
attachment_refs=_evidence(payload.attachment_refs),
accepted_at=payload.accepted_at,
idempotency_key=payload.idempotency_key,
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return {"evidence": reference.to_dict(include_inspection=True)}
@router.post(
"/instances/{instance_id}/transition",
response_model=dict[str, object],
@@ -542,4 +885,46 @@ def _error(exc: Exception) -> HTTPException:
return HTTPException(status_code=code, detail=message)
def _public_error(
exc: Exception,
*,
disclose_valid_request: bool = False,
) -> HTTPException:
message = str(exc)
if "rate limited" in message.casefold():
return HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Public Form intake is temporarily unavailable.",
)
if disclose_valid_request and not isinstance(exc, FormIntakeError):
lowered = message.casefold()
code = 409 if any(word in lowered for word in ("conflict", "stale")) else 422
return HTTPException(status_code=code, detail=message)
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Public Form intake is unavailable.",
)
def _custodian_ref(principal: ApiPrincipal) -> str:
user_id = str(getattr(principal.user, "id", "") or "").strip()
if user_id:
return f"user:{user_id}"
return f"account:{principal.account_id}"
def _grant_payload(grant: object) -> dict[str, object]:
upload_token = getattr(grant, "upload_token")
return {
"provider_id": str(getattr(grant, "provider_id")),
"grant_id": str(getattr(grant, "grant_id")),
"upload_token": str(upload_token) if upload_token is not None else None,
"upload_url": str(getattr(grant, "upload_url")),
"expires_at": getattr(grant, "expires_at").isoformat(),
"max_size_bytes": int(getattr(grant, "max_size_bytes")),
"allowed_content_types": list(getattr(grant, "allowed_content_types")),
"replayed": bool(getattr(grant, "replayed", False)),
}
__all__ = ["create_router"]
@@ -89,6 +89,67 @@ class FormHandoffCompensateRequest(FormHandoffActionRequest):
change_reason: str = Field(min_length=1, max_length=1000)
class FormIntakeProfileCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition_ref: dict[str, Any]
mode: Literal["anonymous", "invitation"]
draft_ttl_seconds: int = Field(default=2_592_000, ge=60, le=31_536_000)
invitation_ttl_seconds: int = Field(default=1_209_600, ge=60, le=7_776_000)
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
recorded_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class FormIntakeProfileStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
enabled: bool
class FormIntakeInvitationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
expires_at: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class PublicFormStartRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
values: dict[str, Any] = Field(default_factory=dict)
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
class FormEvidenceGrantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
provider_id: str = Field(default="files", min_length=1, max_length=120)
purpose: str = Field(min_length=1, max_length=255)
idempotency_key: str = Field(min_length=1, max_length=255)
expires_at: datetime
max_size_bytes: int | None = Field(default=None, ge=1)
allowed_content_types: list[str] = Field(default_factory=list, max_length=100)
attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000)
class FormAcknowledgementRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
statement_id: str = Field(min_length=1, max_length=255)
statement_version: str = Field(min_length=1, max_length=255)
values: dict[str, Any] = Field(default_factory=dict)
attachment_refs: list[dict[str, Any]] = Field(default_factory=list)
accepted_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class FormInstanceListResponse(BaseModel):
instances: list[dict[str, Any]]
total: int
@@ -105,7 +166,9 @@ class FormInstanceEventsResponse(BaseModel):
__all__ = [
"FormAcknowledgementRequest",
"FormDraftUpdateRequest",
"FormEvidenceGrantCreateRequest",
"FormHandoffRequest",
"FormHandoffActionRequest",
"FormHandoffCompensateRequest",
@@ -114,6 +177,10 @@ __all__ = [
"FormInstanceEventsResponse",
"FormInstanceHistoryResponse",
"FormInstanceListResponse",
"FormIntakeInvitationRequest",
"FormIntakeProfileCreateRequest",
"FormIntakeProfileStateRequest",
"PublicFormStartRequest",
"FormSubmitRequest",
"FormTransitionRequest",
]
@@ -0,0 +1,289 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_forms_runtime.backend.db.models import (
FormInstanceIdentity,
FormInstanceRevision,
)
PROVIDER_ID = "forms_runtime.submissions"
RESOURCE_TYPE = "form_submission"
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
READ_SCOPE = "forms_runtime:workspace:read"
ADMIN_SCOPE = "forms_runtime:workspace:admin"
class FormsRuntimeSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="forms_runtime",
resource_type=RESOURCE_TYPE,
label="Form submissions",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = (
select(FormInstanceRevision, FormInstanceIdentity)
.join(
FormInstanceIdentity,
FormInstanceIdentity.id == FormInstanceRevision.identity_id,
)
.where(
FormInstanceRevision.tenant_id == request.tenant_id,
FormInstanceRevision.superseded_at.is_(None),
)
)
if request.cursor:
statement = statement.where(
FormInstanceRevision.instance_id > request.cursor
)
rows = list(
db.execute(
statement.order_by(FormInstanceRevision.instance_id).limit(
request.limit + 1
)
).all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(FormInstanceRevision.recorded_at)).where(
FormInstanceRevision.tenant_id == request.tenant_id,
FormInstanceRevision.superseded_at.is_(None),
)
)
return SearchBackfillPage(
documents=tuple(_document(row, identity=identity) for row, identity in selected),
next_cursor=selected[-1][0].instance_id if has_more and selected else None,
complete=not has_more,
high_watermark=high_watermark.isoformat() if high_watermark else None,
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal):
return decisions
may_read_all = principal.has(READ_SCOPE) or principal.has(ADMIN_SCOPE)
may_participate = principal.has(PARTICIPATE_SCOPE)
if not may_read_all and not may_participate:
return decisions
eligible = tuple(
item
for item in requests
if item.reference.tenant_id == principal.tenant_id
and item.reference.module_id == "forms_runtime"
and item.reference.resource_type == RESOURCE_TYPE
)
ids = {item.reference.resource_id for item in eligible}
identities = {
row.instance_id: row
for row in _session(session).scalars(
select(FormInstanceIdentity).where(
FormInstanceIdentity.tenant_id == principal.tenant_id,
FormInstanceIdentity.instance_id.in_(ids),
)
)
} if ids else {}
actors = {
str(value)
for value in (
principal.account_id,
principal.identity_id,
principal.membership_id,
)
if value
}
for item in eligible:
identity = identities.get(item.reference.resource_id)
decisions[item.reference.key] = bool(
identity
and (may_read_all or identity.created_by in actors)
)
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "forms_runtime"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
result = db.execute(
select(FormInstanceRevision, FormInstanceIdentity)
.join(
FormInstanceIdentity,
FormInstanceIdentity.id == FormInstanceRevision.identity_id,
)
.where(
FormInstanceRevision.tenant_id == event.tenant.id,
FormInstanceRevision.instance_id == event.resource.id,
FormInstanceRevision.superseded_at.is_(None),
)
).one_or_none()
cursor = event.event_id
document = (
_document(result[0], identity=result[1], change_cursor=cursor)
if result is not None
else None
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="forms_runtime",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="upsert" if document is not None else "delete",
reference=reference,
source_revision=(document.source_revision if document else cursor),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_forms_runtime_search_source(
_context: ModuleContext,
) -> FormsRuntimeSearchSource:
return FormsRuntimeSearchSource()
def _document(
row: FormInstanceRevision,
*,
identity: FormInstanceIdentity,
change_cursor: str | None = None,
) -> SearchDocument:
snapshot = dict(row.snapshot or {})
definition = _mapping(snapshot.get("definition_ref"))
definition_label = _text(definition.get("label"))
receipt_id = _text(snapshot.get("receipt_id"))
service = _mapping(snapshot.get("service_ref"))
service_id = _text(service.get("object_id"))
definition_id = identity.definition_id
title = definition_label or f"Form {definition_id}"
body = " ".join(
value
for value in (
definition_id,
identity.definition_revision,
receipt_id,
service_id,
row.status,
)
if value
)
tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"]
if identity.created_by:
tokens.extend(
(
f"account:{identity.created_by}",
f"identity:{identity.created_by}",
f"membership:{identity.created_by}",
)
)
return SearchDocument(
tenant_id=row.tenant_id,
module_id="forms_runtime",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.instance_id,
title=title[:500],
url=f"/forms-runtime/{quote(row.instance_id, safe='')}",
summary=(f"{row.status} - receipt {receipt_id}" if receipt_id else row.status),
body=body[:200_000],
keywords=tuple(
value[:200]
for value in (definition_id, identity.definition_revision, row.status)
if value
),
visibility="restricted",
acl_tokens=tuple(dict.fromkeys(tokens)),
metadata={
"definition_id": definition_id,
"definition_revision": identity.definition_revision,
"status": row.status,
"receipt_id": receipt_id,
"protected_values_indexed": False,
},
source_revision=str(row.revision),
change_cursor=change_cursor,
source_updated_at=row.recorded_at,
requires_authorization_recheck=True,
)
def _mapping(value: object) -> dict[str, object]:
return dict(value) if isinstance(value, Mapping) else {}
def _text(value: object) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Forms Runtime search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Forms Runtime search requires a SQLAlchemy session.")
return value
__all__ = [
"FormsRuntimeSearchSource",
"PROVIDER_ID",
"RESOURCE_TYPE",
"create_forms_runtime_search_source",
]
+182 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import replace
from datetime import UTC, date, datetime
import hashlib
import json
@@ -38,6 +39,7 @@ from govoplan_forms_runtime.backend.db.models import (
FormInstanceRevision,
)
from govoplan_forms_runtime.backend.domain import FormInstance
from govoplan_forms_runtime.backend.evidence import FormEvidenceCoordinator
CAPABILITY_FORMS_RUNTIME_REGISTRY = "forms_runtime.registry"
@@ -82,6 +84,7 @@ class FormRuntimePolicyEvaluator(Protocol):
class FormRuntimeService:
def __init__(self, registry: object | None) -> None:
self._registry = registry
self._evidence = FormEvidenceCoordinator(registry)
def create_instance(
self,
@@ -200,6 +203,27 @@ class FormRuntimeService:
changed_by=actor_id,
metadata=dict(metadata or {}),
)
evidence_diagnostics, evidence_snapshots = self._inspect_evidence(
session,
principal,
instance=instance,
definition=definition,
values=clean_values,
attachment_refs=attachments,
signature_refs=signatures,
purpose="start Form instance",
final=False,
observed_at=recorded_at,
)
if evidence_diagnostics or evidence_snapshots:
instance = replace(
instance,
validation_results=(*diagnostics, *evidence_diagnostics),
metadata={
**dict(instance.metadata),
"evidence_verification": list(evidence_snapshots),
},
)
return _record_instance(
session,
principal,
@@ -619,6 +643,111 @@ class FormRuntimeService:
for row in rows
)
def create_evidence_grant(
self,
session: Session,
principal: object,
*,
instance_id: str,
expected_revision: int,
provider_id: str,
custodian_ref: str,
purpose: str,
idempotency_key: str,
expires_at: datetime,
max_size_bytes: int | None = None,
allowed_content_types: Sequence[str] = (),
attachment_refs: Sequence[EvidenceReference] = (),
allow_all: bool = False,
):
current, _identity = _current_instance(
session,
principal,
instance_id=instance_id,
lock=True,
allow_all=allow_all,
)
if current.revision != expected_revision:
raise FormRuntimeError(
"Form instance revision conflict: the expected revision is stale."
)
definition = self._definition(
session,
principal,
reference=current.definition_ref,
effective_at=current.recorded_at,
)
try:
return self._evidence.create_upload_grant(
session,
principal,
instance=current,
definition=definition,
provider_id=provider_id,
custodian_ref=custodian_ref,
purpose=purpose,
idempotency_key=idempotency_key,
expires_at=expires_at,
max_size_bytes=max_size_bytes,
allowed_content_types=allowed_content_types,
attachment_refs=attachment_refs,
)
except ValueError as exc:
raise FormRuntimeError(str(exc)) from exc
def acknowledge(
self,
session: Session,
principal: object,
*,
instance_id: str,
expected_revision: int,
statement_id: str,
statement_version: str,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
accepted_at: datetime,
idempotency_key: str,
allow_all: bool = False,
) -> EvidenceReference:
current, _identity = _current_instance(
session,
principal,
instance_id=instance_id,
lock=True,
allow_all=allow_all,
)
definition = self._definition(
session,
principal,
reference=current.definition_ref,
effective_at=accepted_at,
)
clean_values = normalize_form_values(
definition,
_mapping_copy(values, "Form acknowledgement values"),
)
attachments = tuple(attachment_refs)
if any(item.tenant_id != current.tenant_id for item in attachments):
raise FormRuntimeError(
"Form acknowledgement attachments cannot cross tenants."
)
try:
return self._evidence.create_acknowledgement(
session,
principal,
instance=current,
expected_revision=expected_revision,
statement_id=statement_id,
statement_version=statement_version,
values=clean_values,
attachment_refs=attachments,
accepted_at=accepted_at,
idempotency_key=idempotency_key,
)
except ValueError as exc:
raise FormRuntimeError(str(exc)) from exc
def _revise(
self,
session: Session,
@@ -657,6 +786,19 @@ class FormRuntimeService:
signature_refs=signatures,
final=final_validation,
)
evidence_diagnostics, evidence_snapshots = self._inspect_evidence(
session,
principal,
instance=current,
definition=definition,
values=clean_values,
attachment_refs=attachments,
signature_refs=signatures,
purpose=operation.replace("_", " "),
final=final_validation,
observed_at=recorded_at,
)
diagnostics = (*diagnostics, *evidence_diagnostics)
request = {
"operation": operation,
"instance_id": current.instance_id,
@@ -716,7 +858,14 @@ class FormRuntimeService:
change_reason=clean_reason,
created_by=current.created_by,
changed_by=actor_id,
metadata=current.metadata,
metadata=(
{
**dict(current.metadata),
"evidence_verification": list(evidence_snapshots),
}
if evidence_snapshots
else current.metadata
),
)
current_row = (
session.query(FormInstanceRevision)
@@ -744,6 +893,38 @@ class FormRuntimeService:
operation=operation,
)
def _inspect_evidence(
self,
session: Session,
principal: object,
*,
instance: FormInstance,
definition: FormDefinition,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
signature_refs: Sequence[EvidenceReference],
purpose: str,
final: bool,
observed_at: datetime,
) -> tuple[tuple[Mapping[str, object], ...], tuple[Mapping[str, object], ...]]:
if not attachment_refs and not signature_refs:
return (), ()
try:
return self._evidence.inspect(
session,
principal,
instance=instance,
definition=definition,
values=values,
attachment_refs=attachment_refs,
signature_refs=signature_refs,
purpose=purpose,
final=final,
observed_at=observed_at,
)
except ValueError as exc:
raise FormRuntimeError(str(exc)) from exc
def _definition(
self,
session: Session,