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
+7
View File
@@ -8,6 +8,12 @@
including validation, permitted drafts, attachment/signature references, status
history, receipts, and handoff evidence.
Administrators can configure applicant status for each exact published Form
revision as account-authenticated, a short-lived link delivered to a matching
submitted email address, or a permanent high-entropy bearer link. Forms Runtime
owns the policy, grant, hash-only secrets, and bounded status projection;
Notifications owns email delivery, and Portal owns the applicant-facing page.
Its runtime module ID is `forms_runtime`; the repository and Python distribution retain the hyphenated `govoplan-forms-runtime` name.
The module persists tenant-bound immutable revisions and events, exposes bounded
@@ -60,6 +66,7 @@ Optional integrations:
- cases
- policy
- audit
- notifications
## Development Install
+31
View File
@@ -78,6 +78,15 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
selection before submission
- migrations, uninstall guards, tenant summaries, events, recovery notes, and
tenant/replay/stale-write/validation/handoff tests
- an administrator-configured applicant-status policy on each exact published
Form revision: authenticated applicant access, short-lived links delivered
after a linked-email match, or a permanent bearer link
- per-submission, high-entropy tracking grants and a deliberately bounded
projection containing only title, lifecycle status, update time, receipt
identifier, and deduplicated public lifecycle events
- hash-only short-lived secrets, grant-bound email comparison, generic link
request responses, bounded hourly requests, resend revocation, and
Notifications-owned mail delivery
## Security And Policy
@@ -111,6 +120,28 @@ new one-time bearer URL for each participant. The UI can copy a newly issued
secret but cannot retrieve it later. Disabling a profile prevents new starts;
already submitted revisions and their evidence remain governed records.
Applicant status is separately configured for one exact published Form
revision. The administrator selects one of three disclosure profiles:
- `authenticated` binds access to the submitting account. Assisted intake can
bind this profile only when its affected party is an explicit `account:`
reference; otherwise no status grant is issued.
- `email_link` binds a grant to the normalized value of one configured Form
field. A request supplies the tracking identifier and email address, always
receives the same response, and results in delivery only after a match. The
new short-lived link revokes its predecessor and Notifications owns the raw
delivery address, delivered URL, and attempt under its retention policy.
Forms Runtime stores only the secret digest.
- `permanent_link` makes the high-entropy tracking URL itself a non-expiring
bearer credential. Anyone possessing it can read the bounded projection.
Policy changes apply to later submissions; grants already issued retain their
mode and limits. Disabling the policy suspends all its grants immediately.
Submitted values, applicant identity, evidence, internal notes, and handoff
details never enter the public projection. Administrators must therefore choose
permanent links only where their possession-based disclosure and forwarding
risk is acceptable.
Administrators enable assisted profiles against the same published Form
revisions. An authenticated operator starts the session only after recording
the governed party and function references, authority and purpose, channel,
@@ -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",
]
+285
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import unittest
from urllib.parse import parse_qs, urlsplit
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@@ -20,6 +21,7 @@ from govoplan_core.core.institutional import (
ServiceLaunchResult,
TemporalRevision,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import (
SqlFormDefinitionProvider,
@@ -34,6 +36,9 @@ from govoplan_forms_runtime.backend.db.models import (
FormInstanceRevision,
FormIntakeProfile,
FormIntakeSession,
FormStatusAccessGrant,
FormStatusAccessPolicy,
FormStatusAccessToken,
)
from govoplan_forms_runtime.backend.service import (
FormRuntimeError,
@@ -42,6 +47,11 @@ from govoplan_forms_runtime.backend.service import (
)
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
from govoplan_forms_runtime.backend.intake import FormIntakeError, FormIntakeService
from govoplan_forms_runtime.backend.status_access import (
FormStatusAccessError,
FormStatusAccessService,
FormStatusUnavailable,
)
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
@@ -66,6 +76,53 @@ class Registry:
def require_capability(self, name: str) -> object:
return self.capabilities[name]
def capability(self, name: str) -> object:
return self.capabilities[name]
class NotificationProvider:
def __init__(self) -> None:
self.requests: list[object] = []
def tenant_id_for_notification(
self,
session: object,
*,
notification_id: str,
) -> str | None:
del session, notification_id
return "tenant-1"
def enqueue_notification(
self,
session: object,
request: object,
*,
enqueue_delivery: bool = True,
) -> dict[str, object]:
del session, enqueue_delivery
self.requests.append(request)
return {"id": f"notification-{len(self.requests)}"}
def deliver_notification(
self,
session: object,
*,
notification_id: str,
) -> dict[str, object]:
del session, notification_id
return {}
def deliver_pending(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> dict[str, object]:
del session, tenant_id, limit
return {}
def form_definition(
*,
@@ -101,6 +158,12 @@ def form_definition(
value_type="choice",
options=("portal", "mail"),
),
FormFieldDefinition(
key="email",
label="Email",
value_type="email",
constraints={"max_length": 320},
),
),
publication_state="published",
allow_drafts=True,
@@ -121,6 +184,9 @@ class FormsRuntimeTests(unittest.TestCase):
FormIntakeProfile.__table__,
FormIntakeSession.__table__,
FormAssistedConfirmation.__table__,
FormStatusAccessPolicy.__table__,
FormStatusAccessGrant.__table__,
FormStatusAccessToken.__table__,
FormAcknowledgement.__table__,
):
table.create(self.engine)
@@ -987,6 +1053,225 @@ class FormsRuntimeTests(unittest.TestCase):
),
)
def test_status_access_modes_are_configurable_and_public_projection_is_bounded(
self,
) -> None:
notifications = NotificationProvider()
registry = Registry(
SqlFormDefinitionProvider(),
**{CAPABILITY_NOTIFICATIONS_DISPATCH: notifications},
)
runtime = FormRuntimeService(registry)
status_access = FormStatusAccessService(registry)
policy = status_access.upsert_policy(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="authenticated",
enabled=True,
email_field_key=None,
recorded_at=NOW,
)
authenticated_draft = runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada", "delivery": "portal"},
idempotency_key="status-auth-create",
recorded_at=NOW + timedelta(minutes=1),
)
authenticated_submission = runtime.submit_instance(
self.session,
self.principal,
instance_id=authenticated_draft.instance_id,
expected_revision=1,
values=authenticated_draft.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="status-auth-submit",
recorded_at=NOW + timedelta(minutes=2),
)
authenticated_summary = status_access.access_summary_for_instance(
self.session,
tenant_id="tenant-1",
instance_id=authenticated_submission.instance_id,
)
self.assertEqual("authenticated", authenticated_summary["mode"])
authenticated_projection = status_access.get_authenticated_projection(
self.session,
self.principal,
tracking_id=authenticated_summary["tracking_id"],
observed_at=NOW + timedelta(minutes=3),
)
self.assertEqual("submitted", authenticated_projection["status"])
self.assertNotIn("values", authenticated_projection)
self.assertNotIn("actor_id", authenticated_projection)
with self.assertRaises(FormStatusUnavailable):
status_access.get_authenticated_projection(
self.session,
Principal(account_id="another-account"),
tracking_id=authenticated_summary["tracking_id"],
observed_at=NOW + timedelta(minutes=3),
)
with self.assertRaisesRegex(FormStatusAccessError, "email value type"):
status_access.upsert_policy(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="email_link",
enabled=True,
email_field_key="name",
expected_revision=policy.revision,
recorded_at=NOW + timedelta(minutes=4),
)
policy = status_access.upsert_policy(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="email_link",
enabled=True,
email_field_key="email",
token_ttl_seconds=900,
request_limit_per_hour=3,
expected_revision=policy.revision,
recorded_at=NOW + timedelta(minutes=4),
)
email_draft = runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={
"name": "Ada",
"delivery": "mail",
"email": "Ada@example.test",
},
idempotency_key="status-email-create",
recorded_at=NOW + timedelta(minutes=5),
)
email_submission = runtime.submit_instance(
self.session,
self.principal,
instance_id=email_draft.instance_id,
expected_revision=1,
values=email_draft.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="status-email-submit",
recorded_at=NOW + timedelta(minutes=6),
)
email_summary = status_access.access_summary_for_instance(
self.session,
tenant_id="tenant-1",
instance_id=email_submission.instance_id,
)
self.assertFalse(
status_access.request_email_link(
self.session,
tracking_id=email_summary["tracking_id"],
email="wrong@example.test",
requested_at=NOW + timedelta(minutes=7),
)
)
self.assertEqual([], notifications.requests)
self.assertTrue(
status_access.request_email_link(
self.session,
tracking_id=email_summary["tracking_id"],
email="ada@EXAMPLE.test",
requested_at=NOW + timedelta(minutes=8),
)
)
first_url = notifications.requests[-1].action_url
first_token = parse_qs(urlsplit(first_url).query)["token"][0]
email_projection = status_access.get_public_projection(
self.session,
tracking_id=email_summary["tracking_id"],
token=first_token,
observed_at=NOW + timedelta(minutes=9),
)
self.assertEqual(email_submission.receipt_id, email_projection["receipt_id"])
self.assertTrue(
status_access.request_email_link(
self.session,
tracking_id=email_summary["tracking_id"],
email="ada@example.test",
requested_at=NOW + timedelta(minutes=10),
)
)
with self.assertRaises(FormStatusUnavailable):
status_access.get_public_projection(
self.session,
tracking_id=email_summary["tracking_id"],
token=first_token,
observed_at=NOW + timedelta(minutes=11),
)
second_url = notifications.requests[-1].action_url
second_token = parse_qs(urlsplit(second_url).query)["token"][0]
self.assertNotEqual(first_token, second_token)
policy = status_access.upsert_policy(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="permanent_link",
enabled=True,
email_field_key=None,
expected_revision=policy.revision,
recorded_at=NOW + timedelta(minutes=12),
)
permanent_draft = runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada", "delivery": "portal"},
idempotency_key="status-permanent-create",
recorded_at=NOW + timedelta(minutes=13),
)
permanent_submission = runtime.submit_instance(
self.session,
self.principal,
instance_id=permanent_draft.instance_id,
expected_revision=1,
values=permanent_draft.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="status-permanent-submit",
recorded_at=NOW + timedelta(minutes=14),
)
permanent_summary = status_access.access_summary_for_instance(
self.session,
tenant_id="tenant-1",
instance_id=permanent_submission.instance_id,
)
permanent_projection = status_access.get_public_projection(
self.session,
tracking_id=permanent_summary["tracking_id"],
token=None,
observed_at=NOW + timedelta(minutes=15),
)
self.assertEqual("submitted", permanent_projection["status"])
policy = status_access.upsert_policy(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="permanent_link",
enabled=False,
email_field_key=None,
expected_revision=policy.revision,
recorded_at=NOW + timedelta(minutes=16),
)
self.assertFalse(policy.enabled)
with self.assertRaises(FormStatusUnavailable):
status_access.get_public_projection(
self.session,
tracking_id=permanent_summary["tracking_id"],
token=None,
observed_at=NOW + timedelta(minutes=17),
)
def test_native_acknowledgement_is_bound_to_actor_and_exact_payload(self) -> None:
draft = self.runtime.create_instance(
self.session,
+4 -1
View File
@@ -35,12 +35,15 @@ class FormsRuntimeMigrationTests(unittest.TestCase):
"form_intake_profiles",
"form_intake_sessions",
"form_assisted_confirmations",
"form_status_access_policies",
"form_status_access_grants",
"form_status_access_tokens",
"form_acknowledgements",
}.issubset(inspect(engine).get_table_names())
)
with engine.connect() as connection:
self.assertIn(
"c5f7a9b1d3e4",
"d6a8b0c2e4f6",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
+57
View File
@@ -49,6 +49,27 @@ export type FormIntakeProfile = {
metadata: Record<string, unknown>;
};
export type FormStatusAccessMode = "authenticated" | "email_link" | "permanent_link";
export type FormStatusAccessPolicy = {
policy_id: string;
definition_ref: InstitutionalReference;
mode: FormStatusAccessMode;
enabled: boolean;
revision: number;
email_field_key?: string | null;
token_ttl_seconds: number;
request_limit_per_hour: number;
metadata: Record<string, unknown>;
};
export type FormStatusAccessSummary = {
tracking_id: string;
mode: FormStatusAccessMode;
href: string;
enabled: boolean;
};
export type PublicIntakeResult = {
session_id: string;
mode: "anonymous" | "invitation" | "assisted";
@@ -177,6 +198,7 @@ export type FormInstance = {
created_by: string;
changed_by: string;
metadata: Record<string, unknown>;
status_access?: FormStatusAccessSummary | null;
replayed: boolean;
};
@@ -407,6 +429,41 @@ export function createFormIntakeProfile(
});
}
export function listFormStatusAccessPolicies(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ policies: FormStatusAccessPolicy[] }> {
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", { signal });
}
export function saveFormStatusAccessPolicy(
settings: ApiSettings,
options: {
definitionRef: InstitutionalReference;
mode: FormStatusAccessMode;
enabled: boolean;
emailFieldKey?: string;
tokenTtlSeconds: number;
requestLimitPerHour: number;
expectedRevision?: number;
}
): Promise<FormStatusAccessPolicy> {
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", {
method: "PUT",
body: JSON.stringify({
definition_ref: options.definitionRef,
mode: options.mode,
enabled: options.enabled,
email_field_key: options.mode === "email_link" ? options.emailFieldKey?.trim() || null : null,
token_ttl_seconds: options.tokenTtlSeconds,
request_limit_per_hour: options.requestLimitPerHour,
expected_revision: options.expectedRevision ?? null,
recorded_at: new Date().toISOString(),
metadata: {}
})
});
}
export function listAssistedIntakeProfiles(
settings: ApiSettings,
signal?: AbortSignal
@@ -569,6 +569,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<code>{instance.receipt_id}</code>
</div>
}
{!editable && instance.status_access &&
<section className="form-status-access-receipt">
<div>
<strong>Applicant status access</strong>
<span>{statusAccessMessage(instance.status_access.mode)}</span>
</div>
<code>{instance.status_access.tracking_id}</code>
<a className="btn" href={instance.status_access.href}>Open status page</a>
</section>
}
{!editable && definition.handoff_kinds.length > 0 &&
<section className="form-handoffs">
<div className="form-handoff-heading">
@@ -938,6 +948,12 @@ function intakeContext(instance: FormInstance | null): AssistedIntakeContext | n
};
}
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
if (mode === "authenticated") return "Only the bound applicant account can open this status page.";
if (mode === "email_link") return "The applicant uses this tracking ID and linked email address to request an expiring link.";
return "This non-expiring bearer link can be opened by anyone who holds it.";
}
export function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
const fields = new Map(definition.fields.map((field) => [field.key, field]));
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
+10 -1
View File
@@ -1,4 +1,4 @@
import { Link2, RefreshCw, UserRoundPlus } from "lucide-react";
import { Link2, RefreshCw, ShieldCheck, UserRoundPlus } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ActionToolbar,
Button,
@@ -22,6 +22,7 @@ import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
import IntakeProfilesDialog from "./IntakeProfilesDialog";
import AssistedIntakeDialog from "./AssistedIntakeDialog";
import StatusAccessPoliciesDialog from "./StatusAccessPoliciesDialog";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
@@ -36,6 +37,7 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
const [error, setError] = useState("");
const [intakeOpen, setIntakeOpen] = useState(false);
const [assistedOpen, setAssistedOpen] = useState(false);
const [statusAccessOpen, setStatusAccessOpen] = useState(false);
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
const canAssist = hasScope(auth, "forms_runtime:submission:assist")
|| hasScope(auth, "forms_runtime:workspace:write");
@@ -78,6 +80,12 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
Public intake
</Button>
}
{canAdmin &&
<Button onClick={() => setStatusAccessOpen(true)}>
<ShieldCheck size={16} aria-hidden="true" />
Status access
</Button>
}
{canAssist &&
<Button onClick={() => setAssistedOpen(true)}>
<UserRoundPlus size={16} aria-hidden="true" />
@@ -127,6 +135,7 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
</PageScrollViewport>
</WorkspaceFrame>
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
<StatusAccessPoliciesDialog open={statusAccessOpen} settings={settings} onClose={() => setStatusAccessOpen(false)} />
<AssistedIntakeDialog
open={assistedOpen}
settings={settings}
@@ -323,6 +323,16 @@ export default function PublicFormPage({ settings }: PlatformRouteContext) {
<code>{instance.receipt_id}</code>
</div>
}
{!editable && instance.status_access &&
<section className="form-status-access-receipt">
<div>
<strong>Track this application</strong>
<span>{statusAccessMessage(instance.status_access.mode)}</span>
</div>
<code>{instance.status_access.tracking_id}</code>
<a className="btn" href={instance.status_access.href}>Open status page</a>
</section>
}
</div>
}
</PageScrollViewport>
@@ -389,3 +399,9 @@ function rememberSessionToken(publicId: string, token: string) {
function stateLabel(value: string): string {
return `i18n:govoplan-forms-runtime.state_${value}`;
}
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
if (mode === "authenticated") return "Sign in with the linked applicant account to view status.";
if (mode === "email_link") return "Use this tracking ID and the linked email address to request a short-lived status link.";
return "This permanent bearer link does not require sign-in. Store and share it carefully.";
}
@@ -0,0 +1,280 @@
import { Save } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DialogForm,
DialogSection,
DismissibleAlert,
FormField,
FormGrid,
LoadingIndicator,
StatusBadge,
ToggleSwitch,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listFormIntakeDefinitions,
listFormStatusAccessPolicies,
saveFormStatusAccessPolicy,
type FormDefinition,
type FormStatusAccessMode,
type FormStatusAccessPolicy
} from "../../api/formsRuntime";
type StatusAccessPoliciesDialogProps = {
open: boolean;
settings: PlatformRouteContext["settings"];
onClose: () => void;
};
export default function StatusAccessPoliciesDialog({
open,
settings,
onClose
}: StatusAccessPoliciesDialogProps) {
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
const [policies, setPolicies] = useState<FormStatusAccessPolicy[]>([]);
const [definitionKey, setDefinitionKey] = useState("");
const [mode, setMode] = useState<FormStatusAccessMode>("authenticated");
const [emailFieldKey, setEmailFieldKey] = useState("");
const [tokenMinutes, setTokenMinutes] = useState(60);
const [requestLimit, setRequestLimit] = useState(5);
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const [busyKey, setBusyKey] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const [definitionResult, policyResult] = await Promise.all([
listFormIntakeDefinitions(settings, signal),
listFormStatusAccessPolicies(settings, signal)
]);
setDefinitions(definitionResult.definitions);
setPolicies(policyResult.policies);
setDefinitionKey((current) => current || referenceKey(definitionResult.definitions[0]));
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
if (!open) return undefined;
const controller = new AbortController();
void load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Status access policies could not be loaded.");
}
});
return () => controller.abort();
}, [load, open]);
const definition = useMemo(
() => definitions.find((item) => referenceKey(item) === definitionKey),
[definitionKey, definitions]
);
const policy = useMemo(
() => policies.find((item) => referenceKey(item) === definitionKey),
[definitionKey, policies]
);
useEffect(() => {
if (!definitionKey) return;
if (policy) {
setMode(policy.mode);
setEmailFieldKey(policy.email_field_key ?? "");
setTokenMinutes(Math.max(5, Math.round(policy.token_ttl_seconds / 60)));
setRequestLimit(policy.request_limit_per_hour);
setEnabled(policy.enabled);
return;
}
setMode("authenticated");
setEmailFieldKey("");
setTokenMinutes(60);
setRequestLimit(5);
setEnabled(true);
}, [definitionKey, policy]);
async function save() {
if (!definition || (mode === "email_link" && !emailFieldKey)) return;
setBusyKey(definitionKey);
setError("");
setNotice("");
try {
await saveFormStatusAccessPolicy(settings, {
definitionRef: definition.reference,
mode,
enabled,
emailFieldKey,
tokenTtlSeconds: tokenMinutes * 60,
requestLimitPerHour: requestLimit,
expectedRevision: policy?.revision
});
await load();
setNotice("The applicant status access policy was saved.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The status access policy could not be saved.");
} finally {
setBusyKey("");
}
}
async function toggle(item: FormStatusAccessPolicy, nextEnabled: boolean) {
const exactDefinition = definitions.find((candidate) => referenceKey(candidate) === referenceKey(item));
if (!exactDefinition) return;
setBusyKey(item.policy_id);
setError("");
setNotice("");
try {
await saveFormStatusAccessPolicy(settings, {
definitionRef: exactDefinition.reference,
mode: item.mode,
enabled: nextEnabled,
emailFieldKey: item.email_field_key ?? undefined,
tokenTtlSeconds: item.token_ttl_seconds,
requestLimitPerHour: item.request_limit_per_hour,
expectedRevision: item.revision
});
await load();
setNotice(nextEnabled
? "Applicant status access was enabled for future submissions and existing grants."
: "Applicant status access and all existing grants for this exact Form revision were suspended."
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The status access policy could not be changed.");
} finally {
setBusyKey("");
}
}
return (
<Dialog
open={open}
title="Applicant status access"
description="Choose the access and disclosure profile independently for each exact published Form revision."
size="large"
closeDisabled={Boolean(busyKey)}
onClose={onClose}
helpContextId="forms_runtime.status-policy"
helpTopicId="forms_runtime.submissions"
footer={<Button onClick={onClose} disabled={Boolean(busyKey)}>Close</Button>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading applicant status policies" />}
{!loading && definitions.length === 0 &&
<DismissibleAlert tone="info">Publish a Form revision before configuring applicant status.</DismissibleAlert>
}
{!loading && definitions.length > 0 &&
<DialogForm onSubmit={(event) => { event.preventDefault(); void save(); }}>
<DialogSection title={policy ? "Edit policy" : "Add policy"}>
<FormGrid columns={2} gap="small" collapseAt="workspace">
<FormField label="Published Form">
<select value={definitionKey} onChange={(event) => setDefinitionKey(event.target.value)} disabled={Boolean(busyKey)}>
{definitions.map((item) =>
<option key={referenceKey(item)} value={referenceKey(item)}>
{item.title} · revision {item.reference.version}
</option>
)}
</select>
</FormField>
<FormField label="Access profile">
<select value={mode} onChange={(event) => setMode(event.target.value as FormStatusAccessMode)} disabled={Boolean(busyKey)}>
<option value="authenticated">Authenticated applicant only</option>
<option value="email_link">Short-lived link to linked email</option>
<option value="permanent_link">Permanent public bearer link</option>
</select>
</FormField>
{mode === "email_link" &&
<FormField label="Linked email field" help="The final submitted value is compared without disclosing whether a request matched.">
<select value={emailFieldKey} onChange={(event) => setEmailFieldKey(event.target.value)} disabled={Boolean(busyKey)} required>
<option value="">Select a field</option>
{definition?.fields.filter((field) => field.value_type === "email").map((field) =>
<option key={field.key} value={field.key}>{field.label} ({field.key})</option>
)}
</select>
</FormField>
}
{mode === "email_link" &&
<FormField label="Link validity (minutes)">
<input type="number" min={5} max={10_080} value={tokenMinutes} onChange={(event) => setTokenMinutes(Number(event.target.value))} disabled={Boolean(busyKey)} />
</FormField>
}
{mode === "email_link" &&
<FormField label="Requests per hour">
<input type="number" min={1} max={60} value={requestLimit} onChange={(event) => setRequestLimit(Number(event.target.value))} disabled={Boolean(busyKey)} />
</FormField>
}
<FormField label="Policy state">
<ToggleSwitch label="Applicant status enabled" checked={enabled} onChange={setEnabled} disabled={Boolean(busyKey)} />
</FormField>
</FormGrid>
<AccessConsequence mode={mode} />
<div className="form-status-policy-save">
<Button
variant="primary"
type="submit"
disabled={Boolean(busyKey) || !definition || (mode === "email_link" && !emailFieldKey)}>
<Save size={16} aria-hidden="true" />
Save policy
</Button>
</div>
</DialogSection>
<DialogSection title="Configured policies" variant="inset">
{policies.length === 0 && <p className="form-intake-empty">No applicant status policy has been configured.</p>}
<div className="form-status-policy-list">
{policies.map((item) =>
<div className="form-status-policy-row" key={item.policy_id}>
<span>
<strong>{policyTitle(item)}</strong>
<small>Revision {item.definition_ref.version} · {modeLabel(item.mode)}</small>
</span>
<StatusBadge status={item.enabled ? "active" : "inactive"} label={item.enabled ? "Enabled" : "Suspended"} />
<ToggleSwitch
label="Policy enabled"
checked={item.enabled}
disabled={Boolean(busyKey)}
onChange={(value) => void toggle(item, value)}
/>
</div>
)}
</div>
</DialogSection>
</DialogForm>
}
</Dialog>
);
}
function AccessConsequence({ mode }: { mode: FormStatusAccessMode }) {
if (mode === "authenticated") {
return <DismissibleAlert tone="info">Only the bound applicant account can view status. Assisted intake needs an explicit <code>account:</code> party reference for this profile.</DismissibleAlert>;
}
if (mode === "email_link") {
return <DismissibleAlert tone="warning">A matching identifier and email request creates a new expiring secret and revokes the previous one. Notifications and Mail must be configured for delivery.</DismissibleAlert>;
}
return <DismissibleAlert tone="warning">The link does not expire or require sign-in. Anyone holding it can see the bounded status timeline until the policy is suspended.</DismissibleAlert>;
}
function referenceKey(value?: FormDefinition | FormStatusAccessPolicy): string {
if (!value) return "";
const reference = "definition_ref" in value ? value.definition_ref : value.reference;
return `${reference.object_id}:${reference.version ?? ""}`;
}
function policyTitle(policy: FormStatusAccessPolicy): string {
const title = policy.metadata.definition_title;
return typeof title === "string" && title.trim()
? title
: policy.definition_ref.label ?? policy.definition_ref.object_id;
}
function modeLabel(mode: FormStatusAccessMode): string {
if (mode === "authenticated") return "Authenticated applicant";
if (mode === "email_link") return "Short-lived email link";
return "Permanent public link";
}
+73
View File
@@ -278,6 +278,79 @@
font-size: 0.78rem;
}
.form-status-policy-save {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.form-status-access-receipt {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, auto) auto;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.form-status-access-receipt > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.form-status-access-receipt span {
color: var(--text-soft);
}
.form-status-access-receipt code {
overflow-wrap: anywhere;
}
.form-status-policy-list {
display: grid;
gap: 8px;
}
.form-status-policy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(150px, auto);
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.form-status-policy-row > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
}
.form-status-policy-row small {
color: var(--text-soft);
}
@media (max-width: 760px) {
.form-status-access-receipt {
grid-template-columns: minmax(0, 1fr);
}
.form-status-policy-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.form-status-policy-row .toggle-switch {
grid-column: 1 / -1;
}
}
.form-intake-create,
.form-intake-profiles {
display: flex;