From 0b9e3751c20eb0827d24c0f8e2259ecb460f2dc5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 7 Aug 2026 14:53:24 +0200 Subject: [PATCH] feat: provide access DSAR integration --- src/govoplan_access/backend/dsar_provider.py | 601 +++++++++++++++++++ src/govoplan_access/backend/manifest.py | 79 +++ tests/test_dsar_provider.py | 139 +++++ 3 files changed, 819 insertions(+) create mode 100644 src/govoplan_access/backend/dsar_provider.py create mode 100644 tests/test_dsar_provider.py diff --git a/src/govoplan_access/backend/dsar_provider.py b/src/govoplan_access/backend/dsar_provider.py new file mode 100644 index 0000000..6314e49 --- /dev/null +++ b/src/govoplan_access/backend/dsar_provider.py @@ -0,0 +1,601 @@ +from __future__ import annotations + +import hashlib +from collections.abc import Sequence +from datetime import datetime, timezone + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import ( + Account, + ApiKey, + AuthSession, + Function, + FunctionAssignment, + Group, + Identity, + IdentityAccountLink, + OrganizationUnit, + Role, + SystemRoleAssignment, + User, + UserGroupMembership, + UserRoleAssignment, +) +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, +) + + +ACCESS_DSAR_CAPABILITY = "privacy.dsar.access" + + +class AccessDsarProvider: + provider_id = "access" + module_id = "access" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + users = _subject_users(db, tenant_id=tenant_id, subject=subject) + records: list[DsarRecordRef] = [] + seen: set[tuple[str, str]] = set() + + def append(record: DsarRecordRef) -> None: + key = (record.resource_type, record.resource_id) + if key not in seen: + seen.add(key) + records.append(record) + + for user in users: + append( + _record( + "membership", + user.id, + "profile", + user.display_name or user.email, + { + "account_id": user.account_id, + "email": user.email, + "display_name": user.display_name, + "is_active": user.is_active, + "auth_provider": user.auth_provider, + "last_login_at": _iso(user.last_login_at), + "created_at": _iso(user.created_at), + "updated_at": _iso(user.updated_at), + }, + observed_at=user.updated_at, + source_path=f"/admin?section=tenant-users&user={user.id}", + ) + ) + account = db.get(Account, user.account_id) + if account is not None: + append( + _record( + "account", + account.id, + "global_identity", + account.display_name or account.email, + { + "email": account.email, + "display_name": account.display_name, + "is_active": account.is_active, + "auth_provider": account.auth_provider, + "last_login_at": _iso(account.last_login_at), + "created_at": _iso(account.created_at), + }, + observed_at=account.updated_at, + source_path=f"/admin?section=system-users&account={account.id}", + ) + ) + _append_identity_records(db, append, account) + _append_system_role_records(db, append, account) + _append_api_key_records(db, append, user) + _append_session_records(db, append, user) + _append_group_records(db, append, user) + _append_role_records(db, append, user) + _append_function_records(db, append, user) + return tuple(records) + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + del session, subject + actions: list[DsarErasureActionRef] = [] + for record in records: + if record.resource_type == "membership": + actions.append( + _action( + f"access:anonymize:membership:{record.resource_id}", + "anonymize", + record, + "Anonymize and deactivate the tenant membership", + "Tenant-local profile data can be removed without deleting stable evidence identifiers.", + executable=True, + irreversible=True, + metadata={"tenant_id": tenant_id}, + ) + ) + elif record.resource_type == "api_key" and record.data.get("active"): + actions.append( + _action( + f"access:revoke:api-key:{record.resource_id}", + "revoke", + record, + "Revoke API key", + "An active credential associated with the data subject must no longer authenticate.", + executable=True, + ) + ) + elif record.resource_type == "auth_session" and record.data.get("active"): + actions.append( + _action( + f"access:revoke:session:{record.resource_id}", + "revoke", + record, + "Revoke login session", + "An active session associated with the data subject must no longer authenticate.", + executable=True, + ) + ) + elif record.resource_type in {"account", "identity"}: + actions.append( + _action( + f"access:review:{record.resource_type}:{record.resource_id}", + "manual_review", + record, + f"Review global {record.resource_type}", + "Global identities may serve other tenants or legal obligations and require a system-level decision.", + executable=False, + ) + ) + return tuple(actions) + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + del subject + db = _session(session) + now = datetime.now(timezone.utc) + results: list[DsarExecutionResultRef] = [] + for action in actions: + if action.action_id.startswith("access:anonymize:membership:"): + row = db.get(User, action.resource_id) + if row is None or row.tenant_id != tenant_id: + results.append(_blocked(action, "Tenant membership is no longer available.")) + continue + replacement = _erased_email(tenant_id, row.id) + unchanged = ( + row.email == replacement + and row.display_name == "Erased data subject" + and not row.is_active + ) + row.email = replacement + row.display_name = "Erased data subject" + row.is_active = False + row.is_tenant_admin = False + row.password_hash = None + row.last_login_at = None + row.settings = {} + row.mail_profile_policy = {} + results.append( + _result( + action, + "unchanged" if unchanged else "executed", + "Tenant membership was already anonymized." + if unchanged + else "Tenant membership was anonymized and deactivated.", + {"request_id": request_id, "replacement_email": replacement}, + ) + ) + elif action.action_id.startswith("access:revoke:api-key:"): + row = db.get(ApiKey, action.resource_id) + if row is None or row.tenant_id != tenant_id: + results.append(_blocked(action, "API key is no longer available.")) + continue + unchanged = row.revoked_at is not None + if row.revoked_at is None: + row.revoked_at = now + results.append( + _result( + action, + "unchanged" if unchanged else "executed", + "API key was already revoked." if unchanged else "API key was revoked.", + {"request_id": request_id, "revoked_at": _iso(row.revoked_at)}, + ) + ) + elif action.action_id.startswith("access:revoke:session:"): + row = db.get(AuthSession, action.resource_id) + if row is None or row.tenant_id != tenant_id: + results.append(_blocked(action, "Login session is no longer available.")) + continue + unchanged = row.revoked_at is not None and not row.ip_address and not row.user_agent + if row.revoked_at is None: + row.revoked_at = now + row.ip_address = None + row.user_agent = None + row.csrf_token_hash = None + results.append( + _result( + action, + "unchanged" if unchanged else "executed", + "Login session was already revoked and redacted." + if unchanged + else "Login session was revoked and client metadata was redacted.", + {"request_id": request_id, "revoked_at": _iso(row.revoked_at)}, + ) + ) + else: + results.append(_blocked(action, "Access does not execute this action kind.")) + db.flush() + return tuple(results) + + +def _subject_users( + session: Session, + *, + tenant_id: str, + subject: DsarSubjectRef, +) -> tuple[User, ...]: + candidate_sets: list[set[str]] = [] + if subject.membership_id: + candidate_sets.append({ + row[0] + for row in session.query(User.id).filter( + User.tenant_id == tenant_id, + User.id == subject.membership_id, + ) + }) + if subject.account_id: + candidate_sets.append({ + row[0] + for row in session.query(User.id).filter( + User.tenant_id == tenant_id, + User.account_id == subject.account_id, + ) + }) + if subject.identity_id: + account_ids = { + row[0] + for row in session.query(IdentityAccountLink.account_id).filter( + IdentityAccountLink.identity_id == subject.identity_id + ) + } + candidate_sets.append( + { + row[0] + for row in session.query(User.id).filter( + User.tenant_id == tenant_id, + User.account_id.in_(account_ids), + ) + } + if account_ids + else set() + ) + for key, value in subject.external_references.items(): + if key in {"access.account", "account_id"}: + candidate_sets.append({ + row[0] + for row in session.query(User.id).filter( + User.tenant_id == tenant_id, + User.account_id == value, + ) + }) + elif key in {"access.membership", "membership_id"}: + candidate_sets.append({ + row[0] + for row in session.query(User.id).filter( + User.tenant_id == tenant_id, + User.id == value, + ) + }) + if subject.email: + normalized = subject.email.strip().casefold() + matching_accounts = { + row[0] + for row in session.query(Account.id).filter( + Account.normalized_email == normalized + ) + } + email_matches = { + row[0] + for row in session.query(User.id).filter( + User.tenant_id == tenant_id, + func.lower(User.email) == normalized, + ) + } + if matching_accounts: + email_matches.update( + row[0] + for row in session.query(User.id).filter( + User.tenant_id == tenant_id, + User.account_id.in_(matching_accounts), + ) + ) + candidate_sets.append(email_matches) + if not candidate_sets: + return () + user_ids = set.intersection(*candidate_sets) + if not user_ids: + return () + return tuple( + session.query(User) + .filter(User.tenant_id == tenant_id, User.id.in_(user_ids)) + .order_by(User.id) + .all() + ) + + +def _append_identity_records(session: Session, append: object, account: Account) -> None: + for link, identity in ( + session.query(IdentityAccountLink, Identity) + .join(Identity, Identity.id == IdentityAccountLink.identity_id) + .filter(IdentityAccountLink.account_id == account.id) + .all() + ): + append( # type: ignore[operator] + _record( + "identity", + identity.id, + "global_identity", + identity.display_name or identity.id, + { + "display_name": identity.display_name, + "external_subject": identity.external_subject, + "source": identity.source, + "is_active": identity.is_active, + "is_primary_link": link.is_primary, + }, + observed_at=identity.updated_at, + source_path=f"/admin?section=system-users&identity={identity.id}", + ) + ) + + +def _append_system_role_records(session: Session, append: object, account: Account) -> None: + for assignment, role in ( + session.query(SystemRoleAssignment, Role) + .join(Role, Role.id == SystemRoleAssignment.role_id) + .filter(SystemRoleAssignment.account_id == account.id) + .all() + ): + append( # type: ignore[operator] + _record( + "system_role_assignment", + assignment.id, + "governance_evidence", + f"System role: {role.name}", + {"role_id": role.id, "role_name": role.name}, + observed_at=assignment.updated_at, + immutable=True, + retention_reason="System authorization history is institutional evidence.", + ) + ) + + +def _append_api_key_records(session: Session, append: object, user: User) -> None: + for item in session.query(ApiKey).filter(ApiKey.user_id == user.id).all(): + append( # type: ignore[operator] + _record( + "api_key", + item.id, + "credential", + item.name, + { + "prefix": item.prefix, + "scopes": list(item.scopes or ()), + "active": item.revoked_at is None, + "expires_at": _iso(item.expires_at), + "last_used_at": _iso(item.last_used_at), + "revoked_at": _iso(item.revoked_at), + }, + observed_at=item.updated_at, + source_path="/admin?section=tenant-api-keys", + ) + ) + + +def _append_session_records(session: Session, append: object, user: User) -> None: + for item in session.query(AuthSession).filter(AuthSession.user_id == user.id).all(): + append( # type: ignore[operator] + _record( + "auth_session", + item.id, + "authentication", + f"Login session {item.id[:8]}", + { + "active": item.revoked_at is None, + "expires_at": _iso(item.expires_at), + "last_seen_at": _iso(item.last_seen_at), + "revoked_at": _iso(item.revoked_at), + }, + observed_at=item.updated_at, + ) + ) + + +def _append_group_records(session: Session, append: object, user: User) -> None: + for assignment, group in ( + session.query(UserGroupMembership, Group) + .join(Group, Group.id == UserGroupMembership.group_id) + .filter(UserGroupMembership.user_id == user.id) + .all() + ): + append( # type: ignore[operator] + _record( + "group_membership", + assignment.id, + "governance_evidence", + f"Group: {group.name}", + {"group_id": group.id, "group_name": group.name}, + observed_at=assignment.updated_at, + immutable=True, + retention_reason="Group assignment history is institutional access evidence.", + ) + ) + + +def _append_role_records(session: Session, append: object, user: User) -> None: + for assignment, role in ( + session.query(UserRoleAssignment, Role) + .join(Role, Role.id == UserRoleAssignment.role_id) + .filter(UserRoleAssignment.user_id == user.id) + .all() + ): + append( # type: ignore[operator] + _record( + "role_assignment", + assignment.id, + "governance_evidence", + f"Role: {role.name}", + {"role_id": role.id, "role_name": role.name}, + observed_at=assignment.updated_at, + immutable=True, + retention_reason="Role assignment history is institutional access evidence.", + ) + ) + + +def _append_function_records(session: Session, append: object, user: User) -> None: + rows = ( + session.query(FunctionAssignment, Function, OrganizationUnit) + .join(Function, Function.id == FunctionAssignment.function_id) + .join(OrganizationUnit, OrganizationUnit.id == FunctionAssignment.organization_unit_id) + .filter( + FunctionAssignment.tenant_id == user.tenant_id, + FunctionAssignment.account_id == user.account_id, + ) + .all() + ) + for assignment, function, unit in rows: + append( # type: ignore[operator] + _record( + "function_assignment", + assignment.id, + "governance_evidence", + f"{function.name} in {unit.name}", + { + "function_id": function.id, + "function_name": function.name, + "organization_unit_id": unit.id, + "organization_unit_name": unit.name, + "source": assignment.source, + "valid_from": _iso(assignment.valid_from), + "valid_until": _iso(assignment.valid_until), + "is_active": assignment.is_active, + }, + observed_at=assignment.updated_at, + immutable=True, + retention_reason="Function incumbency is effective-dated institutional evidence.", + source_path="/admin?section=tenant-function-role-mappings", + ) + ) + + +def _record( + resource_type: str, + resource_id: str, + category: str, + title: str, + data: dict[str, object], + *, + observed_at: datetime | None = None, + immutable: bool = False, + retention_reason: str | None = None, + source_path: str | None = None, +) -> DsarRecordRef: + return DsarRecordRef( + provider_id="access", + module_id="access", + resource_type=resource_type, + resource_id=resource_id, + category=category, + title=title, + data=data, + observed_at=observed_at, + immutable_evidence=immutable, + retention_reason=retention_reason, + source_path=source_path, + ) + + +def _action( + action_id: str, + kind: str, + record: DsarRecordRef, + title: str, + rationale: str, + *, + executable: bool, + irreversible: bool = False, + metadata: dict[str, object] | None = None, +) -> DsarErasureActionRef: + return DsarErasureActionRef( + action_id=action_id, + provider_id="access", + module_id="access", + kind=kind, # type: ignore[arg-type] + resource_type=record.resource_type, + resource_id=record.resource_id, + title=title, + rationale=rationale, + executable=executable, + irreversible=irreversible, + metadata=metadata or {}, + ) + + +def _result( + action: DsarErasureActionRef, + result_status: str, + summary: str, + evidence: dict[str, object] | None = None, +) -> DsarExecutionResultRef: + return DsarExecutionResultRef( + action_id=action.action_id, + status=result_status, # type: ignore[arg-type] + summary=summary, + evidence=evidence or {}, + ) + + +def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef: + return _result(action, "blocked", summary) + + +def _erased_email(tenant_id: str, membership_id: str) -> str: + digest = hashlib.sha256(f"{tenant_id}\0{membership_id}".encode()).hexdigest()[:24] + return f"erased+{digest}@invalid.govoplan" + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Access DSAR provider requires a SQLAlchemy session.") + return value + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +__all__ = ["ACCESS_DSAR_CAPABILITY", "AccessDsarProvider"] diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 23a7303..f8b7b29 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY +from govoplan_access.backend.dsar_provider import ACCESS_DSAR_CAPABILITY from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata from govoplan_core.core.access import ( @@ -105,6 +106,10 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( _permission("access:credential:manage_own", "Manage own credentials", "Manage reusable credentials owned by the current membership.", "Tenant access", "tenant"), _permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"), _permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"), + _permission("access:privacy:read", "View data-subject requests", "Inspect tenant data-subject requests, provider coverage, and retained evidence decisions.", "Privacy", "tenant"), + _permission("access:privacy:manage", "Manage data-subject requests", "Create requests and run provider searches and erasure planning.", "Privacy", "tenant"), + _permission("access:privacy:export", "Export data-subject requests", "Export the collected personal-data package and its coverage manifest.", "Privacy", "tenant"), + _permission("access:privacy:erase", "Execute data erasure", "Execute explicitly selected, provider-owned erasure and anonymization actions.", "Privacy", "tenant"), _permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"), _permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"), ) @@ -244,6 +249,20 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( managed=True, protected=False, ), + RoleTemplate( + slug="privacy_officer", + name="Privacy officer", + description="Search, export, plan, and execute governed data-subject requests.", + permissions=( + "access:privacy:read", + "access:privacy:manage", + "access:privacy:export", + "access:privacy:erase", + ), + level="tenant", + managed=True, + protected=False, + ), ) ADMIN_READ_SCOPES = ( @@ -263,6 +282,7 @@ ADMIN_READ_SCOPES = ( "access:account:read", "access:governance:read", "access:function:read", + "access:privacy:read", "views:definition:read", "views:assignment:read", "views:system_definition:read", @@ -573,6 +593,56 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( ], }, ), + DocumentationTopic( + id="access.workflow.data-subject-request", + title="Process a data-subject request", + summary="Privacy officers search provider-owned data, export a coverage manifest, and execute only reviewed erasure actions while retaining required institutional evidence.", + body=( + "Create a request with at least one stable subject selector, run the cross-module search, and inspect provider coverage before treating the result as complete. " + "For erasure requests, generate a plan and review every provider-owned action. Immutable role, function, and audit evidence remains present with its retention reason; global accounts and identities require system-level review because they may serve more than one tenant. " + "Execution requires the dedicated erasure permission, the current resource revision, selected executable actions, and an exact confirmation phrase. Access anonymizes the tenant membership and revokes active API keys and sessions without deleting stable evidence identifiers." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("privacy_officer", "tenant_owner", "operator"), + order=34, + conditions=( + DocumentationCondition( + required_modules=("access", "admin"), + any_scopes=( + "access:privacy:read", + "access:privacy:manage", + "access:privacy:export", + "access:privacy:erase", + ), + ), + ), + links=( + DocumentationLink(label="Data-subject requests", href="/admin?section=tenant-data-subject-requests", kind="runtime"), + DocumentationLink(label="Data-subject request API", href="/api/v1/admin/privacy/data-subject-requests", kind="api"), + ), + translations={ + "de": { + "title": "Betroffenenanfrage bearbeiten", + "summary": "Datenschutzbeauftragte suchen modulspezifische Daten, exportieren einen Abdeckungsnachweis und führen nur geprüfte Löschaktionen aus; erforderliche institutionelle Nachweise bleiben erhalten.", + "body": "Legen Sie eine Anfrage mit mindestens einem stabilen Merkmal der betroffenen Person an, führen Sie die modulübergreifende Suche aus und prüfen Sie die Anbieterabdeckung. Erstellen Sie bei Löschanfragen anschließend einen Plan und prüfen Sie jede Aktion. Unveränderliche Rollen-, Funktions- und Auditnachweise bleiben mit Begründung erhalten. Die Ausführung erfordert ein eigenes Recht, die aktuelle Revision, ausgewählte Aktionen und die exakte Bestätigung.", + } + }, + metadata={ + "kind": "workflow", + "help_contexts": ["admin.privacy.data-subject-requests"], + "permission_scopes": [ + "access:privacy:read", + "access:privacy:manage", + "access:privacy:export", + "access:privacy:erase", + ], + "limitations": [ + "Modules without a DSAR provider are reported as coverage gaps.", + "Global accounts and identities are not erased automatically.", + ], + }, + ), ) @@ -732,6 +802,13 @@ def _configuration_provider(context: ModuleContext) -> object: return SqlAccessConfigurationProvider() +def _dsar_provider(context: ModuleContext) -> object: + del context + from govoplan_access.backend.dsar_provider import AccessDsarProvider + + return AccessDsarProvider() + + def _route_factory(context: ModuleContext): from fastapi import APIRouter @@ -773,6 +850,7 @@ manifest = ModuleManifest( name="auth.automation_principal", version="0.2.0", ), + ModuleInterfaceProvider(name=ACCESS_DSAR_CAPABILITY, version="0.1.0"), ), permissions=ACCESS_PERMISSIONS, role_templates=ACCESS_ROLE_TEMPLATES, @@ -848,6 +926,7 @@ manifest = ModuleManifest( CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search, CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, + ACCESS_DSAR_CAPABILITY: _dsar_provider, }, documentation=ACCESS_DOCUMENTATION, architecture=declared_module_architecture( diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..8a70e73 --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from govoplan_access.backend.db.base import AccessBase +from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User +from govoplan_access.backend.dsar_provider import AccessDsarProvider +from govoplan_core.core.dsar import DsarSubjectRef + + +class AccessDsarProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine( + "sqlite+pysqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + AccessBase.metadata.create_all(bind=self.engine) + self.session = sessionmaker(bind=self.engine, expire_on_commit=False)() + self.account = Account( + id="account-1", + email="ada@example.test", + normalized_email="ada@example.test", + display_name="Ada Example", + password_hash="secret-hash", + ) + self.user = User( + id="membership-1", + tenant_id="tenant-1", + account_id=self.account.id, + email="ada@example.test", + display_name="Ada Example", + password_hash="tenant-secret-hash", + settings={"locale": "de"}, + mail_profile_policy={"profile": "one"}, + ) + self.key = ApiKey( + id="key-1", + tenant_id="tenant-1", + user_id=self.user.id, + name="Automation", + prefix="gpn_example", + key_hash="do-not-export", + scopes=["files:read"], + ) + self.auth_session = AuthSession( + id="session-1", + tenant_id="tenant-1", + user_id=self.user.id, + account_id=self.account.id, + token_hash="do-not-export", + csrf_token_hash="do-not-export", + expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + user_agent="Browser fingerprint", + ip_address="192.0.2.10", + ) + self.session.add_all([self.account, self.user, self.key, self.auth_session]) + self.session.commit() + self.provider = AccessDsarProvider() + + def tearDown(self) -> None: + self.session.close() + AccessBase.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def test_search_omits_secret_and_client_fingerprint_material(self) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(email="ADA@example.test"), + ) + serialized = repr([record.to_dict() for record in records]) + self.assertIn("membership-1", serialized) + self.assertNotIn("do-not-export", serialized) + self.assertNotIn("Browser fingerprint", serialized) + self.assertNotIn("192.0.2.10", serialized) + + def test_multiple_subject_selectors_must_identify_the_same_membership(self) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + membership_id=self.user.id, + email="different@example.test", + ), + ) + + self.assertEqual((), records) + + def test_plan_and_execution_anonymize_membership_and_revoke_credentials(self) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id=self.account.id), + ) + actions = self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id=self.account.id), + records=records, + ) + executable = tuple(action for action in actions if action.executable) + self.assertEqual(3, len(executable)) + self.assertTrue(any(action.kind == "manual_review" for action in actions)) + + results = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id=self.account.id), + actions=executable, + request_id="dsar-1", + ) + self.assertEqual({"executed"}, {result.status for result in results}) + self.assertTrue(self.user.email.endswith("@invalid.govoplan")) + self.assertFalse(self.user.is_active) + self.assertEqual({}, self.user.settings) + self.assertIsNotNone(self.key.revoked_at) + self.assertIsNotNone(self.auth_session.revoked_at) + self.assertIsNone(self.auth_session.user_agent) + self.assertIsNone(self.auth_session.ip_address) + self.assertEqual("ada@example.test", self.account.email) + + repeated = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id=self.account.id), + actions=executable, + request_id="dsar-1", + ) + self.assertEqual({"unchanged"}, {result.status for result in repeated}) + + +if __name__ == "__main__": + unittest.main()