diff --git a/README.md b/README.md index fbace0e..6242a02 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md b/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md index 991a926..36fa175 100644 --- a/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md +++ b/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md @@ -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, diff --git a/src/govoplan_forms_runtime/backend/db/models.py b/src/govoplan_forms_runtime/backend/db/models.py index aff752f..0ce4612 100644 --- a/src/govoplan_forms_runtime/backend/db/models.py +++ b/src/govoplan_forms_runtime/backend/db/models.py @@ -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", ] diff --git a/src/govoplan_forms_runtime/backend/manifest.py b/src/govoplan_forms_runtime/backend/manifest.py index 870dfb5..8825cfb 100644 --- a/src/govoplan_forms_runtime/backend/manifest.py +++ b/src/govoplan_forms_runtime/backend/manifest.py @@ -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",), diff --git a/src/govoplan_forms_runtime/backend/migrations/versions/d6a8b0c2e4f6_form_status_access.py b/src/govoplan_forms_runtime/backend/migrations/versions/d6a8b0c2e4f6_form_status_access.py new file mode 100644 index 0000000..a2bef5b --- /dev/null +++ b/src/govoplan_forms_runtime/backend/migrations/versions/d6a8b0c2e4f6_form_status_access.py @@ -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") diff --git a/src/govoplan_forms_runtime/backend/router.py b/src/govoplan_forms_runtime/backend/router.py index 305d7d8..204602e 100644 --- a/src/govoplan_forms_runtime/backend/router.py +++ b/src/govoplan_forms_runtime/backend/router.py @@ -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, *, diff --git a/src/govoplan_forms_runtime/backend/schemas.py b/src/govoplan_forms_runtime/backend/schemas.py index eff4c2f..7e16ea7 100644 --- a/src/govoplan_forms_runtime/backend/schemas.py +++ b/src/govoplan_forms_runtime/backend/schemas.py @@ -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", diff --git a/src/govoplan_forms_runtime/backend/service.py b/src/govoplan_forms_runtime/backend/service.py index ac1eb37..c392cbc 100644 --- a/src/govoplan_forms_runtime/backend/service.py +++ b/src/govoplan_forms_runtime/backend/service.py @@ -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( diff --git a/src/govoplan_forms_runtime/backend/status_access.py b/src/govoplan_forms_runtime/backend/status_access.py new file mode 100644 index 0000000..0a19d79 --- /dev/null +++ b/src/govoplan_forms_runtime/backend/status_access.py @@ -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", +] diff --git a/tests/test_forms_runtime.py b/tests/test_forms_runtime.py index c62dc7b..987222d 100644 --- a/tests/test_forms_runtime.py +++ b/tests/test_forms_runtime.py @@ -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, diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 8daf72e..5115774 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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: diff --git a/webui/src/api/formsRuntime.ts b/webui/src/api/formsRuntime.ts index 9420dad..c632167 100644 --- a/webui/src/api/formsRuntime.ts +++ b/webui/src/api/formsRuntime.ts @@ -49,6 +49,27 @@ export type FormIntakeProfile = { metadata: Record; }; +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; +}; + +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; + 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 { + 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 diff --git a/webui/src/features/forms/FormInstancePage.tsx b/webui/src/features/forms/FormInstancePage.tsx index 06a6e45..5310842 100644 --- a/webui/src/features/forms/FormInstancePage.tsx +++ b/webui/src/features/forms/FormInstancePage.tsx @@ -569,6 +569,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex {instance.receipt_id} } + {!editable && instance.status_access && +
+
+ Applicant status access + {statusAccessMessage(instance.status_access.mode)} +
+ {instance.status_access.tracking_id} + Open status page +
+ } {!editable && definition.handoff_kinds.length > 0 &&
@@ -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) { const fields = new Map(definition.fields.map((field) => [field.key, field])); const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values); diff --git a/webui/src/features/forms/FormsRuntimePage.tsx b/webui/src/features/forms/FormsRuntimePage.tsx index 7a471f9..1bb5ecb 100644 --- a/webui/src/features/forms/FormsRuntimePage.tsx +++ b/webui/src/features/forms/FormsRuntimePage.tsx @@ -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 } + {canAdmin && + + } {canAssist &&
} + {!editable && instance.status_access && +
+
+ Track this application + {statusAccessMessage(instance.status_access.mode)} +
+ {instance.status_access.tracking_id} + Open status page +
+ } } @@ -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."; +} diff --git a/webui/src/features/forms/StatusAccessPoliciesDialog.tsx b/webui/src/features/forms/StatusAccessPoliciesDialog.tsx new file mode 100644 index 0000000..0a7a879 --- /dev/null +++ b/webui/src/features/forms/StatusAccessPoliciesDialog.tsx @@ -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([]); + const [policies, setPolicies] = useState([]); + const [definitionKey, setDefinitionKey] = useState(""); + const [mode, setMode] = useState("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 ( + Close}> + {error && {error}} + {notice && {notice}} + {loading && } + {!loading && definitions.length === 0 && + Publish a Form revision before configuring applicant status. + } + {!loading && definitions.length > 0 && + { event.preventDefault(); void save(); }}> + + + + + + + + + {mode === "email_link" && + + + + } + {mode === "email_link" && + + setTokenMinutes(Number(event.target.value))} disabled={Boolean(busyKey)} /> + + } + {mode === "email_link" && + + setRequestLimit(Number(event.target.value))} disabled={Boolean(busyKey)} /> + + } + + + + + +
+ +
+
+ + {policies.length === 0 &&

No applicant status policy has been configured.

} +
+ {policies.map((item) => +
+ + {policyTitle(item)} + Revision {item.definition_ref.version} · {modeLabel(item.mode)} + + + void toggle(item, value)} + /> +
+ )} +
+
+
+ } +
+ ); +} + +function AccessConsequence({ mode }: { mode: FormStatusAccessMode }) { + if (mode === "authenticated") { + return Only the bound applicant account can view status. Assisted intake needs an explicit account: party reference for this profile.; + } + if (mode === "email_link") { + return A matching identifier and email request creates a new expiring secret and revokes the previous one. Notifications and Mail must be configured for delivery.; + } + return The link does not expire or require sign-in. Anyone holding it can see the bounded status timeline until the policy is suspended.; +} + +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"; +} diff --git a/webui/src/styles/forms-runtime.css b/webui/src/styles/forms-runtime.css index 6992c01..e78c611 100644 --- a/webui/src/styles/forms-runtime.css +++ b/webui/src/styles/forms-runtime.css @@ -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;