Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c5545af66 | ||
|
|
016136f56c | ||
|
|
cc13bcf45e | ||
|
|
1a042593d5 | ||
|
|
9826d098f6 | ||
|
|
279226594c | ||
|
|
0f9d760bb0 |
@@ -55,3 +55,17 @@ Files, Campaign, Calendar, Mail, IDM, and Postbox provide native source
|
||||
adapters. Mail indexes only its bounded read-only cache, and Postbox never
|
||||
indexes ciphertext or key material. All six recheck current source-owned
|
||||
authorization when results are returned.
|
||||
|
||||
## Data-subject requests
|
||||
|
||||
Search publishes `privacy.dsar.search` for derived index documents, queued
|
||||
changes, and minimized ACL projections. Exact Search or source-module
|
||||
references locate derived copies without exporting indexed text, URLs,
|
||||
metadata, token values, hashes, cursors, queued payloads, or errors. Account,
|
||||
identity, and membership matches describe access projections only and do not
|
||||
establish ownership of source content.
|
||||
|
||||
Derived documents and queued changes can be purged idempotently. ACL-only
|
||||
matches require review at the source authority. The authoritative module must
|
||||
be corrected or erased before a rebuild; otherwise its provider may republish
|
||||
the derived Search row.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/search-webui",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-search"
|
||||
version = "0.1.17"
|
||||
version = "0.1.19"
|
||||
description = "Permission-aware global and contextual search for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.17"]
|
||||
dependencies = ["govoplan-core>=0.1.37"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN search module."""
|
||||
|
||||
__version__ = "0.1.17"
|
||||
__version__ = "0.1.19"
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import tuple_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_search.backend.db.models import (
|
||||
SearchIndexAclToken,
|
||||
SearchIndexChangeQueue,
|
||||
SearchIndexDocument,
|
||||
)
|
||||
|
||||
|
||||
SEARCH_DSAR_CAPABILITY = dsar_capability_name("search")
|
||||
_MAX_RECORDS = 5_000
|
||||
_CONFLICT = object()
|
||||
_RESERVED_REFERENCE_NAMES = frozenset(
|
||||
{
|
||||
"search.account",
|
||||
"search.identity",
|
||||
"search.membership",
|
||||
"access.account",
|
||||
"identity.id",
|
||||
"tenancy.membership",
|
||||
"search.document",
|
||||
"search.index_document",
|
||||
"search.change",
|
||||
"search.queued_change",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SourceReference:
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
account_id: str | None
|
||||
identity_id: str | None
|
||||
membership_id: str | None
|
||||
document_id: str | None
|
||||
change_id: str | None
|
||||
source_references: tuple[_SourceReference, ...]
|
||||
|
||||
@property
|
||||
def acl_tokens(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
token
|
||||
for token in (
|
||||
f"account:{self.account_id}" if self.account_id else None,
|
||||
f"identity:{self.identity_id}" if self.identity_id else None,
|
||||
f"membership:{self.membership_id}" if self.membership_id else None,
|
||||
)
|
||||
if token
|
||||
)
|
||||
|
||||
@property
|
||||
def has_direct_selector(self) -> bool:
|
||||
return bool(self.document_id or self.change_id or self.source_references)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ResourceIdentity:
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
|
||||
|
||||
class SearchDsarProvider:
|
||||
provider_id = "search"
|
||||
module_id = "search"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None or not (
|
||||
selectors.acl_tokens or selectors.has_direct_selector
|
||||
):
|
||||
return ()
|
||||
|
||||
direct = _direct_rows(db, tenant_id=tenant_id, selectors=selectors)
|
||||
if direct is None:
|
||||
return ()
|
||||
documents, changes = direct
|
||||
records: list[DsarRecordRef] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def append(record: DsarRecordRef) -> None:
|
||||
key = (record.resource_type, record.resource_id)
|
||||
if key in seen:
|
||||
return
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Search DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
seen.add(key)
|
||||
records.append(record)
|
||||
|
||||
for row in documents:
|
||||
append(_document_record(row))
|
||||
for row in changes:
|
||||
append(_change_record(row))
|
||||
for row, document in _acl_rows(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
selectors=selectors,
|
||||
direct_identities={_identity(item) for item in (*documents, *changes)},
|
||||
):
|
||||
append(_acl_record(row, document=document))
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Search DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
executable = record.resource_type in {
|
||||
"search_index_document",
|
||||
"search_index_change",
|
||||
}
|
||||
kind = "delete" if executable else "manual_review"
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"search:{kind}:{record.resource_type}:{record.resource_id}"
|
||||
),
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind=kind,
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=(
|
||||
f"Delete {record.title}"
|
||||
if executable
|
||||
else f"Review {record.title}"
|
||||
),
|
||||
rationale=(
|
||||
"The row is a derived Search cache or queued copy and can "
|
||||
"be removed without changing the authoritative source."
|
||||
if executable
|
||||
else "The ACL token is a derived authorization projection; correct or revoke authority at the source before rebuilding Search."
|
||||
),
|
||||
executable=executable,
|
||||
)
|
||||
)
|
||||
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)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Search DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if not action.executable:
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Correct the source-owned authorization before the "
|
||||
"next Search rebuild."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
continue
|
||||
model = {
|
||||
"search_index_document": SearchIndexDocument,
|
||||
"search_index_change": SearchIndexChangeQueue,
|
||||
}.get(action.resource_type)
|
||||
if model is None or action.kind != "delete":
|
||||
raise ValueError("Search DSAR executable action is not supported.")
|
||||
row = (
|
||||
db.query(model)
|
||||
.filter(
|
||||
model.tenant_id == tenant_id,
|
||||
model.id == action.resource_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
status = "unchanged"
|
||||
summary = "Derived Search row was already absent."
|
||||
else:
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
status = "executed"
|
||||
summary = (
|
||||
"Derived Search row removed; the source owner remains "
|
||||
"authoritative and must be corrected before any rebuild."
|
||||
)
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status=status,
|
||||
summary=summary,
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _direct_rows(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> tuple[list[SearchIndexDocument], list[SearchIndexChangeQueue]] | None:
|
||||
documents: dict[str, SearchIndexDocument] = {}
|
||||
changes: dict[str, SearchIndexChangeQueue] = {}
|
||||
direct_identities: list[_ResourceIdentity] = []
|
||||
source_identities: set[_ResourceIdentity] = set()
|
||||
|
||||
if selectors.document_id:
|
||||
row = (
|
||||
session.query(SearchIndexDocument)
|
||||
.filter(
|
||||
SearchIndexDocument.tenant_id == tenant_id,
|
||||
SearchIndexDocument.id == selectors.document_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
documents[row.id] = row
|
||||
direct_identities.append(_identity(row))
|
||||
|
||||
if selectors.change_id:
|
||||
row = (
|
||||
session.query(SearchIndexChangeQueue)
|
||||
.filter(
|
||||
SearchIndexChangeQueue.tenant_id == tenant_id,
|
||||
SearchIndexChangeQueue.id == selectors.change_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
changes[row.id] = row
|
||||
direct_identities.append(_identity(row))
|
||||
|
||||
if len(set(direct_identities)) > 1:
|
||||
return None
|
||||
|
||||
for reference in selectors.source_references:
|
||||
document_rows = (
|
||||
session.query(SearchIndexDocument)
|
||||
.filter(
|
||||
SearchIndexDocument.tenant_id == tenant_id,
|
||||
SearchIndexDocument.module_id == reference.module_id,
|
||||
SearchIndexDocument.resource_type == reference.resource_type,
|
||||
SearchIndexDocument.resource_id == reference.resource_id,
|
||||
)
|
||||
.order_by(SearchIndexDocument.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
change_rows = (
|
||||
session.query(SearchIndexChangeQueue)
|
||||
.filter(
|
||||
SearchIndexChangeQueue.tenant_id == tenant_id,
|
||||
SearchIndexChangeQueue.module_id == reference.module_id,
|
||||
SearchIndexChangeQueue.resource_type == reference.resource_type,
|
||||
SearchIndexChangeQueue.resource_id == reference.resource_id,
|
||||
)
|
||||
.order_by(SearchIndexChangeQueue.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if not document_rows and not change_rows:
|
||||
return None
|
||||
source_identities.update(
|
||||
_identity(row) for row in (*document_rows, *change_rows)
|
||||
)
|
||||
documents.update((row.id, row) for row in document_rows)
|
||||
changes.update((row.id, row) for row in change_rows)
|
||||
|
||||
if direct_identities and selectors.source_references:
|
||||
if not set(direct_identities).issubset(source_identities):
|
||||
return None
|
||||
if len(documents) + len(changes) > _MAX_RECORDS:
|
||||
raise ValueError("Search DSAR result limit exceeded; narrow the selectors.")
|
||||
return (
|
||||
sorted(documents.values(), key=lambda row: row.id),
|
||||
sorted(changes.values(), key=lambda row: row.id),
|
||||
)
|
||||
|
||||
|
||||
def _acl_rows(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
direct_identities: set[_ResourceIdentity],
|
||||
) -> list[tuple[SearchIndexAclToken, SearchIndexDocument]]:
|
||||
if not selectors.acl_tokens:
|
||||
return []
|
||||
query = (
|
||||
session.query(SearchIndexAclToken, SearchIndexDocument)
|
||||
.join(
|
||||
SearchIndexDocument,
|
||||
SearchIndexDocument.id == SearchIndexAclToken.document_id,
|
||||
)
|
||||
.filter(
|
||||
SearchIndexDocument.tenant_id == tenant_id,
|
||||
SearchIndexAclToken.token.in_(selectors.acl_tokens),
|
||||
)
|
||||
)
|
||||
if direct_identities:
|
||||
query = query.filter(
|
||||
tuple_(
|
||||
SearchIndexDocument.module_id,
|
||||
SearchIndexDocument.resource_type,
|
||||
SearchIndexDocument.resource_id,
|
||||
).in_(
|
||||
sorted(
|
||||
(
|
||||
identity.module_id,
|
||||
identity.resource_type,
|
||||
identity.resource_id,
|
||||
)
|
||||
for identity in direct_identities
|
||||
)
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
query.order_by(SearchIndexDocument.id, SearchIndexAclToken.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError("Search DSAR result limit exceeded; narrow the selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
def _identity(row: object) -> _ResourceIdentity:
|
||||
return _ResourceIdentity(
|
||||
module_id=str(getattr(row, "module_id")),
|
||||
resource_type=str(getattr(row, "resource_type")),
|
||||
resource_id=str(getattr(row, "resource_id")),
|
||||
)
|
||||
|
||||
|
||||
def _document_record(row: SearchIndexDocument) -> DsarRecordRef:
|
||||
return _record(
|
||||
"search_index_document",
|
||||
row.id,
|
||||
"derived_search_document",
|
||||
"Derived Search document",
|
||||
{
|
||||
"module_id": row.module_id,
|
||||
"provider_id": row.provider_id,
|
||||
"resource_type": row.resource_type,
|
||||
"resource_id": row.resource_id,
|
||||
"source_revision": row.source_revision,
|
||||
"source_updated_at": _iso(row.source_updated_at),
|
||||
"visibility": row.visibility,
|
||||
"index_version": row.index_version,
|
||||
"requires_authorization_recheck": row.requires_authorization_recheck,
|
||||
"active": row.active,
|
||||
"indexed_at": _iso(row.indexed_at),
|
||||
},
|
||||
observed_at=row.indexed_at,
|
||||
)
|
||||
|
||||
|
||||
def _change_record(row: SearchIndexChangeQueue) -> DsarRecordRef:
|
||||
return _record(
|
||||
"search_index_change",
|
||||
row.id,
|
||||
"derived_search_change",
|
||||
"Queued Search change",
|
||||
{
|
||||
"module_id": row.module_id,
|
||||
"provider_id": row.provider_id,
|
||||
"resource_type": row.resource_type,
|
||||
"resource_id": row.resource_id,
|
||||
"kind": row.kind,
|
||||
"source_revision": row.source_revision,
|
||||
"occurred_at": _iso(row.occurred_at),
|
||||
"status": row.status,
|
||||
"attempts": row.attempts,
|
||||
"available_at": _iso(row.available_at),
|
||||
"processed_at": _iso(row.processed_at),
|
||||
},
|
||||
observed_at=row.occurred_at or row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _acl_record(
|
||||
row: SearchIndexAclToken,
|
||||
*,
|
||||
document: SearchIndexDocument,
|
||||
) -> DsarRecordRef:
|
||||
return _record(
|
||||
"search_acl_projection",
|
||||
row.id,
|
||||
"derived_access_projection",
|
||||
"Search access projection",
|
||||
{
|
||||
"module_id": document.module_id,
|
||||
"resource_type": document.resource_type,
|
||||
"resource_id": document.resource_id,
|
||||
"token_kind": row.token.partition(":")[0],
|
||||
"active": document.active,
|
||||
"indexed_at": _iso(document.indexed_at),
|
||||
},
|
||||
observed_at=document.indexed_at,
|
||||
)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
references = subject.external_references
|
||||
values = {
|
||||
"account_id": _coalesce(
|
||||
subject.account_id,
|
||||
references.get("search.account"),
|
||||
references.get("access.account"),
|
||||
),
|
||||
"identity_id": _coalesce(
|
||||
subject.identity_id,
|
||||
references.get("search.identity"),
|
||||
references.get("identity.id"),
|
||||
),
|
||||
"membership_id": _coalesce(
|
||||
subject.membership_id,
|
||||
references.get("search.membership"),
|
||||
references.get("tenancy.membership"),
|
||||
),
|
||||
"document_id": _coalesce(
|
||||
references.get("search.document"),
|
||||
references.get("search.index_document"),
|
||||
),
|
||||
"change_id": _coalesce(
|
||||
references.get("search.change"),
|
||||
references.get("search.queued_change"),
|
||||
),
|
||||
}
|
||||
if any(value is _CONFLICT for value in values.values()):
|
||||
return None
|
||||
|
||||
source_references: list[_SourceReference] = []
|
||||
for key, raw_value in sorted(references.items()):
|
||||
if key in _RESERVED_REFERENCE_NAMES:
|
||||
continue
|
||||
module_id, separator, resource_type = key.partition(".")
|
||||
value = str(raw_value or "").strip()
|
||||
if (
|
||||
not separator
|
||||
or not module_id
|
||||
or module_id == "search"
|
||||
or not resource_type
|
||||
or not value
|
||||
):
|
||||
continue
|
||||
source_references.append(
|
||||
_SourceReference(
|
||||
module_id=module_id,
|
||||
resource_type=resource_type,
|
||||
resource_id=value,
|
||||
)
|
||||
)
|
||||
return _SubjectSelectors(
|
||||
account_id=_optional_string(values["account_id"]),
|
||||
identity_id=_optional_string(values["identity_id"]),
|
||||
membership_id=_optional_string(values["membership_id"]),
|
||||
document_id=_optional_string(values["document_id"]),
|
||||
change_id=_optional_string(values["change_id"]),
|
||||
source_references=tuple(source_references),
|
||||
)
|
||||
|
||||
|
||||
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 _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: dict[str, object],
|
||||
*,
|
||||
observed_at: datetime | None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="search",
|
||||
module_id="search",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data={key: value for key, value in data.items() if value is not None},
|
||||
observed_at=_aware(observed_at),
|
||||
source_path="/admin/search",
|
||||
)
|
||||
|
||||
|
||||
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("Search DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "search" or record.module_id != "search":
|
||||
raise ValueError("Search DSAR cannot plan a foreign provider record.")
|
||||
if not record.resource_type or not record.resource_id:
|
||||
raise ValueError("Search DSAR record identity is incomplete.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "search" or action.module_id != "search":
|
||||
raise ValueError("Search DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("search:"):
|
||||
raise ValueError("Search DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["SEARCH_DSAR_CAPABILITY", "SearchDsarProvider"]
|
||||
@@ -11,6 +11,8 @@ from govoplan_core.core.module_guards import (
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -30,11 +32,15 @@ from govoplan_core.core.search import (
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_search.backend.db import models as search_models
|
||||
from govoplan_search.backend.dsar_provider import (
|
||||
SEARCH_DSAR_CAPABILITY,
|
||||
SearchDsarProvider,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "search"
|
||||
MODULE_NAME = "Search"
|
||||
MODULE_VERSION = "0.1.17"
|
||||
MODULE_VERSION = "0.1.19"
|
||||
READ_SCOPE = "search:result:read"
|
||||
INDEX_SCOPE = "search:index:write"
|
||||
ADMIN_SCOPE = "search:index:admin"
|
||||
@@ -101,6 +107,11 @@ def _router(_context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _dsar_provider(context: ModuleContext) -> SearchDsarProvider:
|
||||
del context
|
||||
return SearchDsarProvider()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
@@ -122,6 +133,7 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="search.provider", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name="search.index_writer", version="1.1.0"),
|
||||
ModuleInterfaceProvider(name="search.source", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name=SEARCH_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -189,6 +201,14 @@ manifest = ModuleManifest(
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_SEARCH_INDEX_WRITER: _service,
|
||||
SEARCH_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
SEARCH_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Search data-subject request provider",
|
||||
summary="Finds and purges derived index copies while preserving source authority.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
search_providers=(
|
||||
SearchProviderRegistration(
|
||||
@@ -198,6 +218,84 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="search.data-subject-requests",
|
||||
title="Derived Search data-subject requests",
|
||||
summary="Remove derived index copies without treating Search as the authoritative data owner.",
|
||||
body=(
|
||||
"Search correlates explicit index/change identifiers and provider-owned source references inside the exact tenant. Account, identity, and membership selectors expose only minimized ACL projections and never imply ownership of the indexed source object. Indexed title, summary, body, search text, URL, keywords, metadata, external-reference payloads, ACL token values, hashes, cursors, queued documents, and errors are excluded. "
|
||||
"Derived document and queued-change rows may be deleted idempotently. ACL-only matches require source-authority review. Source correction or erasure must happen in the owner module before Search is rebuilt; otherwise the source can legitimately republish the derived entry."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("core", "access"),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"search.data-subject-requests",
|
||||
"search.admin.index",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"purge_derived_index": (
|
||||
"Deletes matching derived documents and queued changes idempotently."
|
||||
),
|
||||
"preserve_authoritative_source": (
|
||||
"Does not correct or erase the authoritative object owned by its source module."
|
||||
),
|
||||
"review_acl_only_matches": (
|
||||
"Requires source-authority review when only an ACL projection identifies the subject."
|
||||
),
|
||||
},
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Datenschutzanfragen zum abgeleiteten Suchindex",
|
||||
"summary": (
|
||||
"Abgeleitete Indexkopien entfernen, ohne Search als führende Quelle "
|
||||
"der indexierten Objekte zu behandeln."
|
||||
),
|
||||
"body": (
|
||||
"Search gleicht innerhalb des exakten Mandanten nur ausdrückliche Index- "
|
||||
"oder Änderungskennungen und quellanbietergeführte Referenzen ab. Selektoren "
|
||||
"für Konten, Identitäten und Mitgliedschaften legen ausschließlich minimierte "
|
||||
"Berechtigungsprojektionen offen und begründen niemals die Eigentümerschaft am "
|
||||
"indexierten Quellobjekt. Indexierter Titel, Zusammenfassung, Inhalt, Suchtext, "
|
||||
"URL, Schlüsselwörter, Metadaten, externe Referenzinhalte, Berechtigungswerte, "
|
||||
"Prüfsummen, Cursor, eingereihte Dokumente und Fehler bleiben ausgeschlossen. "
|
||||
"Abgeleitete Dokumente und eingereihte Änderungen können idempotent gelöscht "
|
||||
"werden. Treffer allein über Berechtigungsprojektionen erfordern eine Prüfung "
|
||||
"durch die führende Quelle. Berichtigung oder Löschung muss im Eigentümermodul "
|
||||
"erfolgen, bevor Search neu aufgebaut wird; andernfalls darf die Quelle den "
|
||||
"abgeleiteten Eintrag erneut veröffentlichen."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"consequence_classes": {
|
||||
"purge_derived_index": (
|
||||
"Löscht passende abgeleitete Dokumente und eingereihte Änderungen idempotent."
|
||||
),
|
||||
"preserve_authoritative_source": (
|
||||
"Berichtigt oder löscht nicht das führende Objekt des jeweiligen Quellmoduls."
|
||||
),
|
||||
"review_acl_only_matches": (
|
||||
"Erfordert eine Prüfung durch die führende Quelle, wenn die betroffene Person nur über eine Berechtigungsprojektion erkannt wird."
|
||||
),
|
||||
}
|
||||
}
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Search index lifecycle",
|
||||
href="govoplan-search/README.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
order=11,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="search.global-and-contextual",
|
||||
title="Global and contextual search",
|
||||
@@ -223,14 +321,98 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("administrator", "user"),
|
||||
related_modules=("connectors", "views"),
|
||||
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"search.global",
|
||||
"search.results",
|
||||
"search.filters",
|
||||
"search.admin.index",
|
||||
],
|
||||
"purpose": (
|
||||
"Find currently authorized native and connected objects without broadening source permissions."
|
||||
),
|
||||
"prerequisites": [
|
||||
"The actor has Search read access and the source module authorizes each returned object now.",
|
||||
"At least one enabled source provider has published or can supply a searchable projection.",
|
||||
],
|
||||
"steps": [
|
||||
"Open Search from the title bar, F3, or Ctrl/Cmd+K and enter a precise term.",
|
||||
"Narrow the result set by type, source, or context without changing source authorization.",
|
||||
"Review partial-provider and quarantine diagnostics before relying on completeness.",
|
||||
"Open an authorized result; Search rechecks the provider-owned object permission before disclosure.",
|
||||
"Administrators may process queued changes, reconcile providers, or start a bounded rebuild when diagnostics require it.",
|
||||
],
|
||||
"limitations": [
|
||||
"Search is a derived discovery layer and is never authoritative for source content or access decisions.",
|
||||
"A provider failure can make results incomplete but never permits unsafe results to bypass ACL filtering.",
|
||||
],
|
||||
"operational_consequences": {
|
||||
"process_queue": "Applies pending derived index changes under current tenant and provider boundaries.",
|
||||
"reconcile": "Compares enabled source coverage and retains unresolved changes in quarantine.",
|
||||
"rebuild": "Recreates bounded derived projections while source data remains authoritative.",
|
||||
},
|
||||
"verification": [
|
||||
"Every displayed result names its source and remains openable by the current principal.",
|
||||
"Partial-provider failures and quarantined changes remain visible as diagnostics.",
|
||||
"Administrative rebuild status can be reconciled against the enabled provider inventory.",
|
||||
],
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Globale und kontextbezogene Suche",
|
||||
"summary": (
|
||||
"Berechtigte native und angebundene Objekte über eine gemeinsame, "
|
||||
"berechtigungsbewusste Oberfläche finden."
|
||||
),
|
||||
"body": (
|
||||
"Search verwendet den eingebauten Datenbankindex und kann optionale Anbieter "
|
||||
"zusammenführen. Quellmodule melden durchsuchbare Typen, Kontextbereiche und "
|
||||
"berechtigungsgeprüfte Indexeinträge. Externe Suchmaschinen bleiben optionale "
|
||||
"Adapter. Der Suchbefehl in der Titelleiste, F3 oder Strg/Cmd+K öffnet die per "
|
||||
"Tastatur bedienbare Suchüberlagerung; Filter erweitern niemals die "
|
||||
"Quellberechtigungen der aktuellen Person. Anbieterausfälle werden als partielle "
|
||||
"Diagnosen angezeigt, ohne sichere Ergebnisse zu verwerfen. Administratoren "
|
||||
"können die Abdeckung nativer Quellen prüfen, eingereihte Änderungen verarbeiten, "
|
||||
"aktivierte Module abgleichen und begrenzte Neuaufbauten aus der Administration "
|
||||
"starten. Änderungen in Quarantäne bleiben sichtbar, bis sie repariert und "
|
||||
"abgeglichen wurden."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"purpose": (
|
||||
"Aktuell berechtigte native und angebundene Objekte finden, ohne Quellberechtigungen zu erweitern."
|
||||
),
|
||||
"prerequisites": [
|
||||
"Die handelnde Person darf Search lesen und das Quellmodul autorisiert jedes zurückgegebene Objekt weiterhin.",
|
||||
"Mindestens ein aktivierter Quellanbieter hat eine durchsuchbare Projektion veröffentlicht oder kann sie bereitstellen.",
|
||||
],
|
||||
"steps": [
|
||||
"Search über die Titelleiste, F3 oder Strg/Cmd+K öffnen und einen präzisen Suchbegriff eingeben.",
|
||||
"Die Ergebnisse nach Typ, Quelle oder Kontext eingrenzen, ohne die Quellautorisierung zu verändern.",
|
||||
"Diagnosen zu partiellen Anbieterausfällen und Quarantäne prüfen, bevor Vollständigkeit angenommen wird.",
|
||||
"Ein berechtigtes Ergebnis öffnen; Search prüft vor der Offenlegung erneut die Berechtigung am quellengeführten Objekt.",
|
||||
"Administratoren können bei entsprechenden Diagnosen eingereihte Änderungen verarbeiten, Anbieter abgleichen oder einen begrenzten Neuaufbau starten.",
|
||||
],
|
||||
"limitations": [
|
||||
"Search ist eine abgeleitete Auffindbarkeitsschicht und niemals führend für Quellinhalte oder Zugriffsentscheidungen.",
|
||||
"Ein Anbieterausfall kann Ergebnisse unvollständig machen, erlaubt aber niemals das Umgehen der Berechtigungsfilterung.",
|
||||
],
|
||||
"operational_consequences": {
|
||||
"process_queue": "Verarbeitet ausstehende abgeleitete Indexänderungen innerhalb der aktuellen Mandanten- und Anbietergrenzen.",
|
||||
"reconcile": "Vergleicht die Abdeckung aktivierter Quellen und hält ungeklärte Änderungen in Quarantäne.",
|
||||
"rebuild": "Erstellt begrenzte abgeleitete Projektionen neu, während die Quelldaten führend bleiben.",
|
||||
},
|
||||
"verification": [
|
||||
"Jedes angezeigte Ergebnis nennt seine Quelle und kann von der aktuellen Person weiterhin geöffnet werden.",
|
||||
"Partielle Anbieterausfälle und Änderungen in Quarantäne bleiben als Diagnosen sichtbar.",
|
||||
"Der administrative Neuaufbaustatus lässt sich mit dem Inventar aktivierter Anbieter abgleichen.",
|
||||
],
|
||||
}
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
@@ -248,10 +430,20 @@ manifest = ModuleManifest(
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="README.md",
|
||||
test_ref="tests/test_postgres_search.py",
|
||||
known_limits=("The built-in PostgreSQL index is implemented; optional OpenSearch target evidence is not.",),
|
||||
known_limits=(
|
||||
"The built-in PostgreSQL index is implemented; optional OpenSearch target evidence is not.",
|
||||
),
|
||||
supported_authority_modes=("external_mirror",),
|
||||
owned_concepts=("derived search index", "search ACL projection", "index change queue"),
|
||||
non_owned_concepts=("source object", "source authorization", "external search engine"),
|
||||
owned_concepts=(
|
||||
"derived search index",
|
||||
"search ACL projection",
|
||||
"index change queue",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"source object",
|
||||
"source authorization",
|
||||
"external search engine",
|
||||
),
|
||||
recovery_docs=("README.md",),
|
||||
security_docs=("README.md",),
|
||||
operations_docs=("README.md",),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
documentation_structured_translation_issues,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_search.backend.manifest import manifest
|
||||
|
||||
|
||||
class SearchDocumentationTests(unittest.TestCase):
|
||||
def test_public_topics_have_complete_german_reference_content(self) -> None:
|
||||
self.assertEqual(2, len(manifest.documentation))
|
||||
for topic in manifest.documentation:
|
||||
translation = topic.translations.get("de", {})
|
||||
self.assertTrue(
|
||||
all(translation.get(key) for key in ("title", "summary", "body"))
|
||||
)
|
||||
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||
|
||||
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
|
||||
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
|
||||
self.assertIn("workflow", kinds)
|
||||
self.assertIn("reference", kinds)
|
||||
for topic in manifest.documentation:
|
||||
self.assertEqual((), user_workflow_scope_condition_issues(topic))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,482 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarProvider,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_search.backend.db.models import (
|
||||
SearchIndexAclToken,
|
||||
SearchIndexChangeQueue,
|
||||
SearchIndexDocument,
|
||||
)
|
||||
from govoplan_search.backend.dsar_provider import (
|
||||
SEARCH_DSAR_CAPABILITY,
|
||||
SearchDsarProvider,
|
||||
)
|
||||
from govoplan_search.backend.manifest import manifest
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 17, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: SearchDsarProvider, *, active: bool = True) -> None:
|
||||
self.provider = provider
|
||||
self.active = active
|
||||
|
||||
def capability_names(self):
|
||||
return (SEARCH_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "search"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
active = self.active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{"effective_modules": ("search",) if active else ()},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "search"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != SEARCH_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class SearchDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = SearchDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
self._seed()
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _document(
|
||||
self,
|
||||
*,
|
||||
row_id: str,
|
||||
tenant_id: str,
|
||||
resource_id: str,
|
||||
) -> SearchIndexDocument:
|
||||
return SearchIndexDocument(
|
||||
id=row_id,
|
||||
tenant_id=tenant_id,
|
||||
module_id="cases",
|
||||
provider_id="cases.search",
|
||||
resource_type="case",
|
||||
resource_id=resource_id,
|
||||
title="private-title-do-not-export",
|
||||
summary="private-summary-do-not-export",
|
||||
body="private-body-do-not-export",
|
||||
keywords=["private-keyword-do-not-export"],
|
||||
search_text="private-search-text-do-not-export",
|
||||
url="/private-url-do-not-export",
|
||||
visibility="restricted",
|
||||
external_reference={"secret": "private-reference-do-not-export"},
|
||||
metadata_={"secret": "private-metadata-do-not-export"},
|
||||
content_hash="a" * 64,
|
||||
source_revision="revision-1",
|
||||
change_cursor="private-cursor-do-not-export",
|
||||
source_updated_at=NOW,
|
||||
language="simple",
|
||||
index_version=1,
|
||||
requires_authorization_recheck=True,
|
||||
rebuild_id="private-rebuild-id-do-not-export",
|
||||
active=True,
|
||||
indexed_at=NOW,
|
||||
)
|
||||
|
||||
def _seed(self) -> None:
|
||||
document = self._document(
|
||||
row_id="document-1",
|
||||
tenant_id="tenant-1",
|
||||
resource_id="case-1",
|
||||
)
|
||||
document.acl_tokens.extend(
|
||||
(
|
||||
SearchIndexAclToken(
|
||||
id="acl-1",
|
||||
token="account:account-1",
|
||||
),
|
||||
SearchIndexAclToken(
|
||||
id="acl-2",
|
||||
token="identity:identity-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
unrelated = self._document(
|
||||
row_id="document-2",
|
||||
tenant_id="tenant-1",
|
||||
resource_id="case-2",
|
||||
)
|
||||
unrelated.acl_tokens.append(
|
||||
SearchIndexAclToken(
|
||||
id="acl-3",
|
||||
token="account:account-1",
|
||||
)
|
||||
)
|
||||
other_tenant = self._document(
|
||||
row_id="document-3",
|
||||
tenant_id="tenant-2",
|
||||
resource_id="case-1",
|
||||
)
|
||||
other_tenant.acl_tokens.append(
|
||||
SearchIndexAclToken(
|
||||
id="acl-4",
|
||||
token="account:account-1",
|
||||
)
|
||||
)
|
||||
self.session.add_all((document, unrelated, other_tenant))
|
||||
self.session.add_all(
|
||||
(
|
||||
SearchIndexChangeQueue(
|
||||
id="change-1",
|
||||
change_id="change-public-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_id="cases.search",
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
kind="upsert",
|
||||
source_revision="revision-1",
|
||||
source_cursor="private-source-cursor-do-not-export",
|
||||
document_={"secret": "private-queue-document-do-not-export"},
|
||||
occurred_at=NOW,
|
||||
status="queued",
|
||||
attempts=1,
|
||||
available_at=NOW,
|
||||
error="private-error-do-not-export",
|
||||
),
|
||||
SearchIndexChangeQueue(
|
||||
id="change-2",
|
||||
change_id="change-public-2",
|
||||
tenant_id="tenant-1",
|
||||
provider_id="cases.search",
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-2",
|
||||
kind="upsert",
|
||||
source_revision="revision-1",
|
||||
source_cursor="unrelated-private-cursor",
|
||||
document_={"secret": "unrelated-private-document"},
|
||||
occurred_at=NOW + timedelta(minutes=1),
|
||||
status="queued",
|
||||
attempts=0,
|
||||
available_at=NOW,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def test_source_reference_exports_only_minimized_derived_rows(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{"search_index_document", "search_index_change"},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertIn("case-1", exported)
|
||||
self.assertNotIn("case-2", exported)
|
||||
for secret in (
|
||||
"private-title-do-not-export",
|
||||
"private-summary-do-not-export",
|
||||
"private-body-do-not-export",
|
||||
"private-keyword-do-not-export",
|
||||
"private-search-text-do-not-export",
|
||||
"private-url-do-not-export",
|
||||
"private-reference-do-not-export",
|
||||
"private-metadata-do-not-export",
|
||||
"private-cursor-do-not-export",
|
||||
"private-rebuild-id-do-not-export",
|
||||
"private-source-cursor-do-not-export",
|
||||
"private-queue-document-do-not-export",
|
||||
"private-error-do-not-export",
|
||||
):
|
||||
self.assertNotIn(secret, exported)
|
||||
|
||||
def test_canonical_selectors_export_acl_projection_not_source_content(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{"search_acl_projection"},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
self.assertEqual(3, len(records))
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertNotIn("account-1", exported)
|
||||
self.assertNotIn("identity-1", exported)
|
||||
self.assertNotIn("private-title-do-not-export", exported)
|
||||
self.assertNotIn("document-3", exported)
|
||||
|
||||
def test_direct_references_and_conflicts_fail_closed(self) -> None:
|
||||
direct = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"search.document": "document-1"}
|
||||
),
|
||||
)
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={
|
||||
"search.document": "document-1",
|
||||
"cases.case": "case-2",
|
||||
}
|
||||
),
|
||||
)
|
||||
alias_conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={
|
||||
"search.document": "document-1",
|
||||
"search.index_document": "document-2",
|
||||
}
|
||||
),
|
||||
)
|
||||
wrong_tenant = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-2",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"search.document": "document-1"}
|
||||
),
|
||||
)
|
||||
wrong_resource_type = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(external_references={"cases.ticket": "case-1"}),
|
||||
)
|
||||
change_and_account = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"search.change": "change-1"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(["document-1"], [record.resource_id for record in direct])
|
||||
self.assertEqual((), conflict)
|
||||
self.assertEqual((), alias_conflict)
|
||||
self.assertEqual((), wrong_tenant)
|
||||
self.assertEqual((), wrong_resource_type)
|
||||
self.assertEqual(
|
||||
{"change-1", "acl-1"},
|
||||
{record.resource_id for record in change_and_account},
|
||||
)
|
||||
|
||||
def test_derived_deletion_is_idempotent_but_acl_review_is_blocked(self) -> None:
|
||||
direct_subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
|
||||
direct_records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=direct_subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=direct_subject,
|
||||
records=direct_records,
|
||||
)
|
||||
self.assertTrue(actions)
|
||||
self.assertTrue(all(action.kind == "delete" for action in actions))
|
||||
self.assertTrue(all(action.executable for action in actions))
|
||||
|
||||
first = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=direct_subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
second = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=direct_subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1-retry",
|
||||
)
|
||||
self.assertTrue(all(result.status == "executed" for result in first))
|
||||
self.assertTrue(all(result.status == "unchanged" for result in second))
|
||||
self.assertIsNone(self.session.get(SearchIndexDocument, "document-1"))
|
||||
self.assertIsNone(self.session.get(SearchIndexChangeQueue, "change-1"))
|
||||
|
||||
acl_subject = DsarSubjectRef(account_id="account-1")
|
||||
acl_records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=acl_subject,
|
||||
)
|
||||
acl_actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=acl_subject,
|
||||
records=acl_records,
|
||||
)
|
||||
self.assertTrue(all(action.kind == "manual_review" for action in acl_actions))
|
||||
self.assertTrue(all(not action.executable for action in acl_actions))
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=acl_subject,
|
||||
actions=acl_actions,
|
||||
request_id="dsar-2",
|
||||
)
|
||||
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||
|
||||
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||
subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||
self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=(
|
||||
DsarRecordRef(
|
||||
provider_id="cases",
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
category="case",
|
||||
title="Case",
|
||||
),
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||
self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=(
|
||||
DsarErasureActionRef(
|
||||
action_id="cases:delete:case:case-1",
|
||||
provider_id="cases",
|
||||
module_id="cases",
|
||||
kind="delete",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
title="Delete case",
|
||||
rationale="Foreign",
|
||||
executable=True,
|
||||
),
|
||||
),
|
||||
request_id="dsar-1",
|
||||
)
|
||||
|
||||
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-SEARCH-1",
|
||||
request_kind="access_and_erasure",
|
||||
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||
purpose="Respond to a verified request.",
|
||||
legal_basis="Article 15 and 17 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=row,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(
|
||||
[SEARCH_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
|
||||
)
|
||||
self.assertEqual(2, row.search_result["record_count"])
|
||||
|
||||
inactive = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-SEARCH-2",
|
||||
request_kind="access",
|
||||
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||
purpose="Respond to a verified request.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, active=False),
|
||||
row=inactive,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||
self.assertEqual(
|
||||
[SEARCH_DSAR_CAPABILITY],
|
||||
inactive.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
self.assertEqual(0, inactive.search_result["record_count"])
|
||||
|
||||
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||
self.assertIn(SEARCH_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(SEARCH_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||
self.assertIn(
|
||||
SEARCH_DSAR_CAPABILITY,
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
topic.id == "search.data-subject-requests"
|
||||
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||
for topic in manifest.documentation
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/search-webui",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent
|
||||
} from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import {
|
||||
import { ActionToolbar,
|
||||
Button,
|
||||
CountBadge,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
@@ -467,7 +468,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
<section
|
||||
className="search-overlay-results-panel"
|
||||
style={{ height: layout.resultsHeight }}>
|
||||
<div className="search-overlay-toolbar">
|
||||
<ActionToolbar className="search-overlay-toolbar">
|
||||
{currentContext &&
|
||||
<SegmentedControl<SearchScope>
|
||||
className="search-overlay-scope"
|
||||
@@ -496,9 +497,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
<Filter size={16} aria-hidden="true" />
|
||||
<span>Filters</span>
|
||||
{activeFilterCount > 0 &&
|
||||
<span className="search-filter-count" aria-label={`${activeFilterCount} active filters`}>
|
||||
<CountBadge size="compact" aria-label={`${activeFilterCount} active filters`}>
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
</CountBadge>
|
||||
}
|
||||
</Button>
|
||||
{filtersOpen &&
|
||||
@@ -598,7 +599,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
|
||||
<PageScrollViewport
|
||||
id="global-search-overlay-results"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
@@ -231,12 +232,12 @@ export default function SearchAdminPanel({ settings }: Props) {
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="metric-grid">
|
||||
<MetricGrid>
|
||||
<MetricCard label="Search sources" value={rows.length} tone="neutral" />
|
||||
<MetricCard label="Pending changes" value={pending} tone={pending ? "warning" : "good"} />
|
||||
<MetricCard label="Quarantined" value={quarantined} tone={quarantined ? "danger" : "good"} />
|
||||
<MetricCard label="Backend" value={diagnostics?.backend ?? "-"} tone="neutral" />
|
||||
</div>
|
||||
</MetricGrid>
|
||||
|
||||
{quarantined > 0 && (
|
||||
<DismissibleAlert tone="warning" dismissible={false} compact>
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import {
|
||||
import { ActionToolbar,
|
||||
Button,
|
||||
CountBadge,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
@@ -209,7 +210,7 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
|
||||
return (
|
||||
<main className="search-page">
|
||||
<div className="search-page-toolbar">
|
||||
<ActionToolbar className="search-page-toolbar">
|
||||
<form className="search-page-form" onSubmit={submit}>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
<input
|
||||
@@ -231,9 +232,9 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
<Filter size={16} aria-hidden="true" />
|
||||
<span>Filters</span>
|
||||
{activeFilterCount > 0 &&
|
||||
<span className="search-filter-count" aria-label={`${activeFilterCount} active filters`}>
|
||||
<CountBadge size="compact" aria-label={`${activeFilterCount} active filters`}>
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
</CountBadge>
|
||||
}
|
||||
</Button>
|
||||
{filtersOpen &&
|
||||
@@ -324,7 +325,7 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<PageScrollViewport className="search-results-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--control-bg);
|
||||
color: var(--muted);
|
||||
padding: 0 8px;
|
||||
@@ -41,7 +41,7 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-tight);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
@@ -109,7 +109,7 @@
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
@@ -185,7 +185,7 @@
|
||||
height: 38px;
|
||||
flex: 1 1 520px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--control-bg);
|
||||
color: var(--muted);
|
||||
padding-left: 10px;
|
||||
@@ -238,21 +238,6 @@
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.search-filter-count {
|
||||
min-width: 19px;
|
||||
height: 19px;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.search-filter-popover {
|
||||
position: absolute;
|
||||
z-index: 400;
|
||||
@@ -264,7 +249,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-menu);
|
||||
}
|
||||
@@ -326,7 +311,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 31px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 4px 7px;
|
||||
color: var(--text);
|
||||
@@ -361,7 +346,7 @@
|
||||
gap: 6px;
|
||||
min-height: 27px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--control-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
@@ -435,7 +420,7 @@
|
||||
gap: 7px;
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
var(--control-gradient-start),
|
||||
var(--control-gradient-end)
|
||||
@@ -461,7 +446,7 @@
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.search-overlay-results-panel {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.search-overlay-toolbar {
|
||||
|
||||
Reference in New Issue
Block a user