feat: add configurable application status access

This commit is contained in:
2026-08-19 12:33:34 +02:00
parent d83bb92ec8
commit 87d12519b7
17 changed files with 2073 additions and 9 deletions
@@ -342,6 +342,148 @@ class FormAssistedConfirmation(Base, TimestampMixin):
)
class FormStatusAccessPolicy(Base, TimestampMixin):
__tablename__ = "form_status_access_policies"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"policy_id",
name="uq_form_status_access_policy",
),
UniqueConstraint(
"tenant_id",
"definition_id",
"definition_revision",
name="uq_form_status_access_definition",
),
)
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)
policy_id: Mapped[str] = mapped_column(String(36), 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)
email_field_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
token_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
request_limit_per_hour: Mapped[int] = mapped_column(Integer, 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 FormStatusAccessGrant(Base, TimestampMixin):
__tablename__ = "form_status_access_grants"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"grant_id",
name="uq_form_status_access_grant",
),
UniqueConstraint("tracking_id", name="uq_form_status_tracking_id"),
UniqueConstraint(
"tenant_id",
"instance_id",
name="uq_form_status_access_instance",
),
Index(
"ix_form_status_access_policy_state",
"tenant_id",
"policy_id",
"revoked_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)
grant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
policy_id: Mapped[str] = mapped_column(
ForeignKey("form_status_access_policies.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
tracking_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
applicant_actor_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
recipient_email_sha256: Mapped[str | None] = mapped_column(
String(64), nullable=True
)
token_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
request_limit_per_hour: Mapped[int] = mapped_column(Integer, nullable=False)
request_window_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
request_window_count: Mapped[int] = mapped_column(
Integer, default=0, nullable=False
)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
last_accessed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class FormStatusAccessToken(Base, TimestampMixin):
__tablename__ = "form_status_access_tokens"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"token_id",
name="uq_form_status_access_token",
),
UniqueConstraint("token_sha256", name="uq_form_status_access_token_digest"),
Index(
"ix_form_status_access_token_state",
"tenant_id",
"grant_id",
"expires_at",
"revoked_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)
token_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
grant_id: Mapped[str] = mapped_column(
ForeignKey("form_status_access_grants.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
token_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notification_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class FormAcknowledgement(Base, TimestampMixin):
__tablename__ = "form_acknowledgements"
__table_args__ = (
@@ -393,4 +535,7 @@ __all__ = [
"FormIntakeSession",
"FormInstanceIdentity",
"FormInstanceRevision",
"FormStatusAccessGrant",
"FormStatusAccessPolicy",
"FormStatusAccessToken",
]
+64 -1
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import hashlib
from pathlib import Path
from govoplan_core.core.application_status import (
CAPABILITY_APPLICATION_STATUS_PROJECTION,
)
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
@@ -15,6 +18,7 @@ from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
@@ -51,6 +55,7 @@ from govoplan_forms_runtime.backend.service import (
FormRuntimeService,
FormsServiceLauncher,
)
from govoplan_forms_runtime.backend.status_access import FormStatusAccessService
MODULE_ID = "forms_runtime"
@@ -70,6 +75,7 @@ OPTIONAL_DEPENDENCIES = (
"policy",
"audit",
"records",
"notifications",
)
@@ -150,11 +156,25 @@ def _router(context: ModuleContext):
return create_router(context.registry)
def _status_projection(context: ModuleContext) -> FormStatusAccessService:
return FormStatusAccessService(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/status/" in path:
tracking_id = str(path_params.get("tracking_id") or "").strip()
if not tracking_id:
return None
grant = (
session.query(runtime_models.FormStatusAccessGrant)
.filter(runtime_models.FormStatusAccessGrant.tracking_id == tracking_id)
.one_or_none()
)
return grant.tenant_id if grant is not None else None
if "/forms-runtime/public/profiles/" in path:
public_id = str(path_params.get("public_id") or "").strip()
if not public_id:
@@ -231,6 +251,14 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
)
.filter(runtime_models.FormAssistedConfirmation.tenant_id == tenant_id)
.count(),
"status_access_policies": session.query(
runtime_models.FormStatusAccessPolicy
)
.filter(runtime_models.FormStatusAccessPolicy.tenant_id == tenant_id)
.count(),
"status_access_grants": session.query(runtime_models.FormStatusAccessGrant)
.filter(runtime_models.FormStatusAccessGrant.tenant_id == tenant_id)
.count(),
"authenticated_acknowledgements": session.query(
runtime_models.FormAcknowledgement
)
@@ -253,6 +281,7 @@ manifest = ModuleManifest(
optional_capabilities=(
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
CAPABILITY_SERVICE_DEFINITIONS,
CAPABILITY_NOTIFICATIONS_DISPATCH,
"cases.service_launcher",
"workflow_engine.service_launcher",
),
@@ -353,6 +382,9 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(
name="forms_runtime.assisted_intake", version="1.0.0"
),
ModuleInterfaceProvider(
name=CAPABILITY_APPLICATION_STATUS_PROJECTION, version="1.0.0"
),
ModuleInterfaceProvider(
name="forms_runtime.authenticated_acknowledgement",
version="1.0.0",
@@ -386,11 +418,18 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
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_APPLICATION_STATUS_PROJECTION: _status_projection,
},
capability_documentation={
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
@@ -408,6 +447,11 @@ manifest = ModuleManifest(
summary="Resolves currently authorized immutable submission revisions for Records filing.",
contract_version="1.0.0",
),
CAPABILITY_APPLICATION_STATUS_PROJECTION: CapabilityDocumentation(
label="Applicant status projection",
summary="Resolves tenant-bound, deliberately limited application status and configured access challenges.",
contract_version="1.0.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -418,6 +462,9 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider(
runtime_models.FormAcknowledgement,
runtime_models.FormAssistedConfirmation,
runtime_models.FormStatusAccessToken,
runtime_models.FormStatusAccessGrant,
runtime_models.FormStatusAccessPolicy,
runtime_models.FormIntakeSession,
runtime_models.FormIntakeProfile,
runtime_models.FormHandoffEffect,
@@ -432,6 +479,9 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard(
runtime_models.FormAcknowledgement,
runtime_models.FormAssistedConfirmation,
runtime_models.FormStatusAccessToken,
runtime_models.FormStatusAccessGrant,
runtime_models.FormStatusAccessPolicy,
runtime_models.FormIntakeSession,
runtime_models.FormIntakeProfile,
runtime_models.FormHandoffEffect,
@@ -456,7 +506,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. Authenticated assisted sessions retain channel, affected and represented parties, authority, purpose, notice, responsible function, language, accessibility needs, and field provenance without bypassing the exact Form rules. Submission requires immutable read-back evidence bound to the current revision, values, attachments, and signatures; any later draft edit invalidates it. 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."
" Invitation and explicitly enabled anonymous intake use hash-only expiring tokens, bounded rate limits, and isolated synthetic actors. Authenticated assisted sessions retain channel, affected and represented parties, authority, purpose, notice, responsible function, language, accessibility needs, and field provenance without bypassing the exact Form rules. Submission requires immutable read-back evidence bound to the current revision, values, attachments, and signatures; any later draft edit invalidates it. Administrators can configure applicant status per exact Form revision as authenticated-only, a short-lived link sent to a matching Form email, or a non-expiring public link. Status projections expose only a bounded lifecycle timeline and receipt reference, never Form values, actors, evidence, internal notes, or handoff details. 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"),
@@ -477,6 +527,8 @@ manifest = ModuleManifest(
"forms_runtime.public-intake",
"forms_runtime.assisted-intake",
"forms_runtime.assisted-confirmation",
"forms_runtime.status-access",
"forms_runtime.status-policy",
"forms_runtime.search.result",
"forms_runtime.state.read-only",
"forms_runtime.state.permission-blocked",
@@ -487,6 +539,8 @@ manifest = ModuleManifest(
"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.",
"Assisted session provenance names governed party/function references and purpose; operators should not duplicate names or evidence content in free-text references and notes.",
"Forms Runtime retains short-lived status secrets only as digests. The delivered URL necessarily passes to Notifications and Mail under their own retention; email comparison uses a grant-bound digest and unmatched requests receive the same response.",
"A permanent status URL is a non-expiring bearer link. Anyone holding it can see the bounded status projection until an administrator disables the exact Form policy or the grant is revoked.",
],
},
),
@@ -503,6 +557,7 @@ manifest = ModuleManifest(
"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."
" Assisted intake begins with an authenticated, purpose-bound session. The operator records the channel, party and representation references, authority basis, notice, responsible function, language, accessibility support, and per-field sources. Read-back outcomes are append-only and bind the exact current payload. Corrections must first be saved as a new draft revision and confirmed again; an unavailable confirmation requires an explicit exception note."
" Applicant status policies are attached to one exact published Form revision. Authenticated access is object-bound to the applicant account; assisted intake can bind it only from an explicit account party reference. Email-link mode compares the submitted email without revealing whether it matched, revokes the earlier link on resend, and delegates delivery to Notifications. Permanent-link mode deliberately trades authentication and expiry for possession of a stable high-entropy URL. Disabling a policy immediately suspends all grants issued under it; changing policy fields affects future submissions while existing grants retain their issued access profile."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -533,6 +588,8 @@ manifest = ModuleManifest(
"forms_runtime.action.acknowledge",
"forms_runtime.action.start-assisted-intake",
"forms_runtime.action.confirm-assisted-readback",
"forms_runtime.action.configure-status-access",
"forms_runtime.action.request-status-link",
"records.action.file",
],
"consequence_classes": {
@@ -546,6 +603,8 @@ manifest = ModuleManifest(
"acknowledge": "Binds the acting account, statement version, exact Form revision, values, and attachments in an authenticated acknowledgement digest.",
"start_assisted_intake": "Creates a resumable authenticated draft with explicit channel, party, authority, purpose, notice, function, accessibility, and source provenance.",
"confirm_assisted_readback": "Creates immutable evidence for the exact current revision and payload; a later correction requires a new confirmation before submission.",
"configure_status_access": "Selects the applicant-status access and disclosure profile for future submissions of one exact published Form revision; disabling it suspends existing grants.",
"request_status_link": "Returns a generic response, rate-limits attempts, and—only after a linked-email match—revokes the previous short-lived secret and asks Notifications to deliver a new one.",
"file_submission": "Resolves the exact immutable submission revision under current access and preserves only a digest-bound reference in Records.",
},
},
@@ -560,6 +619,7 @@ manifest = ModuleManifest(
known_limits=(
"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.",
"The assisted-intake API retains per-field provenance, while the first operator dialog applies one selected source/confidence profile to all populated values; mixed-source field editing remains UI depth.",
"Status policy field changes apply to future submissions; existing grants retain their issued mode and limits, while disabling the policy suspends all of them immediately. Scheduled token cleanup and per-IP throttling remain operations depth.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
@@ -568,6 +628,8 @@ manifest = ModuleManifest(
"runtime validation",
"submission receipt",
"form handoff evidence",
"applicant status access policy",
"applicant status grant",
),
non_owned_concepts=(
"form definition",
@@ -575,6 +637,7 @@ manifest = ModuleManifest(
"case",
"workflow definition",
"signature key custody",
"notification delivery",
),
reference_packages=("product.service-to-decision",),
migration_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
@@ -0,0 +1,184 @@
"""Add configurable applicant status access policies and grants.
Revision ID: d6a8b0c2e4f6
Revises: c5f7a9b1d3e4
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d6a8b0c2e4f6"
down_revision = "c5f7a9b1d3e4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"form_status_access_policies",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("policy_id", sa.String(length=36), 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("email_field_key", sa.String(length=255), nullable=True),
sa.Column("token_ttl_seconds", sa.Integer(), nullable=False),
sa.Column("request_limit_per_hour", 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_status_access_policies")),
sa.UniqueConstraint(
"tenant_id", "policy_id", name="uq_form_status_access_policy"
),
sa.UniqueConstraint(
"tenant_id",
"definition_id",
"definition_revision",
name="uq_form_status_access_definition",
),
)
for column in (
"tenant_id",
"policy_id",
"definition_id",
"definition_revision",
"mode",
"created_by",
"updated_by",
):
op.create_index(
op.f(f"ix_form_status_access_policies_{column}"),
"form_status_access_policies",
[column],
unique=False,
)
op.create_table(
"form_status_access_grants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("grant_id", sa.String(length=36), nullable=False),
sa.Column("policy_id", sa.String(length=36), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=False),
sa.Column("tracking_id", sa.String(length=64), nullable=False),
sa.Column("mode", sa.String(length=30), nullable=False),
sa.Column("applicant_actor_id", sa.String(length=255), nullable=True),
sa.Column("recipient_email_sha256", sa.String(length=64), nullable=True),
sa.Column("token_ttl_seconds", sa.Integer(), nullable=False),
sa.Column("request_limit_per_hour", sa.Integer(), nullable=False),
sa.Column("request_window_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("request_window_count", sa.Integer(), nullable=False),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_accessed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
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(
["policy_id"],
["form_status_access_policies.id"],
name=op.f(
"fk_form_status_access_grants_policy_id_form_status_access_policies"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_status_access_grants")),
sa.UniqueConstraint(
"tenant_id", "grant_id", name="uq_form_status_access_grant"
),
sa.UniqueConstraint("tracking_id", name="uq_form_status_tracking_id"),
sa.UniqueConstraint(
"tenant_id", "instance_id", name="uq_form_status_access_instance"
),
)
for column in (
"tenant_id",
"grant_id",
"policy_id",
"instance_id",
"tracking_id",
"mode",
"applicant_actor_id",
"issued_at",
"revoked_at",
):
op.create_index(
op.f(f"ix_form_status_access_grants_{column}"),
"form_status_access_grants",
[column],
unique=False,
)
op.create_index(
"ix_form_status_access_policy_state",
"form_status_access_grants",
["tenant_id", "policy_id", "revoked_at"],
unique=False,
)
op.create_table(
"form_status_access_tokens",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("token_id", sa.String(length=36), nullable=False),
sa.Column("grant_id", sa.String(length=36), nullable=False),
sa.Column("token_sha256", sa.String(length=64), nullable=False),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notification_id", sa.String(length=255), nullable=True),
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(
["grant_id"],
["form_status_access_grants.id"],
name=op.f(
"fk_form_status_access_tokens_grant_id_form_status_access_grants"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_status_access_tokens")),
sa.UniqueConstraint(
"tenant_id", "token_id", name="uq_form_status_access_token"
),
sa.UniqueConstraint(
"token_sha256", name="uq_form_status_access_token_digest"
),
)
for column in (
"tenant_id",
"token_id",
"grant_id",
"token_sha256",
"issued_at",
"expires_at",
"revoked_at",
):
op.create_index(
op.f(f"ix_form_status_access_tokens_{column}"),
"form_status_access_tokens",
[column],
unique=False,
)
op.create_index(
"ix_form_status_access_token_state",
"form_status_access_tokens",
["tenant_id", "grant_id", "expires_at", "revoked_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("form_status_access_tokens")
op.drop_table("form_status_access_grants")
op.drop_table("form_status_access_policies")
+172 -6
View File
@@ -35,6 +35,8 @@ from govoplan_forms_runtime.backend.schemas import (
FormIntakeInvitationRequest,
FormIntakeProfileCreateRequest,
FormIntakeProfileStateRequest,
FormStatusAccessPolicyRequest,
FormStatusEmailLinkRequest,
PublicFormStartRequest,
FormSubmitRequest,
FormTransitionRequest,
@@ -48,6 +50,12 @@ from govoplan_forms_runtime.backend.intake import (
profile_payload,
)
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
from govoplan_forms_runtime.backend.status_access import (
FormStatusAccessError,
FormStatusAccessService,
FormStatusUnavailable,
status_policy_payload,
)
def create_router(registry: object | None) -> APIRouter:
@@ -55,6 +63,7 @@ def create_router(registry: object | None) -> APIRouter:
runtime = FormRuntimeService(registry)
handoffs = FormHandoffService(registry)
intake = FormIntakeService(registry)
status_access = FormStatusAccessService(registry)
@router.get("/intake-profiles", response_model=dict[str, object])
def api_list_intake_profiles(
@@ -69,6 +78,137 @@ def create_router(registry: object | None) -> APIRouter:
]
}
@router.get("/status-access/policies", response_model=dict[str, object])
def api_list_status_access_policies(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
return {
"policies": [
status_policy_payload(item)
for item in status_access.list_policies(session, principal)
]
}
@router.put("/status-access/policies", response_model=dict[str, object])
def api_upsert_status_access_policy(
payload: FormStatusAccessPolicyRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
try:
policy = status_access.upsert_policy(
session,
principal,
definition_ref=InstitutionalReference.from_mapping(
payload.definition_ref
),
mode=payload.mode,
enabled=payload.enabled,
email_field_key=payload.email_field_key,
token_ttl_seconds=payload.token_ttl_seconds,
request_limit_per_hour=payload.request_limit_per_hour,
expected_revision=payload.expected_revision,
metadata=payload.metadata,
recorded_at=payload.recorded_at,
)
session.commit()
except (
FormStatusAccessError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return status_policy_payload(policy)
@router.get(
"/public/status/{tracking_id}/access",
response_model=dict[str, object],
)
def api_public_status_access_challenge(
tracking_id: str,
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
return status_access.public_access_challenge(
session,
tracking_id=tracking_id,
)
except FormStatusUnavailable as exc:
raise _status_unavailable() from exc
@router.post(
"/public/status/{tracking_id}/email-links",
response_model=dict[str, object],
status_code=status.HTTP_202_ACCEPTED,
)
def api_request_public_status_email_link(
tracking_id: str,
payload: FormStatusEmailLinkRequest,
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
status_access.request_email_link(
session,
tracking_id=tracking_id,
email=payload.email,
requested_at=datetime.now(UTC),
)
session.commit()
except Exception:
# A matched email, a missing provider, and an unknown tracking id
# must remain indistinguishable to an unauthenticated caller.
session.rollback()
return {
"accepted": True,
"message": (
"If the application and email address match, a new short-lived "
"status link will be sent. Any earlier link is then revoked."
),
}
@router.get("/public/status/{tracking_id}", response_model=dict[str, object])
def api_get_public_status(
tracking_id: str,
token: str | None = Query(default=None, max_length=500),
session: Session = Depends(get_session),
) -> dict[str, object]:
try:
projection = status_access.get_public_projection(
session,
tracking_id=tracking_id,
token=token,
observed_at=datetime.now(UTC),
)
session.commit()
except FormStatusUnavailable as exc:
session.rollback()
raise _status_unavailable() from exc
return projection
@router.get("/status/{tracking_id}", response_model=dict[str, object])
def api_get_authenticated_status(
tracking_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
projection = status_access.get_authenticated_projection(
session,
principal,
tracking_id=tracking_id,
observed_at=datetime.now(UTC),
)
session.commit()
except FormStatusUnavailable as exc:
session.rollback()
raise _status_unavailable() from exc
return projection
@router.get("/intake-profile-definitions", response_model=dict[str, object])
def api_list_intake_profile_definitions(
query: str = Query(default="", max_length=255),
@@ -358,7 +498,10 @@ def create_router(registry: object | None) -> APIRouter:
except (FormIntakeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc) from exc
return {"instance": instance.to_dict(), "definition": definition.to_dict()}
return {
"instance": _instance_payload(instance, session, status_access),
"definition": definition.to_dict(),
}
@router.patch("/public/intake", response_model=dict[str, object])
def api_update_public_intake(
@@ -382,7 +525,7 @@ def create_router(registry: object | None) -> APIRouter:
except (FormRuntimeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc, disclose_valid_request=True) from exc
return instance.to_dict()
return _instance_payload(instance, session, status_access)
@router.post("/public/intake/submit", response_model=dict[str, object])
def api_submit_public_intake(
@@ -405,7 +548,7 @@ def create_router(registry: object | None) -> APIRouter:
except (FormRuntimeError, InstitutionalContextError) as exc:
session.rollback()
raise _public_error(exc, disclose_valid_request=True) from exc
return instance.to_dict()
return _instance_payload(instance, session, status_access)
@router.get("/instances", response_model=FormInstanceListResponse)
def api_list_instances(
@@ -466,7 +609,7 @@ def create_router(registry: object | None) -> APIRouter:
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
return _instance_payload(item, session, status_access)
@router.get("/instances/{instance_id}", response_model=dict[str, object])
def api_get_instance(
@@ -488,7 +631,7 @@ def create_router(registry: object | None) -> APIRouter:
raise _error(exc) from exc
if item is None:
raise HTTPException(status_code=404, detail="Form instance not found")
return item.to_dict()
return _instance_payload(item, session, status_access)
@router.get(
"/instances/{instance_id}/definition",
@@ -579,7 +722,7 @@ def create_router(registry: object | None) -> APIRouter:
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
return _instance_payload(item, session, status_access)
@router.post(
"/instances/{instance_id}/evidence-grants",
@@ -1015,6 +1158,29 @@ def _error(exc: Exception) -> HTTPException:
return HTTPException(status_code=code, detail=message)
def _status_unavailable() -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application status is unavailable.",
)
def _instance_payload(
instance: object,
session: Session,
status_access: FormStatusAccessService,
) -> dict[str, object]:
payload = dict(getattr(instance, "to_dict")())
summary = status_access.access_summary_for_instance(
session,
tenant_id=str(getattr(instance, "tenant_id")),
instance_id=str(getattr(instance, "instance_id")),
)
if summary is not None:
payload["status_access"] = summary
return payload
def _public_error(
exc: Exception,
*,
@@ -189,6 +189,26 @@ class AssistedFormConfirmationRequest(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class FormStatusAccessPolicyRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition_ref: dict[str, Any]
mode: Literal["authenticated", "email_link", "permanent_link"]
enabled: bool = True
email_field_key: str | None = Field(default=None, min_length=1, max_length=255)
token_ttl_seconds: int = Field(default=3600, ge=300, le=604_800)
request_limit_per_hour: int = Field(default=5, ge=1, le=60)
expected_revision: int | None = Field(default=None, ge=1)
recorded_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class FormStatusEmailLinkRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str = Field(min_length=3, max_length=320)
class FormEvidenceGrantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -236,6 +256,8 @@ __all__ = [
"FormAcknowledgementRequest",
"FormDraftUpdateRequest",
"FormEvidenceGrantCreateRequest",
"FormStatusAccessPolicyRequest",
"FormStatusEmailLinkRequest",
"FormHandoffRequest",
"FormHandoffActionRequest",
"FormHandoffCompensateRequest",
@@ -351,6 +351,16 @@ class FormRuntimeService:
definition=definition,
allowed_current_statuses=("started", "draft"),
)
if not submitted.replayed:
from govoplan_forms_runtime.backend.status_access import (
FormStatusAccessService,
)
FormStatusAccessService(self._registry).ensure_for_submission(
session,
instance=submitted,
issued_at=recorded_at,
)
assisted_session = (
session.query(FormIntakeSession)
.filter(
@@ -0,0 +1,697 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
import hashlib
import hmac
import secrets
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
FormDefinition,
FormDefinitionProvider,
InstitutionalReference,
)
from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_forms_runtime.backend.db.models import (
FormInstanceEvent,
FormInstanceRevision,
FormStatusAccessGrant,
FormStatusAccessPolicy,
FormStatusAccessToken,
)
from govoplan_forms_runtime.backend.domain import FormInstance
STATUS_ACCESS_MODES = frozenset(
{"authenticated", "email_link", "permanent_link"}
)
PUBLIC_TIMELINE_STATUSES = frozenset(
{
"submitted",
"validated",
"needs_review",
"accepted",
"rejected",
"handed_off",
"archived",
}
)
DEFAULT_STATUS_TOKEN_TTL_SECONDS = 60 * 60
DEFAULT_STATUS_REQUEST_LIMIT_PER_HOUR = 5
class FormStatusAccessError(ValueError):
pass
class FormStatusUnavailable(FormStatusAccessError):
pass
class FormStatusAccessService:
def __init__(self, registry: object | None) -> None:
self._registry = registry
def list_policies(
self,
session: Session,
principal: object,
) -> tuple[FormStatusAccessPolicy, ...]:
return tuple(
session.query(FormStatusAccessPolicy)
.filter(
FormStatusAccessPolicy.tenant_id == _principal_tenant(principal)
)
.order_by(
FormStatusAccessPolicy.definition_id.asc(),
FormStatusAccessPolicy.definition_revision.asc(),
)
.all()
)
def upsert_policy(
self,
session: Session,
principal: object,
*,
definition_ref: InstitutionalReference,
mode: str,
enabled: bool,
email_field_key: str | None,
token_ttl_seconds: int = DEFAULT_STATUS_TOKEN_TTL_SECONDS,
request_limit_per_hour: int = DEFAULT_STATUS_REQUEST_LIMIT_PER_HOUR,
expected_revision: int | None = None,
metadata: Mapping[str, object] | None = None,
recorded_at: datetime,
) -> FormStatusAccessPolicy:
tenant_id = _principal_tenant(principal)
actor_id = _principal_actor(principal)
_require_aware(recorded_at, "Status access policy recorded_at")
if mode not in STATUS_ACCESS_MODES:
raise FormStatusAccessError(
f"Unsupported Form status access mode: {mode!r}."
)
if definition_ref.tenant_id != tenant_id:
raise FormStatusAccessError(
"Status access policy cannot reference another tenant."
)
if not 300 <= token_ttl_seconds <= 7 * 24 * 60 * 60:
raise FormStatusAccessError(
"Short-lived status links must expire between five minutes and seven days."
)
if not 1 <= request_limit_per_hour <= 60:
raise FormStatusAccessError(
"Status link requests must be limited to between 1 and 60 per hour."
)
definition = self._definition(
session,
principal,
reference=definition_ref,
effective_at=recorded_at,
)
if definition.publication_state != "published":
raise FormStatusAccessError(
"Only a published Form can expose applicant status."
)
clean_email_field = str(email_field_key or "").strip() or None
if mode == "email_link":
if clean_email_field is None:
raise FormStatusAccessError(
"Short-lived email status links require an exact Form email field."
)
field = next(
(
candidate
for candidate in definition.fields
if candidate.key == clean_email_field
),
None,
)
if field is None:
raise FormStatusAccessError(
"The configured status email field does not exist on the exact Form revision."
)
if field.value_type != "email":
raise FormStatusAccessError(
"Short-lived status links require a Form field with the email value type."
)
else:
clean_email_field = None
policy = (
session.query(FormStatusAccessPolicy)
.filter(
FormStatusAccessPolicy.tenant_id == tenant_id,
FormStatusAccessPolicy.definition_id
== definition.reference.object_id,
FormStatusAccessPolicy.definition_revision
== str(definition.reference.version),
)
.with_for_update()
.one_or_none()
)
if policy is None:
if expected_revision is not None:
raise FormStatusAccessError(
"Status access policy revision conflict: the policy does not exist."
)
policy = FormStatusAccessPolicy(
tenant_id=tenant_id,
policy_id=str(uuid.uuid4()),
definition_id=definition.reference.object_id,
definition_revision=str(definition.reference.version),
mode=mode,
enabled=bool(enabled),
revision=1,
email_field_key=clean_email_field,
token_ttl_seconds=token_ttl_seconds,
request_limit_per_hour=request_limit_per_hour,
created_by=actor_id,
updated_by=actor_id,
details={
**dict(metadata or {}),
"definition_title": definition.title,
},
)
else:
if expected_revision is None or policy.revision != expected_revision:
raise FormStatusAccessError(
"Status access policy revision conflict: reload before saving."
)
policy.mode = mode
policy.enabled = bool(enabled)
policy.revision += 1
policy.email_field_key = clean_email_field
policy.token_ttl_seconds = token_ttl_seconds
policy.request_limit_per_hour = request_limit_per_hour
policy.updated_by = actor_id
policy.details = {
**dict(metadata or {}),
"definition_title": definition.title,
}
session.add(policy)
session.flush()
return policy
def ensure_for_submission(
self,
session: Session,
*,
instance: FormInstance,
issued_at: datetime,
) -> FormStatusAccessGrant | None:
_require_aware(issued_at, "Status access issuance time")
existing = (
session.query(FormStatusAccessGrant)
.filter(
FormStatusAccessGrant.tenant_id == instance.tenant_id,
FormStatusAccessGrant.instance_id == instance.instance_id,
)
.one_or_none()
)
if existing is not None:
return existing
policy = (
session.query(FormStatusAccessPolicy)
.filter(
FormStatusAccessPolicy.tenant_id == instance.tenant_id,
FormStatusAccessPolicy.definition_id
== instance.definition_ref.object_id,
FormStatusAccessPolicy.definition_revision
== str(instance.definition_ref.version),
FormStatusAccessPolicy.enabled.is_(True),
)
.one_or_none()
)
if policy is None:
return None
grant_id = str(uuid.uuid4())
tracking_id = secrets.token_urlsafe(24)
applicant_actor_id = _applicant_actor(instance)
clean_email = None
if policy.mode == "email_link" and policy.email_field_key:
clean_email = _normalize_email(instance.values.get(policy.email_field_key))
if policy.mode == "authenticated" and applicant_actor_id is None:
return None
if policy.mode == "email_link" and clean_email is None:
return None
grant = FormStatusAccessGrant(
tenant_id=instance.tenant_id,
grant_id=grant_id,
policy_id=policy.id,
instance_id=instance.instance_id,
tracking_id=tracking_id,
mode=policy.mode,
applicant_actor_id=applicant_actor_id,
recipient_email_sha256=(
_email_digest(grant_id, clean_email) if clean_email else None
),
token_ttl_seconds=policy.token_ttl_seconds,
request_limit_per_hour=policy.request_limit_per_hour,
request_window_started_at=None,
request_window_count=0,
issued_at=issued_at,
details={
"definition_id": instance.definition_ref.object_id,
"definition_revision": str(instance.definition_ref.version),
"definition_title": policy.details.get("definition_title")
or instance.definition_ref.label
or "Application",
"email_field_key": policy.email_field_key,
},
)
session.add(grant)
session.flush()
return grant
def access_summary_for_instance(
self,
session: Session,
*,
tenant_id: str,
instance_id: str,
) -> dict[str, object] | None:
grant = (
session.query(FormStatusAccessGrant)
.filter(
FormStatusAccessGrant.tenant_id == tenant_id,
FormStatusAccessGrant.instance_id == instance_id,
)
.one_or_none()
)
if grant is None:
return None
policy = session.get(FormStatusAccessPolicy, grant.policy_id)
return {
"tracking_id": grant.tracking_id,
"mode": grant.mode,
"href": f"/portal/status/{grant.tracking_id}",
"enabled": bool(
policy is not None
and policy.enabled
and grant.revoked_at is None
),
}
def tenant_id_for_tracking_id(
self,
session: Session,
*,
tracking_id: str,
) -> str | None:
grant = (
session.query(FormStatusAccessGrant)
.filter(
FormStatusAccessGrant.tracking_id
== str(tracking_id or "").strip()
)
.one_or_none()
)
return grant.tenant_id if grant is not None else None
def public_access_challenge(
self,
session: Session,
*,
tracking_id: str,
) -> dict[str, object]:
grant, policy = self._active_grant(session, tracking_id=tracking_id)
return {
"tracking_id": grant.tracking_id,
"mode": grant.mode,
"authenticated_available": grant.applicant_actor_id is not None,
"email_link_available": (
grant.mode == "email_link"
and grant.recipient_email_sha256 is not None
),
"token_ttl_seconds": (
grant.token_ttl_seconds if grant.mode == "email_link" else None
),
"policy_revision": policy.revision,
}
def get_authenticated_projection(
self,
session: Session,
principal: object,
*,
tracking_id: str,
observed_at: datetime,
) -> dict[str, object]:
grant, _policy = self._active_grant(session, tracking_id=tracking_id)
if (
grant.applicant_actor_id is None
or grant.applicant_actor_id not in _principal_actor_ids(principal)
):
raise FormStatusUnavailable("Application status is unavailable.")
grant.last_accessed_at = observed_at
session.add(grant)
return self._projection(session, grant=grant)
def get_public_projection(
self,
session: Session,
*,
tracking_id: str,
token: str | None,
observed_at: datetime,
) -> dict[str, object]:
_require_aware(observed_at, "Status observation time")
grant, _policy = self._active_grant(session, tracking_id=tracking_id)
if grant.mode == "permanent_link":
pass
elif grant.mode == "email_link" and token:
token_row = (
session.query(FormStatusAccessToken)
.filter(
FormStatusAccessToken.tenant_id == grant.tenant_id,
FormStatusAccessToken.grant_id == grant.id,
FormStatusAccessToken.token_sha256 == _token_digest(token),
FormStatusAccessToken.revoked_at.is_(None),
)
.one_or_none()
)
if token_row is None or _aware(token_row.expires_at) <= observed_at:
raise FormStatusUnavailable("Application status is unavailable.")
token_row.last_used_at = observed_at
session.add(token_row)
else:
raise FormStatusUnavailable("Application status is unavailable.")
grant.last_accessed_at = observed_at
session.add(grant)
return self._projection(session, grant=grant)
def request_email_link(
self,
session: Session,
*,
tracking_id: str,
email: str,
requested_at: datetime,
) -> bool:
"""Issue a link when eligible; callers must always return a generic response."""
_require_aware(requested_at, "Status link request time")
try:
grant, _policy = self._active_grant(
session, tracking_id=tracking_id, lock=True
)
except FormStatusUnavailable:
return False
if grant.mode != "email_link" or grant.recipient_email_sha256 is None:
return False
if not _consume_request_limit(grant, now=requested_at):
session.add(grant)
return False
session.add(grant)
clean_email = _normalize_email(email)
if clean_email is None or not hmac.compare_digest(
grant.recipient_email_sha256,
_email_digest(grant.grant_id, clean_email),
):
return False
provider = notification_dispatch_provider(self._registry)
if provider is None:
return False
session.query(FormStatusAccessToken).filter(
FormStatusAccessToken.tenant_id == grant.tenant_id,
FormStatusAccessToken.grant_id == grant.id,
FormStatusAccessToken.revoked_at.is_(None),
).update(
{FormStatusAccessToken.revoked_at: requested_at},
synchronize_session=False,
)
secret = secrets.token_urlsafe(32)
token_row = FormStatusAccessToken(
tenant_id=grant.tenant_id,
token_id=str(uuid.uuid4()),
grant_id=grant.id,
token_sha256=_token_digest(secret),
issued_at=requested_at,
expires_at=requested_at
+ timedelta(seconds=grant.token_ttl_seconds),
details={"delivery": "notifications"},
)
session.add(token_row)
session.flush()
action_url = f"/portal/status/{grant.tracking_id}?token={secret}"
result = provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=grant.tenant_id,
source_module="forms_runtime",
source_resource_type="form_status_access",
source_resource_id=grant.grant_id,
event_kind="forms_runtime.status_link.requested",
channel="mail",
recipient=clean_email,
recipient_type="email",
subject="Your application status link",
body_text=(
"Use the secure link to view the current status of your application. "
"The link expires automatically and replaces any earlier status link."
),
action_url=action_url,
payload={
"tracking_id": grant.tracking_id,
"expires_at": token_row.expires_at.isoformat(),
},
metadata={
"purpose": "application_status_access",
"token_persisted_as_digest": True,
},
),
enqueue_delivery=True,
)
notification_id = str(result.get("id") or "").strip()
token_row.notification_id = notification_id or None
session.add(token_row)
session.flush()
return True
def _projection(
self,
session: Session,
*,
grant: FormStatusAccessGrant,
) -> dict[str, object]:
current = (
session.query(FormInstanceRevision)
.filter(
FormInstanceRevision.tenant_id == grant.tenant_id,
FormInstanceRevision.instance_id == grant.instance_id,
FormInstanceRevision.superseded_at.is_(None),
)
.one_or_none()
)
if current is None:
raise FormStatusUnavailable("Application status is unavailable.")
snapshot = dict(current.snapshot or {})
events = (
session.query(FormInstanceEvent)
.filter(
FormInstanceEvent.tenant_id == grant.tenant_id,
FormInstanceEvent.instance_id == grant.instance_id,
FormInstanceEvent.status.in_(tuple(PUBLIC_TIMELINE_STATUSES)),
)
.order_by(FormInstanceEvent.occurred_at.asc())
.all()
)
timeline: list[dict[str, object]] = []
previous_status = ""
for event in events:
if event.status == previous_status:
continue
timeline.append(
{
"status": event.status,
"occurred_at": _aware(event.occurred_at).isoformat(),
}
)
previous_status = event.status
return {
"tracking_id": grant.tracking_id,
"title": str(grant.details.get("definition_title") or "Application"),
"status": current.status,
"updated_at": _aware(current.recorded_at).isoformat(),
"receipt_id": snapshot.get("receipt_id"),
"timeline": timeline,
}
def _active_grant(
self,
session: Session,
*,
tracking_id: str,
lock: bool = False,
) -> tuple[FormStatusAccessGrant, FormStatusAccessPolicy]:
query = session.query(FormStatusAccessGrant).filter(
FormStatusAccessGrant.tracking_id == str(tracking_id or "").strip()
)
if lock:
query = query.with_for_update()
grant = query.one_or_none()
if grant is None or grant.revoked_at is not None:
raise FormStatusUnavailable("Application status is unavailable.")
policy = session.get(FormStatusAccessPolicy, grant.policy_id)
if policy is None or not policy.enabled:
raise FormStatusUnavailable("Application status is unavailable.")
return grant, policy
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 FormStatusAccessError("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 FormStatusAccessError("The exact Form definition is unavailable.")
return definition
def status_policy_payload(policy: FormStatusAccessPolicy) -> dict[str, object]:
return {
"policy_id": policy.policy_id,
"definition_ref": {
"kind": "form",
"owner_module": "forms",
"object_id": policy.definition_id,
"tenant_id": policy.tenant_id,
"version": policy.definition_revision,
},
"mode": policy.mode,
"enabled": policy.enabled,
"revision": policy.revision,
"email_field_key": policy.email_field_key,
"token_ttl_seconds": policy.token_ttl_seconds,
"request_limit_per_hour": policy.request_limit_per_hour,
"metadata": dict(policy.details),
}
def _applicant_actor(instance: FormInstance) -> str | None:
intake = instance.metadata.get("intake")
if isinstance(intake, Mapping) and intake.get("mode") == "assisted":
affected = str(intake.get("affected_party_ref") or "").strip()
if affected.startswith("account:") and len(affected) > len("account:"):
return affected[len("account:") :]
return None
actor_id = str(instance.created_by or "").strip()
if not actor_id or actor_id.startswith("form-public:"):
return None
return actor_id
def _consume_request_limit(grant: FormStatusAccessGrant, *, now: datetime) -> bool:
window = (
_aware(grant.request_window_started_at)
if grant.request_window_started_at is not None
else None
)
if window is None or now - window >= timedelta(hours=1):
grant.request_window_started_at = now
grant.request_window_count = 1
return True
if grant.request_window_count >= grant.request_limit_per_hour:
return False
grant.request_window_count += 1
return True
def _normalize_email(value: object) -> str | None:
candidate = str(value or "").strip().casefold()
if (
not candidate
or len(candidate) > 320
or candidate.count("@") != 1
or any(character.isspace() for character in candidate)
):
return None
local, domain = candidate.rsplit("@", 1)
if not local or "." not in domain or domain.startswith(".") or domain.endswith("."):
return None
return candidate
def _email_digest(grant_id: str, email: str) -> str:
return hashlib.sha256(f"{grant_id}\0{email}".encode("utf-8")).hexdigest()
def _token_digest(token: str) -> str:
clean = str(token or "").strip()
if len(clean) < 32:
raise FormStatusUnavailable("Application status is unavailable.")
return hashlib.sha256(clean.encode("utf-8")).hexdigest()
def _principal_tenant(principal: object) -> str:
value = str(getattr(principal, "tenant_id", "") or "").strip()
if not value:
raise FormStatusAccessError("Status access requires a tenant-bound principal.")
return value
def _principal_actor(principal: object) -> str:
values = _principal_actor_ids(principal)
if not values:
raise FormStatusAccessError("Status access requires an acting identity.")
return values[0]
def _principal_actor_ids(principal: object) -> tuple[str, ...]:
values: list[str] = []
for name in ("account_id", "identity_id"):
value = str(getattr(principal, name, "") or "").strip()
if value and value not in values:
values.append(value)
return tuple(values)
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 _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _require_aware(value: datetime, label: str) -> None:
if value.tzinfo is None:
raise FormStatusAccessError(f"{label} must include a timezone.")
__all__ = [
"DEFAULT_STATUS_REQUEST_LIMIT_PER_HOUR",
"DEFAULT_STATUS_TOKEN_TTL_SECONDS",
"FormStatusAccessError",
"FormStatusAccessService",
"FormStatusUnavailable",
"STATUS_ACCESS_MODES",
"status_policy_payload",
]