feat: provide access DSAR integration

This commit is contained in:
2026-08-07 14:53:24 +02:00
parent e04671034f
commit 0b9e3751c2
3 changed files with 819 additions and 0 deletions
@@ -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"]
+79
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY 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.base import AccessBase
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.access import ( 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: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: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: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: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"), _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, managed=True,
protected=False, 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 = ( ADMIN_READ_SCOPES = (
@@ -263,6 +282,7 @@ ADMIN_READ_SCOPES = (
"access:account:read", "access:account:read",
"access:governance:read", "access:governance:read",
"access:function:read", "access:function:read",
"access:privacy:read",
"views:definition:read", "views:definition:read",
"views:assignment:read", "views:assignment:read",
"views:system_definition: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() 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): def _route_factory(context: ModuleContext):
from fastapi import APIRouter from fastapi import APIRouter
@@ -773,6 +850,7 @@ manifest = ModuleManifest(
name="auth.automation_principal", name="auth.automation_principal",
version="0.2.0", version="0.2.0",
), ),
ModuleInterfaceProvider(name=ACCESS_DSAR_CAPABILITY, version="0.1.0"),
), ),
permissions=ACCESS_PERMISSIONS, permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES, role_templates=ACCESS_ROLE_TEMPLATES,
@@ -848,6 +926,7 @@ manifest = ModuleManifest(
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search, CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options, CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
ACCESS_DSAR_CAPABILITY: _dsar_provider,
}, },
documentation=ACCESS_DOCUMENTATION, documentation=ACCESS_DOCUMENTATION,
architecture=declared_module_architecture( architecture=declared_module_architecture(
+139
View File
@@ -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()