feat(forms-runtime): add governed DSAR coverage

This commit is contained in:
2026-08-21 02:18:32 +02:00
parent f6c13ccd4f
commit 2ea7afaead
5 changed files with 1928 additions and 0 deletions
+9
View File
@@ -16,6 +16,15 @@ 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. Its runtime module ID is `forms_runtime`; the repository and Python distribution retain the hyphenated `govoplan-forms-runtime` name.
The module also contributes `privacy.dsar.forms_runtime`. Exact-tenant actor,
email-status, confirmation, acknowledgement, and explicit runtime selectors
produce bounded submission and lifecycle exports. Assisted operators receive
only attribution unless they are also the identified applicant. Credential-like
form keys, access hashes, replay keys, opaque details, validation internals, and
evidence identifiers are excluded. Immutable submission/handoff evidence is
retained; current drafts and active status grants require manual review through
their normal lifecycle, with no automatic DSAR mutation.
The module persists tenant-bound immutable revisions and events, exposes bounded The module persists tenant-bound immutable revisions and events, exposes bounded
owner/manager APIs and WebUI routes, and provides both owner/manager APIs and WebUI routes, and provides both
`forms_runtime.registry` and `forms_runtime.service_launcher`. `forms_runtime.registry` and `forms_runtime.service_launcher`.
+21
View File
@@ -210,6 +210,27 @@ Destructive retirement is blocked while state exists and requires a verified
database snapshot plus an export or retention decision for referenced evidence. database snapshot plus an export or retention decision for referenced evidence.
No local generated files are required, so API and worker nodes remain stateless. No local generated files are required, so API and worker nodes remain stateless.
## Data-subject requests
Forms Runtime publishes `privacy.dsar.forms_runtime`. It correlates exact-tenant
canonical actors, normalized email addresses against grant-specific hashes,
applicant confirmations and acknowledgements, and explicit instance or
lifecycle references. An assisted session prevents its operator from being
mistaken for the applicant when the session identifies a different actor; the
operator still receives a minimized accountability-attribution record.
Corroborated subject instances export bounded form values and typed definition,
status, intake, acknowledgement, status-access, token-lifecycle, and handoff
facts. Credential-like value keys are redacted. Raw token/email hashes,
idempotency and provider keys, request/payload hashes, opaque metadata/details,
validation internals, evidence identifiers, errors, and unrelated submissions
are excluded. Immutable revisions, events, handoffs, confirmations,
acknowledgements, intake evidence, and minimized token lifecycle are retained.
Current drafts and active status grants receive non-executable manual-review
actions and may only be changed through authorized runtime lifecycles. Forms
owns definitions, Portal owns presentation, and Cases/Workflow Engine own
handoff targets.
Public and assisted intake, Files-backed attachment evidence, and authenticated Public and assisted intake, Files-backed attachment evidence, and authenticated
acknowledgements implement the approved first profiles. Conditional multi-page acknowledgements implement the approved first profiles. Conditional multi-page
definitions are resolved from Forms, and native Case/Workflow handoffs execute definitions are resolved from Forms, and native Case/Workflow handoffs execute
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,10 @@ from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_forms_runtime.backend.db import models as runtime_models from govoplan_forms_runtime.backend.db import models as runtime_models
from govoplan_forms_runtime.backend.dsar_provider import (
FORMS_RUNTIME_DSAR_CAPABILITY,
FormsRuntimeDsarProvider,
)
from govoplan_forms_runtime.backend.record_source import ( from govoplan_forms_runtime.backend.record_source import (
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME, CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME,
create_forms_runtime_record_source, create_forms_runtime_record_source,
@@ -210,6 +214,11 @@ def _service_launcher(context: ModuleContext) -> FormsServiceLauncher:
return FormsServiceLauncher(context.registry) return FormsServiceLauncher(context.registry)
def _dsar_provider(context: ModuleContext) -> FormsRuntimeDsarProvider:
del context
return FormsRuntimeDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(runtime_models.FormInstanceRevision).filter( current = session.query(runtime_models.FormInstanceRevision).filter(
runtime_models.FormInstanceRevision.tenant_id == tenant_id, runtime_models.FormInstanceRevision.tenant_id == tenant_id,
@@ -393,6 +402,10 @@ manifest = ModuleManifest(
name=CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME, name=CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME,
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider(
name=FORMS_RUNTIME_DSAR_CAPABILITY,
version="0.1.0",
),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
@@ -430,6 +443,7 @@ manifest = ModuleManifest(
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher, CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher,
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME: create_forms_runtime_record_source, CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME: create_forms_runtime_record_source,
CAPABILITY_APPLICATION_STATUS_PROJECTION: _status_projection, CAPABILITY_APPLICATION_STATUS_PROJECTION: _status_projection,
FORMS_RUNTIME_DSAR_CAPABILITY: _dsar_provider,
}, },
capability_documentation={ capability_documentation={
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation( CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
@@ -452,6 +466,11 @@ manifest = ModuleManifest(
summary="Resolves tenant-bound, deliberately limited application status and configured access challenges.", summary="Resolves tenant-bound, deliberately limited application status and configured access challenges.",
contract_version="1.0.0", contract_version="1.0.0",
), ),
FORMS_RUNTIME_DSAR_CAPABILITY: CapabilityDocumentation(
label="Forms Runtime data-subject request provider",
summary="Finds minimized tenant-scoped submission, intake, status-access, and handoff facts without exposing credentials.",
contract_version="0.1.0",
),
}, },
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -499,6 +518,26 @@ manifest = ModuleManifest(
), ),
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
documentation=( documentation=(
DocumentationTopic(
id="forms_runtime.data-subject-requests",
title="Form-submission data-subject requests",
summary="Export subject-owned form values and governed lifecycle evidence without exposing access credentials or unrelated assisted-intake data.",
body=(
"The Forms Runtime DSAR provider matches exact-tenant canonical actors, normalized email status grants, applicant confirmations and acknowledgements, and explicit runtime references. Assisted operators are recorded as minimized attribution but are not treated as the applicant when the assisted session identifies another actor. Corroborated subject instances export bounded form values and typed definition, status, intake, acknowledgement, status-access, and handoff facts. Credential-like form keys are redacted; token and email hashes, idempotency keys, request and payload hashes, opaque metadata/details, validation internals, evidence identifiers, provider replay keys, errors, and unrelated submissions are excluded. "
"Immutable revisions, events, handoffs, confirmations, acknowledgements, intake evidence, and minimized token lifecycle receive retention actions. Current drafts and active status grants require authorized manual review; Forms Runtime publishes no automatic DSAR mutation. Forms owns definitions, Portal presents public/status routes, and Cases or Workflow Engine own downstream handoff targets."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("forms", "portal", "cases", "workflow_engine"),
links=(
DocumentationLink(
label="Forms Runtime security and recovery",
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
),
DocumentationTopic( DocumentationTopic(
id="forms_runtime.submissions", id="forms_runtime.submissions",
title="Complete and manage Forms", title="Complete and manage Forms",
+701
View File
@@ -0,0 +1,701 @@
from __future__ import annotations
import hashlib
import json
import unittest
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_forms_runtime.backend.db.models import (
FormAcknowledgement,
FormAssistedConfirmation,
FormHandoffEffect,
FormInstanceEvent,
FormInstanceIdentity,
FormInstanceRevision,
FormIntakeProfile,
FormIntakeSession,
FormStatusAccessGrant,
FormStatusAccessPolicy,
FormStatusAccessToken,
)
from govoplan_forms_runtime.backend.dsar_provider import (
FORMS_RUNTIME_DSAR_CAPABILITY,
FormsRuntimeDsarProvider,
)
from govoplan_forms_runtime.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC)
class _Registry:
def __init__(
self,
provider: FormsRuntimeDsarProvider,
*,
active: bool = True,
) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (FORMS_RUNTIME_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "forms_runtime"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("forms_runtime",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "forms_runtime"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != FORMS_RUNTIME_DSAR_CAPABILITY:
raise KeyError(name)
def _snapshot(instance_id: str, *, name: str) -> dict[str, object]:
return {
"instance_id": instance_id,
"definition_ref": {
"kind": "form",
"owner_module": "forms",
"object_id": "permit-form",
"version": "3",
},
"service_ref": {
"kind": "service",
"owner_module": "services",
"object_id": "permit-service",
"version": "2",
},
"values": {
"name": name,
"email": "subject@example.org",
"password_token": "credential-value-do-not-export",
"nested": {"address": "Example Street 1"},
},
"validation_results": [{"message": "private-validation-do-not-export"}],
"attachment_refs": [{"evidence_id": "private-attachment-do-not-export"}],
"signature_refs": [{"evidence_id": "private-signature-do-not-export"}],
"handoff_refs": [{"object_id": "case-1"}],
"receipt_id": "receipt-1",
"change_reason": "private-change-reason-do-not-export",
"metadata": {"secret": "private-metadata-do-not-export"},
}
class FormsRuntimeDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = FormsRuntimeDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed_subject_instance()
self._seed_assisted_instance()
self._seed_unrelated_instances()
self._seed_operator_configuration()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed_subject_instance(self) -> None:
identity = FormInstanceIdentity(
id="identity-row-1",
tenant_id="tenant-1",
instance_id="instance-1",
definition_id="permit-form",
definition_revision="3",
created_by="applicant-1",
)
self.session.add(identity)
self.session.flush()
first = FormInstanceRevision(
id="revision-row-1",
tenant_id="tenant-1",
instance_id="instance-1",
identity_id=identity.id,
revision=1,
status="draft",
recorded_at=NOW,
superseded_at=NOW + timedelta(minutes=1),
snapshot=_snapshot("instance-1", name="Ada Example draft"),
changed_by="applicant-1",
)
second = FormInstanceRevision(
id="revision-row-2",
tenant_id="tenant-1",
instance_id="instance-1",
identity_id=identity.id,
revision=2,
previous_revision_id=first.id,
status="submitted",
recorded_at=NOW + timedelta(minutes=1),
snapshot=_snapshot("instance-1", name="Ada Example"),
changed_by="applicant-1",
)
events = (
FormInstanceEvent(
id="event-row-1",
tenant_id="tenant-1",
instance_id="instance-1",
instance_revision=1,
event_id="event-public-1",
event_type="forms_runtime.instance.started",
status="draft",
occurred_at=NOW,
actor_id="applicant-1",
idempotency_key="private-event-key-do-not-export",
request_sha256="a" * 64,
payload={"secret": "private-event-payload-do-not-export"},
),
FormInstanceEvent(
id="event-row-2",
tenant_id="tenant-1",
instance_id="instance-1",
instance_revision=2,
event_id="event-public-2",
event_type="forms_runtime.instance.submitted",
status="submitted",
occurred_at=NOW + timedelta(minutes=1),
actor_id="applicant-1",
idempotency_key="private-submit-key-do-not-export",
request_sha256="b" * 64,
payload={"secret": "private-submit-payload-do-not-export"},
),
)
handoff = FormHandoffEffect(
id="handoff-row-1",
tenant_id="tenant-1",
instance_id="instance-1",
effect_id="handoff-public-1",
instance_revision=2,
idempotency_key="private-handoff-key-do-not-export",
provider_key="private-provider-key-do-not-export",
request_sha256="c" * 64,
binding_kind="case",
binding_reference="permit-case",
provider_capability="cases.service_launcher",
state="succeeded",
attempt_count=1,
requested_at=NOW + timedelta(minutes=1),
resolved_at=NOW + timedelta(minutes=2),
target_ref={
"kind": "case",
"owner_module": "cases",
"object_id": "case-1",
"version": "1",
},
evidence=[{"secret": "private-handoff-evidence-do-not-export"}],
last_error="private-error-do-not-export",
details={"secret": "private-handoff-details-do-not-export"},
)
intake = FormIntakeSession(
id="intake-row-1",
tenant_id="tenant-1",
session_id="intake-public-1",
profile_id="profile-row-1",
token_sha256="d" * 64,
mode="authenticated",
status="submitted",
instance_id="instance-1",
actor_id="applicant-1",
idempotency_key="private-intake-key-do-not-export",
request_sha256="e" * 64,
expires_at=NOW + timedelta(hours=1),
started_at=NOW,
submitted_at=NOW + timedelta(minutes=1),
created_by="applicant-1",
details={"secret": "private-intake-details-do-not-export"},
)
grant_id = "status-grant-public-1"
grant = FormStatusAccessGrant(
id="grant-row-1",
tenant_id="tenant-1",
grant_id=grant_id,
policy_id="policy-row-1",
instance_id="instance-1",
tracking_id="private-tracking-id-do-not-export",
mode="email_link",
applicant_actor_id="applicant-1",
recipient_email_sha256=hashlib.sha256(
f"{grant_id}\0subject@example.org".encode()
).hexdigest(),
token_ttl_seconds=900,
request_limit_per_hour=3,
issued_at=NOW + timedelta(minutes=1),
details={"secret": "private-grant-details-do-not-export"},
)
token = FormStatusAccessToken(
id="token-row-1",
tenant_id="tenant-1",
token_id="private-token-id-do-not-export",
grant_id=grant.id,
token_sha256="f" * 64,
issued_at=NOW + timedelta(minutes=1),
expires_at=NOW + timedelta(minutes=16),
notification_id="private-notification-id-do-not-export",
details={"secret": "private-token-details-do-not-export"},
)
confirmation = FormAssistedConfirmation(
id="confirmation-row-1",
tenant_id="tenant-1",
confirmation_id="confirmation-public-1",
intake_session_id=intake.id,
instance_id="instance-1",
instance_revision=2,
outcome="confirmed",
method="read_back",
confirmed_by_ref="applicant-1",
operator_actor_id="operator-other",
confirmed_at=NOW + timedelta(minutes=1),
payload_sha256="1" * 64,
idempotency_key="private-confirmation-key-do-not-export",
request_sha256="2" * 64,
correction_note="private-correction-note-do-not-export",
details={"secret": "private-confirmation-details-do-not-export"},
)
acknowledgement = FormAcknowledgement(
id="ack-row-1",
tenant_id="tenant-1",
acknowledgement_id="ack-public-1",
instance_id="instance-1",
instance_revision=2,
statement_id="truthful-submission",
statement_version="1",
actor_id="applicant-1",
accepted_at=NOW + timedelta(minutes=1),
payload_sha256="3" * 64,
idempotency_key="private-ack-key-do-not-export",
request_sha256="4" * 64,
details={"secret": "private-ack-details-do-not-export"},
)
self.session.add_all(
(
first,
second,
*events,
handoff,
intake,
grant,
token,
confirmation,
acknowledgement,
)
)
def _seed_assisted_instance(self) -> None:
identity = FormInstanceIdentity(
id="identity-assisted",
tenant_id="tenant-1",
instance_id="instance-assisted",
definition_id="permit-form",
definition_revision="3",
created_by="operator-1",
)
self.session.add(identity)
self.session.flush()
self.session.add_all(
(
FormInstanceRevision(
id="revision-assisted",
tenant_id="tenant-1",
instance_id="instance-assisted",
identity_id=identity.id,
revision=1,
status="draft",
recorded_at=NOW,
snapshot=_snapshot(
"instance-assisted",
name="Assisted Applicant Private Name",
),
changed_by="operator-1",
),
FormInstanceEvent(
id="event-assisted",
tenant_id="tenant-1",
instance_id="instance-assisted",
instance_revision=1,
event_id="event-assisted-public",
event_type="forms_runtime.instance.started",
status="draft",
occurred_at=NOW,
actor_id="operator-1",
idempotency_key="assisted-private-key",
request_sha256="5" * 64,
payload={"secret": "assisted-private-payload"},
),
FormIntakeSession(
id="intake-assisted",
tenant_id="tenant-1",
session_id="intake-assisted-public",
profile_id="profile-row-1",
token_sha256="6" * 64,
mode="assisted",
status="started",
instance_id="instance-assisted",
actor_id="applicant-assisted",
idempotency_key="assisted-intake-private-key",
request_sha256="7" * 64,
expires_at=NOW + timedelta(hours=1),
started_at=NOW,
created_by="operator-1",
details={"secret": "assisted-details-private"},
),
)
)
def _seed_unrelated_instances(self) -> None:
for tenant_id, suffix, actor in (
("tenant-1", "unrelated", "unrelated-actor"),
("tenant-2", "other-tenant", "applicant-1"),
):
identity = FormInstanceIdentity(
id=f"identity-{suffix}",
tenant_id=tenant_id,
instance_id=f"instance-{suffix}",
definition_id="permit-form",
definition_revision="3",
created_by=actor,
)
self.session.add(identity)
self.session.flush()
self.session.add(
FormInstanceRevision(
id=f"revision-{suffix}",
tenant_id=tenant_id,
instance_id=identity.instance_id,
identity_id=identity.id,
revision=1,
status="submitted",
recorded_at=NOW,
snapshot=_snapshot(
identity.instance_id,
name=f"private-{suffix}-name-do-not-export",
),
changed_by=actor,
)
)
def _seed_operator_configuration(self) -> None:
self.session.add_all(
(
FormIntakeProfile(
id="profile-row-1",
tenant_id="tenant-1",
profile_id="profile-public-1",
public_id="public-profile-id",
definition_id="permit-form",
definition_revision="3",
mode="assisted",
enabled=True,
revision=1,
custodian_ref="unit-1",
draft_ttl_seconds=3600,
invitation_ttl_seconds=3600,
rate_limit_per_minute=10,
created_by="operator-1",
updated_by="operator-1",
details={"secret": "private-profile-details-do-not-export"},
),
FormStatusAccessPolicy(
id="policy-row-1",
tenant_id="tenant-1",
policy_id="policy-public-1",
definition_id="permit-form",
definition_revision="3",
mode="email_link",
enabled=True,
revision=1,
email_field_key="email",
token_ttl_seconds=900,
request_limit_per_hour=3,
created_by="operator-1",
updated_by="operator-1",
details={"secret": "private-policy-details-do-not-export"},
),
)
)
def test_actor_search_exports_subject_instance_and_minimizes_internals(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="applicant-1"),
)
types = {item.resource_type for item in records}
self.assertIn("forms_runtime_instance_revision", types)
self.assertIn("forms_runtime_handoff_effect", types)
self.assertIn("forms_runtime_status_access_grant", types)
self.assertIn("forms_runtime_status_token_lifecycle", types)
self.assertIn("forms_runtime_assisted_confirmation", types)
self.assertIn("forms_runtime_acknowledgement", types)
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
self.assertIn("Ada Example", exported)
self.assertIn("Example Street 1", exported)
self.assertNotIn("credential-value-do-not-export", exported)
self.assertNotIn("private-attachment-do-not-export", exported)
self.assertNotIn("private-signature-do-not-export", exported)
self.assertNotIn("private-validation-do-not-export", exported)
self.assertNotIn("private-event-payload-do-not-export", exported)
self.assertNotIn("private-token-id-do-not-export", exported)
self.assertNotIn("private-tracking-id-do-not-export", exported)
self.assertNotIn("private-notification-id-do-not-export", exported)
self.assertNotIn("private-provider-key-do-not-export", exported)
self.assertNotIn("private-error-do-not-export", exported)
self.assertNotIn("private-correction-note-do-not-export", exported)
self.assertNotIn("private-unrelated-name-do-not-export", exported)
self.assertNotIn("private-other-tenant-name-do-not-export", exported)
def test_email_selector_matches_grant_specific_digest(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email=" Subject@Example.org "),
)
self.assertTrue(
any(
item.resource_type == "forms_runtime_status_access_grant"
and item.data["email_selector_matched"] is True
for item in records
)
)
self.assertTrue(
any(
item.resource_type == "forms_runtime_instance_revision"
for item in records
)
)
def test_assisted_operator_is_attributed_but_not_treated_as_applicant(self) -> None:
operator_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="operator-1"),
)
exported = json.dumps(
[item.to_dict() for item in operator_records], sort_keys=True
)
self.assertIn("forms_runtime_operator_attribution", exported)
self.assertIn("created_form_instance", exported)
self.assertIn("intake_profile_configuration", exported)
self.assertNotIn("Assisted Applicant Private Name", exported)
self.assertNotIn("assisted-private-payload", exported)
self.assertNotIn("private-profile-details-do-not-export", exported)
self.assertNotIn("private-policy-details-do-not-export", exported)
applicant_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="applicant-assisted"),
)
self.assertIn(
"Assisted Applicant Private Name",
json.dumps([item.to_dict() for item in applicant_records]),
)
def test_direct_and_canonical_conflicts_fail_closed(self) -> None:
direct = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"forms_runtime.instance": "instance-1"}
),
)
self.assertTrue(
any(
item.resource_type == "forms_runtime_instance_revision"
for item in direct
)
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="applicant-1",
external_references={"forms_runtime.instance": "instance-unrelated"},
),
)
self.assertEqual((), conflict)
alias_conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="applicant-1",
external_references={"forms_runtime.account": "other-account"},
),
)
self.assertEqual((), alias_conflict)
def test_planning_retains_evidence_and_reviews_active_status_access(self) -> None:
subject = DsarSubjectRef(account_id="applicant-1")
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertGreater(sum(item.kind == "retain" for item in actions), 5)
self.assertEqual(1, sum(item.kind == "manual_review" for item in actions))
self.assertTrue(all(not item.executable for item in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-forms-1",
)
self.assertTrue(all(item.status == "blocked" for item in results))
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(account_id="applicant-1")
foreign_record = DsarRecordRef(
provider_id="foreign",
module_id="foreign",
resource_type="foreign",
resource_id="foreign-1",
category="foreign",
title="Foreign record",
)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(foreign_record,),
)
foreign_action = DsarErasureActionRef(
action_id="foreign:delete:1",
provider_id="foreign",
module_id="foreign",
kind="delete",
resource_type="foreign",
resource_id="foreign-1",
title="Delete foreign",
rationale="No",
executable=True,
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(foreign_action,),
request_id="dsar-forms-1",
)
def test_workflow_discovers_only_the_active_tenant_capability(self) -> None:
active = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-FORMS-1",
request_kind="access",
subject=DsarSubjectRef(account_id="applicant-1"),
purpose="Subject access request",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-operator",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=active,
expected_revision=1,
)
self.assertEqual("searched", active.status)
self.assertEqual(
[FORMS_RUNTIME_DSAR_CAPABILITY],
active.coverage["provider_capabilities"],
)
self.assertEqual(["forms_runtime"], active.coverage["covered_modules"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-FORMS-2",
request_kind="access",
subject=DsarSubjectRef(account_id="applicant-1"),
purpose="Inactive module coverage",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-operator",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[FORMS_RUNTIME_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertEqual(0, inactive.search_result["record_count"])
def test_manifest_registers_and_documents_the_capability(self) -> None:
self.assertIn(FORMS_RUNTIME_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
FORMS_RUNTIME_DSAR_CAPABILITY,
manifest.capability_documentation,
)
self.assertIn(
FORMS_RUNTIME_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "forms_runtime.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()