feat: add governed Files DSAR coverage

This commit is contained in:
2026-08-20 22:40:33 +02:00
parent 6c3cf1c55e
commit ce4eaefbfd
6 changed files with 1481 additions and 1 deletions
+879
View File
@@ -0,0 +1,879 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime, timezone
from sqlalchemy import inspect, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_files.backend.db.models import (
CampaignAttachmentUse,
FileAsset,
FileConnectorCredential,
FileConnectorPolicy,
FileConnectorProfile,
FileConnectorSpace,
FileFolder,
FileFormEvidenceGrant,
FileIntegrityFinding,
FileIntegrityScan,
FileShare,
FileVersion,
)
FILES_DSAR_CAPABILITY = dsar_capability_name("files")
_MAX_RECORDS = 5_000
class FilesDsarProvider:
provider_id = "files"
module_id = "files"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
subject_user_id = _subject_user_id(subject)
if subject_user_id is None:
return ()
records: list[DsarRecordRef] = []
form_evidence_available = _has_table(db, FileFormEvidenceGrant)
campaign_evidence_available = _has_table(db, CampaignAttachmentUse)
retention_reasons: dict[str, str | None] = {}
def retention_reason(asset: FileAsset) -> str | None:
if asset.id not in retention_reasons:
retention_reasons[asset.id] = _asset_retention_reason(
db,
asset,
form_evidence_available=form_evidence_available,
campaign_evidence_available=campaign_evidence_available,
)
return retention_reasons[asset.id]
def append(record: DsarRecordRef) -> None:
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Files DSAR match limit exceeded; narrow the subject selectors."
)
records.append(record)
assets = _bounded_rows(
db.query(FileAsset)
.filter(
FileAsset.tenant_id == tenant_id,
or_(
FileAsset.owner_user_id == subject_user_id,
FileAsset.created_by_user_id == subject_user_id,
),
)
.order_by(FileAsset.id)
)
for asset in assets:
match_fields = _matching_fields(
asset,
subject_user_id,
("owner_user_id", "created_by_user_id"),
)
asset_retention_reason = retention_reason(asset)
append(
_record(
"file_asset",
asset.id,
"managed_file",
asset.filename,
{
"match_fields": match_fields,
"owner_type": asset.owner_type,
"display_path": asset.display_path,
"filename": asset.filename,
"description": asset.description,
"deleted_at": _iso(asset.deleted_at),
"retained_until": _iso(asset.retained_until),
"legal_hold": asset.legal_hold,
"lifecycle_reason": asset.lifecycle_reason,
"lifecycle_revision": asset.lifecycle_revision,
},
observed_at=asset.updated_at,
immutable=asset_retention_reason is not None,
retention_reason=asset_retention_reason,
source_path=f"/files?file={asset.id}",
)
)
version_rows = _bounded_rows(
db.query(FileVersion, FileAsset)
.join(FileAsset, FileAsset.id == FileVersion.file_asset_id)
.filter(
FileVersion.tenant_id == tenant_id,
FileAsset.tenant_id == tenant_id,
or_(
FileAsset.owner_user_id == subject_user_id,
FileVersion.created_by_user_id == subject_user_id,
),
)
.order_by(FileVersion.id)
)
for version, asset in version_rows:
asset_retention_reason = retention_reason(asset)
append(
_record(
"file_version",
version.id,
"managed_file_version",
version.filename_at_upload,
{
"match_fields": (
["asset.owner_user_id"]
if asset.owner_user_id == subject_user_id
else []
)
+ (
["created_by_user_id"]
if version.created_by_user_id == subject_user_id
else []
),
"file_asset_id": version.file_asset_id,
"version_number": version.version_number,
"filename_at_upload": version.filename_at_upload,
"display_path_at_upload": version.display_path_at_upload,
"content_type": version.content_type,
"size_bytes": version.size_bytes,
"checksum_sha256": version.checksum_sha256,
"created_at": _iso(version.created_at),
},
observed_at=version.updated_at,
immutable=asset_retention_reason is not None,
retention_reason=asset_retention_reason,
source_path=(
f"/api/v1/files/{version.file_asset_id}/versions/"
f"{version.id}/download"
),
)
)
for folder in _bounded_rows(
db.query(FileFolder)
.filter(
FileFolder.tenant_id == tenant_id,
or_(
FileFolder.owner_user_id == subject_user_id,
FileFolder.created_by_user_id == subject_user_id,
),
)
.order_by(FileFolder.id)
):
append(
_record(
"file_folder",
folder.id,
"managed_folder",
folder.path,
{
"match_fields": _matching_fields(
folder,
subject_user_id,
("owner_user_id", "created_by_user_id"),
),
"owner_type": folder.owner_type,
"path": folder.path,
"deleted_at": _iso(folder.deleted_at),
},
observed_at=folder.updated_at,
source_path="/files",
)
)
for share in _bounded_rows(
db.query(FileShare)
.filter(
FileShare.tenant_id == tenant_id,
or_(
(FileShare.target_type == "user")
& (FileShare.target_id == subject_user_id),
FileShare.created_by_user_id == subject_user_id,
FileShare.revoked_by_user_id == subject_user_id,
),
)
.order_by(FileShare.id)
):
target_matches = (
share.target_type == "user" and share.target_id == subject_user_id
)
match_fields = _matching_fields(
share,
subject_user_id,
("created_by_user_id", "revoked_by_user_id"),
)
if target_matches:
match_fields.insert(0, "target_id")
append(
_record(
"file_share",
share.id,
"file_access_evidence",
f"Share for file {share.file_asset_id}",
{
"match_fields": match_fields,
"file_asset_id": share.file_asset_id,
"target_type": share.target_type,
"target_is_subject": target_matches,
"permission": share.permission,
"expires_at": _iso(share.expires_at),
"revoked_at": _iso(share.revoked_at),
},
observed_at=share.updated_at,
immutable=True,
retention_reason=(
"File-sharing history is institutional access evidence."
),
source_path=f"/files?file={share.file_asset_id}",
)
)
if form_evidence_available:
evidence_rows = _bounded_rows(
db.query(FileFormEvidenceGrant)
.outerjoin(
FileAsset,
FileAsset.id == FileFormEvidenceGrant.file_asset_id,
)
.filter(
FileFormEvidenceGrant.tenant_id == tenant_id,
or_(
FileFormEvidenceGrant.custodian_user_id == subject_user_id,
FileAsset.owner_user_id == subject_user_id,
),
)
.order_by(FileFormEvidenceGrant.id)
)
for grant in evidence_rows:
append(
_record(
"file_form_evidence",
grant.id,
"form_evidence",
f"Form evidence {grant.form_definition_id}",
{
"match_fields": (
["custodian_user_id"]
if grant.custodian_user_id == subject_user_id
else ["asset.owner_user_id"]
),
"form_instance_id": grant.form_instance_id,
"form_definition_id": grant.form_definition_id,
"form_definition_revision": grant.form_definition_revision,
"evidence_kind": grant.evidence_kind,
"purpose": grant.purpose,
"status": grant.status,
"expires_at": _iso(grant.expires_at),
"file_asset_id": grant.file_asset_id,
"file_version_id": grant.file_version_id,
},
observed_at=grant.updated_at,
immutable=True,
retention_reason=(
"Submitted Form attachment evidence follows the owning "
"process retention and cannot be erased through Files alone."
),
)
)
if campaign_evidence_available:
campaign_rows = _bounded_rows(
db.query(CampaignAttachmentUse)
.join(FileAsset, FileAsset.id == CampaignAttachmentUse.file_asset_id)
.filter(
CampaignAttachmentUse.tenant_id == tenant_id,
FileAsset.owner_user_id == subject_user_id,
)
.order_by(CampaignAttachmentUse.id)
)
for use in campaign_rows:
append(
_record(
"campaign_attachment_use",
use.id,
"delivery_evidence",
use.filename_used,
{
"match_fields": ["asset.owner_user_id"],
"campaign_id": use.campaign_id,
"campaign_version_id": use.campaign_version_id,
"campaign_job_id": use.campaign_job_id,
"file_asset_id": use.file_asset_id,
"file_version_id": use.file_version_id,
"filename_used": use.filename_used,
"checksum_sha256": use.checksum_sha256,
"size_bytes": use.size_bytes,
"use_stage": use.use_stage,
"used_at": _iso(use.used_at),
},
observed_at=use.updated_at,
immutable=True,
retention_reason=(
"Campaign attachment use is immutable delivery evidence."
),
)
)
self._append_configuration_references(
db,
append=append,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
)
self._append_integrity_references(
db,
append=append,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del session
subject_user_id = _subject_user_id(subject)
if subject_user_id is None:
return ()
actions: list[DsarErasureActionRef] = []
for record in records:
if record.provider_id != self.provider_id or record.module_id != self.module_id:
raise ValueError("Files DSAR received a foreign provider record.")
match_fields = {
str(value) for value in record.data.get("match_fields", ())
}
if record.immutable_evidence:
actions.append(
_action(
f"files:retain:{record.resource_type}:{record.resource_id}",
"retain",
record,
f"Retain {record.title}",
record.retention_reason
or "Institutional evidence must be retained.",
executable=False,
)
)
elif record.resource_type in {
"file_asset",
"file_version",
"file_folder",
}:
actions.append(
_action(
f"files:review:{record.resource_type}:{record.resource_id}",
"manual_review",
record,
f"Review {record.title}",
(
"Managed file content, names, paths, ownership, and shared "
"references require a case decision. Approved byte erasure "
"must use the separate Files purge workflow."
),
executable=False,
)
)
if (
record.resource_type == "file_share"
and "target_id" in match_fields
and record.data.get("revoked_at") is None
):
actions.append(
_action(
f"files:revoke:file_share:{record.resource_id}",
"revoke",
record,
"Revoke active file share",
"The active user-targeted share can be revoked without deleting file evidence.",
executable=True,
metadata={"subject_user_id": subject_user_id},
)
)
for field_name in sorted(
match_fields.intersection(_detachable_fields(record.resource_type))
):
actions.append(
_action(
(
f"files:detach:{record.resource_type}:"
f"{field_name}:{record.resource_id}"
),
"detach",
record,
f"Detach {field_name.replace('_', ' ')}",
(
"Remove the mutable subject reference while preserving the "
"governed resource and DSAR evidence."
),
executable=True,
metadata={
"field": field_name,
"subject_user_id": subject_user_id,
},
)
)
action_ids = [action.action_id for action in actions]
if len(action_ids) != len(set(action_ids)):
raise ValueError("Files DSAR produced duplicate action ids.")
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
subject_user_id = _subject_user_id(subject)
if subject_user_id is None:
return tuple(
_blocked(action, "Files requires a direct membership subject reference.")
for action in actions
)
results: list[DsarExecutionResultRef] = []
for action in actions:
if (
action.provider_id != self.provider_id
or action.module_id != self.module_id
or action.metadata.get("subject_user_id") != subject_user_id
):
results.append(_blocked(action, "The Files DSAR action is stale or invalid."))
continue
if action.action_id.startswith("files:revoke:file_share:"):
results.append(
_revoke_share(
db,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
action=action,
request_id=request_id,
)
)
elif action.action_id.startswith("files:detach:"):
results.append(
_detach_reference(
db,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
action=action,
request_id=request_id,
)
)
else:
results.append(_blocked(action, "Files does not execute this action kind."))
db.flush()
return tuple(results)
def _append_configuration_references(
self,
db: Session,
*,
append: object,
tenant_id: str,
subject_user_id: str,
) -> None:
configurations = (
(FileConnectorProfile, "connector_profile", ("created_by_user_id", "updated_by_user_id")),
(FileConnectorCredential, "connector_credential", ("created_by_user_id", "updated_by_user_id")),
(FileConnectorPolicy, "connector_policy", ("created_by_user_id", "updated_by_user_id")),
(FileConnectorSpace, "connector_space", ("owner_user_id", "created_by_user_id")),
)
for model, resource_type, fields in configurations:
if not _has_table(db, model):
continue
conditions = [getattr(model, field) == subject_user_id for field in fields]
query = db.query(model).filter(or_(*conditions))
if hasattr(model, "tenant_id"):
query = query.filter(model.tenant_id == tenant_id)
for row in _bounded_rows(query.order_by(model.id)):
match_fields = _matching_fields(row, subject_user_id, fields)
data: dict[str, object] = {
"match_fields": match_fields,
"label": getattr(row, "label", None),
"provider": getattr(row, "provider", None),
}
if isinstance(row, FileConnectorCredential):
data["credential_mode"] = row.credential_mode
elif isinstance(row, FileConnectorSpace):
data.update(
{
"remote_path": row.remote_path,
"sync_mode": row.sync_mode,
"read_only": row.read_only,
"deleted_at": _iso(row.deleted_at),
}
)
append( # type: ignore[operator]
_record(
resource_type,
row.id,
"connector_configuration_evidence",
getattr(row, "label", None) or resource_type.replace("_", " "),
data,
observed_at=row.updated_at,
immutable=True,
retention_reason=(
"Connector configuration history is institutional evidence; "
"credential secrets are excluded from the DSAR export."
),
)
)
def _append_integrity_references(
self,
db: Session,
*,
append: object,
tenant_id: str,
subject_user_id: str,
) -> None:
if _has_table(db, FileIntegrityScan):
for scan in _bounded_rows(
db.query(FileIntegrityScan)
.filter(
FileIntegrityScan.tenant_id == tenant_id,
FileIntegrityScan.created_by_user_id == subject_user_id,
)
.order_by(FileIntegrityScan.id)
):
append( # type: ignore[operator]
_record(
"file_integrity_scan",
scan.id,
"storage_integrity_evidence",
f"Integrity scan {scan.id}",
{
"match_fields": ["created_by_user_id"],
"storage_backend": scan.storage_backend,
"status": scan.status,
"started_at": _iso(scan.started_at),
"completed_at": _iso(scan.completed_at),
},
observed_at=scan.updated_at,
immutable=True,
retention_reason="Storage integrity scans are operator evidence.",
)
)
if _has_table(db, FileIntegrityFinding):
for finding in _bounded_rows(
db.query(FileIntegrityFinding)
.filter(
FileIntegrityFinding.tenant_id == tenant_id,
FileIntegrityFinding.resolved_by_user_id == subject_user_id,
)
.order_by(FileIntegrityFinding.id)
):
append( # type: ignore[operator]
_record(
"file_integrity_finding",
finding.id,
"storage_integrity_evidence",
f"Integrity finding {finding.kind}",
{
"match_fields": ["resolved_by_user_id"],
"kind": finding.kind,
"state": finding.state,
"resolved_at": _iso(finding.resolved_at),
},
observed_at=finding.updated_at,
immutable=True,
retention_reason="Integrity resolution is operator evidence.",
)
)
def _asset_retention_reason(
session: Session,
asset: FileAsset,
*,
form_evidence_available: bool,
campaign_evidence_available: bool,
) -> str | None:
reasons: list[str] = []
if asset.legal_hold:
reasons.append("The file is under legal hold.")
retained_until = _aware(asset.retained_until)
if retained_until is not None and retained_until > datetime.now(timezone.utc):
reasons.append(f"The file is retained until {retained_until.isoformat()}.")
if form_evidence_available:
has_form_evidence = (
session.query(FileFormEvidenceGrant.id)
.filter(
FileFormEvidenceGrant.tenant_id == asset.tenant_id,
FileFormEvidenceGrant.file_asset_id == asset.id,
)
.first()
is not None
)
if has_form_evidence:
reasons.append("The file is referenced by submitted Form evidence.")
if campaign_evidence_available:
has_campaign_evidence = (
session.query(CampaignAttachmentUse.id)
.filter(
CampaignAttachmentUse.tenant_id == asset.tenant_id,
CampaignAttachmentUse.file_asset_id == asset.id,
)
.first()
is not None
)
if has_campaign_evidence:
reasons.append("The file is referenced by Campaign delivery evidence.")
return " ".join(reasons) or None
_DETACHABLE_MODELS: dict[str, tuple[type[object], frozenset[str]]] = {
"file_asset": (FileAsset, frozenset({"created_by_user_id"})),
"file_version": (FileVersion, frozenset({"created_by_user_id"})),
"file_folder": (FileFolder, frozenset({"created_by_user_id"})),
"file_share": (
FileShare,
frozenset({"created_by_user_id", "revoked_by_user_id"}),
),
"connector_profile": (
FileConnectorProfile,
frozenset({"created_by_user_id", "updated_by_user_id"}),
),
"connector_credential": (
FileConnectorCredential,
frozenset({"created_by_user_id", "updated_by_user_id"}),
),
"connector_policy": (
FileConnectorPolicy,
frozenset({"created_by_user_id", "updated_by_user_id"}),
),
"connector_space": (FileConnectorSpace, frozenset({"created_by_user_id"})),
}
def _detachable_fields(resource_type: str) -> frozenset[str]:
entry = _DETACHABLE_MODELS.get(resource_type)
return entry[1] if entry is not None else frozenset()
def _revoke_share(
session: Session,
*,
tenant_id: str,
subject_user_id: str,
action: DsarErasureActionRef,
request_id: str,
) -> DsarExecutionResultRef:
row = (
session.query(FileShare)
.filter(FileShare.id == action.resource_id)
.with_for_update()
.one_or_none()
)
if (
row is None
or row.tenant_id != tenant_id
or row.target_type != "user"
or row.target_id != subject_user_id
):
return _blocked(action, "The subject-targeted file share is no longer available.")
if row.revoked_at is not None:
return _result(
action,
"unchanged",
"The file share was already revoked.",
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
)
row.revoked_at = datetime.now(timezone.utc)
row.revoked_by_user_id = None
return _result(
action,
"executed",
"The subject-targeted file share was revoked.",
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
)
def _detach_reference(
session: Session,
*,
tenant_id: str,
subject_user_id: str,
action: DsarErasureActionRef,
request_id: str,
) -> DsarExecutionResultRef:
entry = _DETACHABLE_MODELS.get(action.resource_type)
field_name = str(action.metadata.get("field") or "")
if entry is None or field_name not in entry[1]:
return _blocked(action, "The requested Files subject reference is not detachable.")
model = entry[0]
row = (
session.query(model)
.filter(getattr(model, "id") == action.resource_id)
.with_for_update()
.one_or_none()
)
if row is None or getattr(row, "tenant_id", None) != tenant_id:
return _blocked(action, "The Files resource is no longer available.")
current = getattr(row, field_name)
if current is None:
return _result(
action,
"unchanged",
"The subject reference was already detached.",
{"request_id": request_id, "field": field_name},
)
if current != subject_user_id:
return _blocked(action, "The Files subject reference changed after planning.")
setattr(row, field_name, None)
return _result(
action,
"executed",
"The mutable subject reference was detached.",
{"request_id": request_id, "field": field_name},
)
def _subject_user_id(subject: DsarSubjectRef) -> str | None:
candidates: list[str] = []
if subject.membership_id:
candidates.append(subject.membership_id)
for key, value in subject.external_references.items():
if key in {
"files.user",
"files.membership",
"access.membership",
"membership_id",
}:
candidates.append(value)
normalized = {value.strip() for value in candidates if value.strip()}
if len(normalized) != 1:
return None
return normalized.pop()
def _matching_fields(
row: object,
subject_user_id: str,
fields: Sequence[str],
) -> list[str]:
return [field for field in fields if getattr(row, field) == subject_user_id]
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="files",
module_id="files",
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,
metadata: dict[str, object] | None = None,
) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=action_id,
provider_id="files",
module_id="files",
kind=kind, # type: ignore[arg-type]
resource_type=record.resource_type,
resource_id=record.resource_id,
title=title,
rationale=rationale,
executable=executable,
metadata=metadata or {},
)
def _result(
action: DsarErasureActionRef,
status: str,
summary: str,
evidence: dict[str, object] | None = None,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status=status, # type: ignore[arg-type]
summary=summary,
evidence=evidence or {},
)
def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef:
return _result(action, "blocked", summary)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Files DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_rows(query: object) -> list[object]:
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
if len(rows) > _MAX_RECORDS:
raise ValueError("Files DSAR match limit exceeded; narrow the subject selectors.")
return rows
def _has_table(session: Session, model: type[object]) -> bool:
return inspect(session.connection()).has_table(model.__tablename__)
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
__all__ = ["FILES_DSAR_CAPABILITY", "FilesDsarProvider"]
+78
View File
@@ -54,6 +54,7 @@ from govoplan_files.backend.configuration_provider import (
)
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
from govoplan_files.backend.documentation import documentation_topics
from govoplan_files.backend.dsar_provider import FILES_DSAR_CAPABILITY
from govoplan_files.backend.form_evidence import (
CAPABILITY_FORM_EVIDENCE_FILES,
create_files_form_evidence_provider,
@@ -440,6 +441,13 @@ REMOTE_STORAGE_PROVIDER = ExternalProviderDeclaration(
)
def _dsar_provider(context: ModuleContext) -> object:
del context
from govoplan_files.backend.dsar_provider import FilesDsarProvider
return FilesDsarProvider()
manifest = ModuleManifest(
id="files",
name="Files",
@@ -456,6 +464,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name=CAPABILITY_FILES_POSTBOX_REFERENCES, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_FILES, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_FORM_EVIDENCE_FILES, version="1.0.0"),
ModuleInterfaceProvider(name=FILES_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -1180,6 +1189,69 @@ manifest = ModuleManifest(
],
},
),
DocumentationTopic(
id="files.privacy.data-subject-requests",
title="Review Files data in a data-subject request",
summary="Collect safe Files metadata and keep retention, evidence, and byte-erasure decisions explicit.",
body=(
"The Files DSAR provider searches only the effective tenant and requires a direct membership or namespaced Files user reference. "
"It exports bounded file, version, folder, sharing, evidence, connector-configuration, and integrity metadata without raw file bytes, storage locations, tokens, passwords, secret references, or encrypted credential values. "
"Plans may revoke an active share aimed at the subject or detach a mutable actor reference. Legal hold, active retention, Form evidence, Campaign delivery evidence, configuration history, and integrity evidence remain retained with a reason. File content, ownership, names, and paths require manual review. Approved physical erasure must use the separately authorized Files purge and blob-garbage-collection workflow so DSAR execution cannot bypass evidence blockers, approval, audit, or recovery controls."
),
layer="configured",
documentation_types=("admin",),
audience=("privacy_officer", "file_admin", "records_manager", "operator"),
order=47,
conditions=(
DocumentationCondition(
required_modules=("files", "access"),
any_scopes=(
"access:privacy:read",
"access:privacy:manage",
"access:privacy:erase",
),
),
),
links=(
DocumentationLink(
label="Data-subject requests",
href="/admin?section=tenant-data-subject-requests",
kind="runtime",
),
DocumentationLink(
label="Files handbook",
href="govoplan-files/docs/FILES_HANDBOOK.md",
kind="repository",
),
),
related_modules=("access", "audit", "campaigns", "forms-runtime", "ops"),
metadata={
"kind": "workflow",
"route": "/admin?section=tenant-data-subject-requests",
"screen": "Data-subject requests",
"help_contexts": ["admin.privacy.data-subject-requests"],
"prerequisites": [
"The request has been authorized and contains a direct tenant membership or Files subject reference.",
"The privacy reviewer can distinguish access export from erasure authority and Files purge authority.",
],
"steps": [
"Run the provider search and confirm Files reports complete coverage rather than a failed or absent provider.",
"Review file/version metadata, evidence retention reasons, and the source path for manual content review.",
"Generate the erasure plan and execute only the approved reversible share-revocation or subject-reference actions.",
"For approved byte erasure, resolve every lifecycle blocker and use Files purge preview, execution, and blob garbage collection separately.",
],
"limitations": [
"Email, account, or identity selectors alone cannot be resolved by Files because Files does not own the Access directory; supply the corroborated membership reference.",
"The provider does not embed raw file content in the JSON export and never performs physical blob deletion as a DSAR side effect.",
],
"outcome": "Files-owned subject references are reviewed or removed without silently destroying retained content or evidence.",
"verification": "Confirm every Files record has a retain, review, revoke, or detach disposition and inspect any separate purge through its audit and recovery evidence.",
"related_topic_ids": [
"files.workflow.restore-retain-and-purge",
"files.reference.integrity-recovery-and-fail-closed-transports",
],
},
),
DocumentationTopic(
id="files.governed-connectors-and-provenance",
title="Govern file connections and credential deletion",
@@ -1635,6 +1707,7 @@ manifest = ModuleManifest(
).campaign_capability(context),
CAPABILITY_RECORD_SOURCE_FILES: create_files_record_source,
CAPABILITY_FORM_EVIDENCE_FILES: create_files_form_evidence_provider,
FILES_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_RECORD_SOURCE_FILES: CapabilityDocumentation(
@@ -1647,6 +1720,11 @@ manifest = ModuleManifest(
summary="Issues one-time managed attachment grants and verifies exact Form evidence versions.",
contract_version="1.0.0",
),
FILES_DSAR_CAPABILITY: CapabilityDocumentation(
label="Files data-subject request provider",
summary="Finds safe Files metadata and classifies reversible references, manual file review, and retained evidence.",
contract_version="0.1.0",
),
},
operational_check_providers=(
OperationalCheckProviderRegistration(