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
+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=(