feat(views): add governed DSAR coverage
This commit is contained in:
@@ -0,0 +1,834 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_views.backend.db.models import (
|
||||
ViewAssignment,
|
||||
ViewDefinition,
|
||||
ViewPreference,
|
||||
ViewRevision,
|
||||
)
|
||||
|
||||
|
||||
VIEWS_DSAR_CAPABILITY = dsar_capability_name("views")
|
||||
_MAX_RECORDS = 5_000
|
||||
_MAX_REVISIONS_PER_DEFINITION = 1_000
|
||||
_MAX_PERSONAL_REVISIONS = 5_000
|
||||
_CONFLICT = object()
|
||||
|
||||
_ATTRIBUTION_TYPES = frozenset(
|
||||
{
|
||||
"view_definition_attribution",
|
||||
"view_revision_attribution",
|
||||
"view_assignment_attribution",
|
||||
}
|
||||
)
|
||||
_PERSONAL_TYPES = frozenset(
|
||||
{
|
||||
"personal_view_assignment",
|
||||
"personal_view_preference",
|
||||
"personal_view_definition",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
account_id: str
|
||||
definition_id: str | None
|
||||
assignment_id: str | None
|
||||
preference_id: str | None
|
||||
|
||||
|
||||
class ViewsDsarProvider:
|
||||
provider_id = "views"
|
||||
module_id = "views"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
personal_revision_count = 0
|
||||
if selectors.preference_id is None and selectors.assignment_id is None:
|
||||
definitions = db.query(ViewDefinition).filter(
|
||||
ViewDefinition.tenant_id == tenant_id,
|
||||
ViewDefinition.scope_type == "user",
|
||||
ViewDefinition.scope_id == selectors.account_id,
|
||||
)
|
||||
if selectors.definition_id:
|
||||
definitions = definitions.filter(
|
||||
ViewDefinition.id == selectors.definition_id
|
||||
)
|
||||
for definition in _limited(definitions, label="personal definitions"):
|
||||
record = _personal_definition_record(db, definition)
|
||||
revisions = record.data["revisions"]
|
||||
personal_revision_count += len(revisions) # type: ignore[arg-type]
|
||||
if personal_revision_count > _MAX_PERSONAL_REVISIONS:
|
||||
raise ValueError(
|
||||
"Views DSAR personal revision limit exceeded; narrow the selectors."
|
||||
)
|
||||
records.append(record)
|
||||
|
||||
if selectors.preference_id is None:
|
||||
assignments = db.query(ViewAssignment).filter(
|
||||
ViewAssignment.tenant_id == tenant_id,
|
||||
ViewAssignment.scope_type == "user",
|
||||
ViewAssignment.scope_id == selectors.account_id,
|
||||
)
|
||||
if selectors.assignment_id:
|
||||
assignments = assignments.filter(
|
||||
ViewAssignment.id == selectors.assignment_id
|
||||
)
|
||||
if selectors.definition_id:
|
||||
assignments = assignments.filter(
|
||||
ViewAssignment.definition_id == selectors.definition_id
|
||||
)
|
||||
for assignment in _limited(assignments, label="personal assignments"):
|
||||
records.append(_personal_assignment_record(assignment))
|
||||
|
||||
if selectors.assignment_id is None:
|
||||
preferences = db.query(ViewPreference).filter(
|
||||
ViewPreference.tenant_id == tenant_id,
|
||||
ViewPreference.account_id == selectors.account_id,
|
||||
)
|
||||
if selectors.preference_id:
|
||||
preferences = preferences.filter(
|
||||
ViewPreference.id == selectors.preference_id
|
||||
)
|
||||
if selectors.definition_id:
|
||||
preferences = preferences.filter(
|
||||
ViewPreference.view_id == selectors.definition_id
|
||||
)
|
||||
for preference in _limited(preferences, label="personal preferences"):
|
||||
records.append(_personal_preference_record(preference))
|
||||
|
||||
if selectors.preference_id is None and selectors.assignment_id is None:
|
||||
definitions = db.query(ViewDefinition).filter(
|
||||
ViewDefinition.tenant_id == tenant_id,
|
||||
or_(
|
||||
ViewDefinition.scope_type != "user",
|
||||
ViewDefinition.scope_id != selectors.account_id,
|
||||
),
|
||||
or_(
|
||||
ViewDefinition.created_by == selectors.account_id,
|
||||
ViewDefinition.updated_by == selectors.account_id,
|
||||
),
|
||||
)
|
||||
if selectors.definition_id:
|
||||
definitions = definitions.filter(
|
||||
ViewDefinition.id == selectors.definition_id
|
||||
)
|
||||
for definition in _limited(
|
||||
definitions,
|
||||
label="definition attribution",
|
||||
):
|
||||
records.append(
|
||||
_definition_attribution_record(definition, selectors.account_id)
|
||||
)
|
||||
|
||||
revisions = (
|
||||
db.query(ViewRevision)
|
||||
.join(ViewDefinition, ViewRevision.definition_id == ViewDefinition.id)
|
||||
.filter(
|
||||
ViewRevision.tenant_id == tenant_id,
|
||||
ViewRevision.created_by == selectors.account_id,
|
||||
or_(
|
||||
ViewDefinition.scope_type != "user",
|
||||
ViewDefinition.scope_id != selectors.account_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
if selectors.definition_id:
|
||||
revisions = revisions.filter(
|
||||
ViewRevision.definition_id == selectors.definition_id
|
||||
)
|
||||
for revision in _limited(revisions, label="revision attribution"):
|
||||
records.append(_revision_attribution_record(revision))
|
||||
|
||||
if selectors.preference_id is None:
|
||||
assignments = db.query(ViewAssignment).filter(
|
||||
ViewAssignment.tenant_id == tenant_id,
|
||||
or_(
|
||||
ViewAssignment.scope_type != "user",
|
||||
ViewAssignment.scope_id != selectors.account_id,
|
||||
),
|
||||
or_(
|
||||
ViewAssignment.created_by == selectors.account_id,
|
||||
ViewAssignment.updated_by == selectors.account_id,
|
||||
),
|
||||
)
|
||||
if selectors.assignment_id:
|
||||
assignments = assignments.filter(
|
||||
ViewAssignment.id == selectors.assignment_id
|
||||
)
|
||||
if selectors.definition_id:
|
||||
assignments = assignments.filter(
|
||||
ViewAssignment.definition_id == selectors.definition_id
|
||||
)
|
||||
for assignment in _limited(
|
||||
assignments,
|
||||
label="assignment attribution",
|
||||
):
|
||||
records.append(
|
||||
_assignment_attribution_record(assignment, selectors.account_id)
|
||||
)
|
||||
|
||||
if len(records) > _MAX_RECORDS:
|
||||
raise ValueError("Views DSAR result limit exceeded; narrow the selectors.")
|
||||
order = {
|
||||
"personal_view_assignment": 10,
|
||||
"personal_view_preference": 20,
|
||||
"personal_view_definition": 30,
|
||||
"view_assignment_attribution": 40,
|
||||
"view_definition_attribution": 50,
|
||||
"view_revision_attribution": 60,
|
||||
}
|
||||
return tuple(
|
||||
sorted(
|
||||
records,
|
||||
key=lambda item: (order[item.resource_type], item.resource_id),
|
||||
)
|
||||
)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
raise ValueError("Views DSAR requires one corroborated account.")
|
||||
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
if record.resource_type in _ATTRIBUTION_TYPES:
|
||||
actions.append(_retain_action(record))
|
||||
continue
|
||||
row = _personal_row(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
account_id=selectors.account_id,
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
)
|
||||
if row is None or not _row_matches_selectors(row, selectors):
|
||||
actions.append(_manual_action(record, "ownership or selector mismatch"))
|
||||
continue
|
||||
if isinstance(row, ViewDefinition) and _has_foreign_dependents(
|
||||
db,
|
||||
row,
|
||||
account_id=selectors.account_id,
|
||||
):
|
||||
actions.append(
|
||||
_manual_action(
|
||||
record,
|
||||
"the personal definition has assignments or selections owned by another account",
|
||||
)
|
||||
)
|
||||
continue
|
||||
version = _row_version(row)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"views:delete:{record.resource_type}:{record.resource_id}:"
|
||||
f"{version['token']}"
|
||||
),
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="delete",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Delete {record.title}",
|
||||
rationale=(
|
||||
"The record is an exact account-owned presentation "
|
||||
"preference; deleting it does not change domain data."
|
||||
),
|
||||
executable=True,
|
||||
irreversible=True,
|
||||
metadata={"account_id": selectors.account_id, **version},
|
||||
)
|
||||
)
|
||||
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)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
raise ValueError("Views DSAR requires one corroborated account.")
|
||||
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if not action.executable or action.kind != "delete":
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Institutional View configuration attribution remains "
|
||||
"accountability evidence."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if action.resource_type not in _PERSONAL_TYPES:
|
||||
raise ValueError("Unsupported executable Views DSAR action.")
|
||||
|
||||
row = _personal_row(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
account_id=selectors.account_id,
|
||||
resource_type=action.resource_type,
|
||||
resource_id=action.resource_id,
|
||||
)
|
||||
if row is None:
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="unchanged",
|
||||
summary="The personal View record was already absent.",
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
continue
|
||||
if str(
|
||||
action.metadata.get("account_id") or ""
|
||||
) != selectors.account_id or not _row_matches_selectors(row, selectors):
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"The View record does not match the corroborated "
|
||||
"account and resource selectors."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not _version_matches(row, action.metadata):
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"The View record changed after the erasure plan; "
|
||||
"create a new plan before deleting it."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if isinstance(row, ViewDefinition) and _has_foreign_dependents(
|
||||
db,
|
||||
row,
|
||||
account_id=selectors.account_id,
|
||||
):
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"The personal View now affects another account; "
|
||||
"review its assignments and selections manually."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
resource_id = str(row.id)
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="executed",
|
||||
summary=(
|
||||
"Deleted the personal View record without changing any "
|
||||
"institutional View or domain data."
|
||||
),
|
||||
evidence={
|
||||
"request_id": request_id,
|
||||
"resource_type": action.resource_type,
|
||||
"resource_id": resource_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
references = subject.external_references
|
||||
values = {
|
||||
"account_id": _coalesce(
|
||||
subject.account_id,
|
||||
references.get("views.account"),
|
||||
references.get("access.account"),
|
||||
),
|
||||
"definition_id": _coalesce(
|
||||
references.get("views.definition"),
|
||||
references.get("views.view"),
|
||||
),
|
||||
"assignment_id": _coalesce(
|
||||
references.get("views.assignment"),
|
||||
references.get("views.assignment_id"),
|
||||
),
|
||||
"preference_id": _coalesce(
|
||||
references.get("views.preference"),
|
||||
references.get("views.preference_id"),
|
||||
),
|
||||
}
|
||||
if any(value is _CONFLICT for value in values.values()):
|
||||
return None
|
||||
account_id = _optional_string(values["account_id"])
|
||||
if account_id is None:
|
||||
return None
|
||||
return _SubjectSelectors(
|
||||
account_id=account_id,
|
||||
definition_id=_optional_string(values["definition_id"]),
|
||||
assignment_id=_optional_string(values["assignment_id"]),
|
||||
preference_id=_optional_string(values["preference_id"]),
|
||||
)
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _limited(query, *, label: str):
|
||||
rows = (
|
||||
query.order_by(query.column_descriptions[0]["entity"].id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(f"Views DSAR {label} limit exceeded; narrow the selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
def _personal_definition_record(
|
||||
session: Session,
|
||||
definition: ViewDefinition,
|
||||
) -> DsarRecordRef:
|
||||
revisions = (
|
||||
session.query(ViewRevision)
|
||||
.filter(ViewRevision.definition_id == definition.id)
|
||||
.order_by(ViewRevision.revision, ViewRevision.id)
|
||||
.limit(_MAX_REVISIONS_PER_DEFINITION + 1)
|
||||
.all()
|
||||
)
|
||||
if len(revisions) > _MAX_REVISIONS_PER_DEFINITION:
|
||||
raise ValueError(
|
||||
"Views DSAR personal definition revision limit exceeded; narrow the selectors."
|
||||
)
|
||||
return DsarRecordRef(
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
resource_type="personal_view_definition",
|
||||
resource_id=definition.id,
|
||||
category="personal_interface_preference",
|
||||
title=f"Personal View: {definition.name[:200]}",
|
||||
data={
|
||||
"definition_key": definition.definition_key,
|
||||
"name": definition.name[:200],
|
||||
"description": (definition.description or "")[:2_000] or None,
|
||||
"status": definition.status,
|
||||
"current_revision": definition.current_revision,
|
||||
"published_revision_id": definition.published_revision_id,
|
||||
"deleted_at": _iso(definition.deleted_at),
|
||||
"revisions": [
|
||||
{
|
||||
"id": revision.id,
|
||||
"revision": revision.revision,
|
||||
"surface_contract_version": revision.surface_contract_version,
|
||||
"visible_surface_ids": _string_list(
|
||||
revision.visible_surface_ids,
|
||||
limit=1_000,
|
||||
),
|
||||
"presentation": _presentation_projection(revision.presentation),
|
||||
"created_at": _iso(revision.created_at),
|
||||
}
|
||||
for revision in revisions
|
||||
],
|
||||
},
|
||||
observed_at=_aware(definition.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def _personal_assignment_record(assignment: ViewAssignment) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
resource_type="personal_view_assignment",
|
||||
resource_id=assignment.id,
|
||||
category="personal_interface_preference",
|
||||
title="Personal View assignment",
|
||||
data={
|
||||
"definition_id": assignment.definition_id,
|
||||
"revision_id": assignment.revision_id,
|
||||
"mode": assignment.mode,
|
||||
"priority": assignment.priority,
|
||||
"is_active": assignment.is_active,
|
||||
},
|
||||
observed_at=_aware(assignment.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def _personal_preference_record(preference: ViewPreference) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
resource_type="personal_view_preference",
|
||||
resource_id=preference.id,
|
||||
category="personal_interface_preference",
|
||||
title="Personal active-View selection",
|
||||
data={
|
||||
"selection_kind": preference.selection_kind,
|
||||
"view_id": preference.view_id,
|
||||
},
|
||||
observed_at=_aware(preference.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def _definition_attribution_record(
|
||||
definition: ViewDefinition,
|
||||
account_id: str,
|
||||
) -> DsarRecordRef:
|
||||
activities = []
|
||||
if definition.created_by == account_id:
|
||||
activities.append("created_view_definition")
|
||||
if definition.updated_by == account_id:
|
||||
activities.append("updated_view_definition")
|
||||
return _attribution_record(
|
||||
"view_definition_attribution",
|
||||
definition.id,
|
||||
"View-definition attribution",
|
||||
{
|
||||
"activities": activities,
|
||||
"scope_type": definition.scope_type,
|
||||
"current_revision": definition.current_revision,
|
||||
"status": definition.status,
|
||||
},
|
||||
observed_at=definition.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _revision_attribution_record(revision: ViewRevision) -> DsarRecordRef:
|
||||
return _attribution_record(
|
||||
"view_revision_attribution",
|
||||
revision.id,
|
||||
"View-revision attribution",
|
||||
{
|
||||
"activity": "created_view_revision",
|
||||
"definition_id": revision.definition_id,
|
||||
"revision": revision.revision,
|
||||
"surface_contract_version": revision.surface_contract_version,
|
||||
},
|
||||
observed_at=revision.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _assignment_attribution_record(
|
||||
assignment: ViewAssignment,
|
||||
account_id: str,
|
||||
) -> DsarRecordRef:
|
||||
activities = []
|
||||
if assignment.created_by == account_id:
|
||||
activities.append("created_view_assignment")
|
||||
if assignment.updated_by == account_id:
|
||||
activities.append("updated_view_assignment")
|
||||
return _attribution_record(
|
||||
"view_assignment_attribution",
|
||||
assignment.id,
|
||||
"View-assignment attribution",
|
||||
{
|
||||
"activities": activities,
|
||||
"scope_type": assignment.scope_type,
|
||||
"definition_id": assignment.definition_id,
|
||||
"mode": assignment.mode,
|
||||
"is_active": assignment.is_active,
|
||||
},
|
||||
observed_at=assignment.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _attribution_record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
title: str,
|
||||
data: Mapping[str, object],
|
||||
*,
|
||||
observed_at: datetime | None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category="operator_accountability_evidence",
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=_aware(observed_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Institutional View configuration attribution is retained for "
|
||||
"accountability; definition, presentation, and metadata payloads are excluded."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _personal_row(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
) -> ViewAssignment | ViewPreference | ViewDefinition | None:
|
||||
if resource_type == "personal_view_assignment":
|
||||
return (
|
||||
session.query(ViewAssignment)
|
||||
.filter(
|
||||
ViewAssignment.id == resource_id,
|
||||
ViewAssignment.tenant_id == tenant_id,
|
||||
ViewAssignment.scope_type == "user",
|
||||
ViewAssignment.scope_id == account_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if resource_type == "personal_view_preference":
|
||||
return (
|
||||
session.query(ViewPreference)
|
||||
.filter(
|
||||
ViewPreference.id == resource_id,
|
||||
ViewPreference.tenant_id == tenant_id,
|
||||
ViewPreference.account_id == account_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if resource_type == "personal_view_definition":
|
||||
return (
|
||||
session.query(ViewDefinition)
|
||||
.filter(
|
||||
ViewDefinition.id == resource_id,
|
||||
ViewDefinition.tenant_id == tenant_id,
|
||||
ViewDefinition.scope_type == "user",
|
||||
ViewDefinition.scope_id == account_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
raise ValueError("Unsupported personal Views DSAR record type.")
|
||||
|
||||
|
||||
def _row_matches_selectors(
|
||||
row: ViewAssignment | ViewPreference | ViewDefinition,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> bool:
|
||||
if selectors.assignment_id and (
|
||||
not isinstance(row, ViewAssignment) or row.id != selectors.assignment_id
|
||||
):
|
||||
return False
|
||||
if selectors.preference_id and (
|
||||
not isinstance(row, ViewPreference) or row.id != selectors.preference_id
|
||||
):
|
||||
return False
|
||||
if selectors.definition_id:
|
||||
if isinstance(row, ViewDefinition):
|
||||
return row.id == selectors.definition_id
|
||||
return (
|
||||
row.view_id == selectors.definition_id
|
||||
if isinstance(row, ViewPreference)
|
||||
else row.definition_id == selectors.definition_id
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _has_foreign_dependents(
|
||||
session: Session,
|
||||
definition: ViewDefinition,
|
||||
*,
|
||||
account_id: str,
|
||||
) -> bool:
|
||||
foreign_assignment = (
|
||||
session.query(ViewAssignment.id)
|
||||
.filter(
|
||||
ViewAssignment.definition_id == definition.id,
|
||||
or_(
|
||||
ViewAssignment.tenant_id != definition.tenant_id,
|
||||
ViewAssignment.scope_type != "user",
|
||||
ViewAssignment.scope_id != account_id,
|
||||
),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
foreign_preference = (
|
||||
session.query(ViewPreference.id)
|
||||
.filter(
|
||||
ViewPreference.view_id == definition.id,
|
||||
or_(
|
||||
ViewPreference.tenant_id != definition.tenant_id,
|
||||
ViewPreference.account_id != account_id,
|
||||
),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return foreign_assignment is not None or foreign_preference is not None
|
||||
|
||||
|
||||
def _row_version(
|
||||
row: ViewAssignment | ViewPreference | ViewDefinition,
|
||||
) -> dict[str, object]:
|
||||
updated_at = _iso(row.updated_at)
|
||||
if isinstance(row, ViewDefinition):
|
||||
return {
|
||||
"token": f"r{row.current_revision}",
|
||||
"current_revision": row.current_revision,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
return {"token": "timestamp", "updated_at": updated_at}
|
||||
|
||||
|
||||
def _version_matches(
|
||||
row: ViewAssignment | ViewPreference | ViewDefinition,
|
||||
metadata: Mapping[str, object],
|
||||
) -> bool:
|
||||
if metadata.get("updated_at") != _iso(row.updated_at):
|
||||
return False
|
||||
if isinstance(row, ViewDefinition):
|
||||
return metadata.get("current_revision") == row.current_revision
|
||||
return True
|
||||
|
||||
|
||||
def _presentation_projection(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("View presentation must be an object.")
|
||||
projected: dict[str, object] = {}
|
||||
mode = value.get("navigation_mode")
|
||||
if isinstance(mode, str):
|
||||
projected["navigation_mode"] = mode[:20]
|
||||
for key in (
|
||||
"product_area_order",
|
||||
"quick_access_recommended_tool_ids",
|
||||
"quick_access_focused_tool_ids",
|
||||
):
|
||||
raw = value.get(key)
|
||||
if raw is not None:
|
||||
projected[key] = _string_list(raw, limit=100)
|
||||
labels = value.get("product_area_labels")
|
||||
if labels is not None:
|
||||
if not isinstance(labels, Mapping) or len(labels) > 100:
|
||||
raise ValueError("View presentation labels exceed the DSAR bound.")
|
||||
projected["product_area_labels"] = {
|
||||
str(key)[:80]: str(label)[:200] for key, label in labels.items()
|
||||
}
|
||||
return projected
|
||||
|
||||
|
||||
def _string_list(value: object, *, limit: int) -> list[str]:
|
||||
if not isinstance(value, list) or len(value) > limit:
|
||||
raise ValueError("View list payload exceeds the DSAR bound.")
|
||||
return [str(item)[:255] for item in value]
|
||||
|
||||
|
||||
def _retain_action(record: DsarRecordRef) -> DsarErasureActionRef:
|
||||
return DsarErasureActionRef(
|
||||
action_id=f"views:retain:{record.resource_type}:{record.resource_id}",
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
kind="retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Retain {record.title}",
|
||||
rationale=record.retention_reason
|
||||
or "Institutional View attribution is accountability evidence.",
|
||||
executable=False,
|
||||
)
|
||||
|
||||
|
||||
def _manual_action(record: DsarRecordRef, reason: str) -> DsarErasureActionRef:
|
||||
return DsarErasureActionRef(
|
||||
action_id=f"views:manual_review:{record.resource_type}:{record.resource_id}",
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
kind="manual_review",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Review {record.title}",
|
||||
rationale=f"Automatic deletion stopped because of {reason}.",
|
||||
executable=False,
|
||||
)
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
aware = _aware(value)
|
||||
return aware.isoformat() if aware else None
|
||||
|
||||
|
||||
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 _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Views DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "views" or record.module_id != "views":
|
||||
raise ValueError("Views DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type not in _PERSONAL_TYPES | _ATTRIBUTION_TYPES:
|
||||
raise ValueError("Views DSAR record type is invalid.")
|
||||
if not record.resource_id:
|
||||
raise ValueError("Views DSAR record identity is incomplete.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "views" or action.module_id != "views":
|
||||
raise ValueError("Views DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("views:"):
|
||||
raise ValueError("Views DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["VIEWS_DSAR_CAPABILITY", "ViewsDsarProvider"]
|
||||
@@ -11,6 +11,7 @@ from govoplan_core.core.module_guards import (
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -26,6 +27,10 @@ from govoplan_core.core.policy import CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX
|
||||
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER, ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_views.backend.db import models as view_models
|
||||
from govoplan_views.backend.dsar_provider import (
|
||||
VIEWS_DSAR_CAPABILITY,
|
||||
ViewsDsarProvider,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "views"
|
||||
@@ -216,6 +221,10 @@ def _policy_impact_subjects(context: ModuleContext):
|
||||
return ViewsPolicyImpactSubjectProvider(context.registry)
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> ViewsDsarProvider:
|
||||
return ViewsDsarProvider()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
@@ -228,6 +237,7 @@ manifest = ModuleManifest(
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="views.surface_contract", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name="views.resolver", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name=VIEWS_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -298,8 +308,71 @@ manifest = ModuleManifest(
|
||||
capability_factories={
|
||||
CAPABILITY_VIEWS_RESOLVER: _resolver,
|
||||
f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}views": _policy_impact_subjects,
|
||||
VIEWS_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
VIEWS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Views data-subject request provider",
|
||||
summary=(
|
||||
"Exports and deletes account-owned View preferences while "
|
||||
"retaining minimized institutional configuration attribution."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="views.data-subject-requests",
|
||||
title="Views data-subject requests",
|
||||
summary=(
|
||||
"Export or delete personal Views, assignments, and selections "
|
||||
"without changing institutional projections or domain data."
|
||||
),
|
||||
body=(
|
||||
"Views correlates one exact account in the active tenant and can "
|
||||
"narrow the request to a definition, assignment, or preference. "
|
||||
"The access package includes bounded personal definition and "
|
||||
"revision presentation, user assignments, and the active-View "
|
||||
"selection. Arbitrary assignment metadata is excluded and View "
|
||||
"surfaces are identifiers only; the provider never traverses them "
|
||||
"into feature data. Tenant and group configuration authored or "
|
||||
"updated by the subject contributes minimized attribution only and "
|
||||
"is retained as institutional accountability evidence. System-wide "
|
||||
"Views are outside tenant-scoped requests. Erasure can delete an "
|
||||
"exact personal selection, user assignment, or user-owned definition "
|
||||
"after timestamp, revision, ownership, and dependent-record checks. "
|
||||
"A personal definition that affects another account is sent to "
|
||||
"manual review. Deleting a selection or assignment never deletes "
|
||||
"the referenced institutional View. Repeated execution is unchanged."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "tenant_admin", "operator", "auditor"),
|
||||
related_modules=("core", "access", "quick_access"),
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"views.selector",
|
||||
"views.settings.personal",
|
||||
"views.admin.tenant",
|
||||
"privacy.data-subject-requests",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"delete_selection": (
|
||||
"Removes the account selection; effective defaults apply again."
|
||||
),
|
||||
"delete_assignment": (
|
||||
"Removes only the personal assignment, not its View definition."
|
||||
),
|
||||
"delete_personal_definition": (
|
||||
"Removes the personal definition and revisions only when no "
|
||||
"other account depends on it."
|
||||
),
|
||||
"retain_attribution": (
|
||||
"Preserves minimized tenant/group configuration accountability."
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="views.interface-projections",
|
||||
title="Task-focused Views",
|
||||
@@ -443,9 +516,21 @@ manifest = ModuleManifest(
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="README.md",
|
||||
test_ref="tests/test_views.py",
|
||||
known_limits=("A View filters presentation only; modules still vary in the granularity of announced surfaces.",),
|
||||
owned_concepts=("view definition", "view revision", "view assignment", "view selection"),
|
||||
non_owned_concepts=("authorization", "module navigation", "workflow definition", "dashboard layout"),
|
||||
known_limits=(
|
||||
"A View filters presentation only; modules still vary in the granularity of announced surfaces.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"view definition",
|
||||
"view revision",
|
||||
"view assignment",
|
||||
"view selection",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"authorization",
|
||||
"module navigation",
|
||||
"workflow definition",
|
||||
"dashboard layout",
|
||||
),
|
||||
recovery_docs=("README.md",),
|
||||
security_docs=("README.md",),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user