Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54d1919e4 | ||
|
|
97ca670bfe | ||
|
|
f03497bdaf | ||
|
|
1bb03c0f91 | ||
|
|
45cc3cdc7a | ||
|
|
32a32c9906 | ||
|
|
c16bb51aa3 | ||
|
|
492306f6a7 | ||
|
|
7216ac4842 | ||
|
|
ac8e80c2f2 |
@@ -22,8 +22,11 @@ explicit frozen states, previews, retirement, and atomic producer publication.
|
||||
Origin discovery retains each provider's source mode, structured health, and
|
||||
declared pushdown support. Live previews preserve the provider's effective row,
|
||||
serialized-byte, and elapsed-time limits and its redacted diagnostics.
|
||||
Producer modules can append a bounded tabular result or create a new static
|
||||
datasource through an idempotent capability. The publication ledger retains the
|
||||
Producer modules can append a bounded inline tabular result or pin a larger
|
||||
durable artifact through the same idempotent capability. Artifact references
|
||||
declare a backend, locator, SHA-256 checksum, schema, fingerprint, row and byte
|
||||
counts; the installed provider verifies integrity and serves bounded reads.
|
||||
The publication ledger retains the
|
||||
producer run, output materialization, provenance, and replay identity. On
|
||||
PostgreSQL, a transaction-scoped advisory lock serializes each tenant, producer,
|
||||
and idempotency identity before any output side effect, so retries from multiple
|
||||
@@ -37,7 +40,23 @@ materialization provenance. The supported contract is documented in
|
||||
|
||||
The contracts already model database, HTTP/REST, directory, file, feed,
|
||||
document, binary, directory, and stream sources so providers can be added
|
||||
without changing consumers. Larger durable artifact-backed publications remain
|
||||
a later storage-provider slice.
|
||||
without changing consumers. Storage modules contribute artifact backends
|
||||
through the provider-neutral `datasources.artifactBackends` capability; the
|
||||
Datasources module never imports their internals.
|
||||
|
||||
See [docs/CONCEPT.md](docs/CONCEPT.md) for ownership and lifecycle details.
|
||||
|
||||
## Data-subject requests
|
||||
|
||||
Datasources publishes `privacy.dsar.datasources` for exact catalogue,
|
||||
governance-reference, materialization, payload, stage, and publication
|
||||
references and for minimized operator attribution. It never exports connector
|
||||
references, locators, credentials, arbitrary rows, schemas, validation
|
||||
samples, metadata, provenance bodies, checkpoints, replay material, or hashes.
|
||||
The module does not guess subject identity by scanning schema-dependent tabular
|
||||
payloads; the authoritative source module locates and corrects those facts.
|
||||
|
||||
Unpromoted stages and unreferenced payloads can be deleted idempotently.
|
||||
Published or referenced state, immutable materializations, governance evidence,
|
||||
holds, and operator attribution require data-steward review. Dataflow and
|
||||
Reporting derivatives must be refreshed after the source correction.
|
||||
|
||||
@@ -104,6 +104,13 @@ Consumers should be able to request the governance explanation and dependency
|
||||
impact separately from row access. Seeing catalogue metadata must not imply
|
||||
permission to read protected data.
|
||||
|
||||
When Search is installed, Datasources contributes catalogue entries as a native
|
||||
search source. Only the stable catalogue identity, display name, description,
|
||||
mode, shape, lifecycle state, classification, publication state, and authority
|
||||
mode are indexed. Every result is re-authorized against the current tenant and
|
||||
catalogue-read permission. Rows, schemas, connector references, credentials,
|
||||
arbitrary metadata, and provenance remain outside the derived search index.
|
||||
|
||||
## Next Providers
|
||||
|
||||
Connector providers should cover:
|
||||
|
||||
@@ -96,6 +96,26 @@ This makes concurrent retries from separate API or worker nodes converge on the
|
||||
same publication and materialization rather than relying on a late uniqueness
|
||||
failure after output rows have already been persisted.
|
||||
|
||||
## Durable artifact publications
|
||||
|
||||
Outputs larger than the inline row and byte limits use an immutable artifact
|
||||
reference. The reference pins its backend and locator together with SHA-256
|
||||
checksum, schema, datasource fingerprint, row count, byte count, media type,
|
||||
and optional resume checkpoint. Datasources persists that reference as the
|
||||
materialization payload and asks the installed Core-contract artifact backend
|
||||
to verify it before creating catalogue state. Reads remain bounded and are
|
||||
re-authorized by Datasources before reaching the backend.
|
||||
|
||||
Schema rules are evaluated by Datasources. Content-level rules such as
|
||||
uniqueness or range require producer evidence bound to the exact payload
|
||||
checksum and current quality-policy hash, including all evaluated rule IDs.
|
||||
Missing or explicitly deferred evidence produces a `review_required`
|
||||
publication and an immutable, addressable materialization, but it does not
|
||||
replace the Datasource's current state. Valid warnings produce
|
||||
`published_with_warnings`; failed evidence blocks the publication without a
|
||||
catalogue side effect. These terminal states are preserved for Dataflow and
|
||||
Workflow handoffs instead of being collapsed into generic success.
|
||||
|
||||
Approval authority, approval expiry, and retention/deletion execution remain
|
||||
separate work under `govoplan-datasources#2`. Until those contracts are added,
|
||||
no JSON flag is treated as an approval and no stage is deleted automatically.
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-datasources"
|
||||
version = "0.1.16"
|
||||
version = "0.1.20"
|
||||
description = "Governed datasource catalogue, staging, and materialization lifecycle for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.16"]
|
||||
dependencies = ["govoplan-core>=0.1.20"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Datasources module."""
|
||||
|
||||
__version__ = "0.1.16"
|
||||
__version__ = "0.1.20"
|
||||
|
||||
@@ -109,6 +109,10 @@ class DatasourceRecord(Base, TimestampMixin):
|
||||
)
|
||||
privacy_profile_ref: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
retention_policy_ref: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
access_policy_ref: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
visibility_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
hold_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
publication_state: Mapped[str] = mapped_column(
|
||||
String(50), default="draft", nullable=False, index=True
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_datasources.backend.db.models import (
|
||||
DatasourceGovernanceReferenceRecord,
|
||||
DatasourceMaterializationRecord,
|
||||
DatasourcePayloadRecord,
|
||||
DatasourcePublicationRecord,
|
||||
DatasourceRecord,
|
||||
DatasourceStageRecord,
|
||||
)
|
||||
|
||||
|
||||
DATASOURCES_DSAR_CAPABILITY = dsar_capability_name("datasources")
|
||||
_MAX_RECORDS = 5_000
|
||||
_CONFLICT = object()
|
||||
_DIRECT_ALIASES = {
|
||||
"datasource_id": ("datasources.datasource", "datasources.catalogue"),
|
||||
"governance_reference_id": ("datasources.governance_reference",),
|
||||
"materialization_id": ("datasources.materialization",),
|
||||
"payload_id": ("datasources.payload",),
|
||||
"stage_id": ("datasources.stage",),
|
||||
"publication_id": ("datasources.publication",),
|
||||
}
|
||||
_RESOURCE_MODELS = {
|
||||
"datasource": DatasourceRecord,
|
||||
"datasource_governance_reference": DatasourceGovernanceReferenceRecord,
|
||||
"datasource_materialization": DatasourceMaterializationRecord,
|
||||
"datasource_payload": DatasourcePayloadRecord,
|
||||
"datasource_stage": DatasourceStageRecord,
|
||||
"datasource_publication": DatasourcePublicationRecord,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Selectors:
|
||||
account_id: str | None
|
||||
identity_id: str | None
|
||||
membership_id: str | None
|
||||
direct: dict[str, str]
|
||||
|
||||
@property
|
||||
def actor_ids(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
value
|
||||
for value in (self.account_id, self.identity_id, self.membership_id)
|
||||
if value
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Match:
|
||||
resource_type: str
|
||||
row: Any
|
||||
category: str
|
||||
|
||||
|
||||
class DatasourcesDsarProvider:
|
||||
provider_id = "datasources"
|
||||
module_id = "datasources"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _selectors(subject)
|
||||
if selectors is None or not (selectors.actor_ids or selectors.direct):
|
||||
return ()
|
||||
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
|
||||
if direct is None:
|
||||
return ()
|
||||
if direct:
|
||||
if selectors.actor_ids and not any(
|
||||
_correlates(match, selectors.actor_ids) for match in direct
|
||||
):
|
||||
return ()
|
||||
matches = direct
|
||||
else:
|
||||
matches = _canonical_matches(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
actor_ids=selectors.actor_ids,
|
||||
)
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for match in matches:
|
||||
key = (match.resource_type, str(match.row.id))
|
||||
if key in seen:
|
||||
continue
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Datasources DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
seen.add(key)
|
||||
records.append(_record(match))
|
||||
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 _selectors(subject) is None:
|
||||
raise ValueError("Datasources DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
if record.category in {
|
||||
"unpromoted_datasource_stage",
|
||||
"unreferenced_datasource_payload",
|
||||
}:
|
||||
kind = "delete"
|
||||
executable = True
|
||||
rationale = (
|
||||
"Remove transient Datasources content that has not become "
|
||||
"immutable or referenced lifecycle evidence."
|
||||
)
|
||||
elif record.category == "datasource_operator_attribution":
|
||||
kind = "retain"
|
||||
executable = False
|
||||
rationale = record.retention_reason or (
|
||||
"Institutional data operations remain attributable."
|
||||
)
|
||||
else:
|
||||
kind = "manual_review"
|
||||
executable = False
|
||||
rationale = (
|
||||
"A data steward must correct the authoritative source and "
|
||||
"review immutable revisions, holds, consumers, and evidence."
|
||||
)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"datasources:{kind}:{record.resource_type}:"
|
||||
f"{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=rationale,
|
||||
executable=executable,
|
||||
irreversible=executable,
|
||||
metadata={"record_category": record.category},
|
||||
)
|
||||
)
|
||||
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 = _selectors(subject)
|
||||
if selectors is None:
|
||||
raise ValueError("Datasources 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=(
|
||||
"Use governed source correction and Datasources "
|
||||
"retention review before changing published state."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
continue
|
||||
if action.kind != "delete" or action.resource_type not in {
|
||||
"datasource_stage",
|
||||
"datasource_payload",
|
||||
}:
|
||||
raise ValueError("Datasources DSAR executable action is unsupported.")
|
||||
model = _RESOURCE_MODELS[action.resource_type]
|
||||
row = (
|
||||
db.query(model)
|
||||
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
status = "unchanged"
|
||||
summary = "Transient Datasources row was already absent."
|
||||
else:
|
||||
match = _Match(action.resource_type, row, "execution")
|
||||
if not (
|
||||
_directly_targets(selectors, match)
|
||||
or _correlates(match, selectors.actor_ids)
|
||||
):
|
||||
raise ValueError(
|
||||
"Datasources DSAR action is not corroborated by the subject."
|
||||
)
|
||||
_assert_deletable(db, resource_type=action.resource_type, row=row)
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
status = "executed"
|
||||
summary = "Transient, unreferenced Datasources row removed."
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status=status,
|
||||
summary=summary,
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _direct_matches(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _Selectors,
|
||||
) -> list[_Match] | None:
|
||||
matches: list[_Match] = []
|
||||
for selector, value in selectors.direct.items():
|
||||
if selector == "datasource_id":
|
||||
datasource = _one(
|
||||
session,
|
||||
DatasourceRecord,
|
||||
tenant_id=tenant_id,
|
||||
field="id",
|
||||
value=value.removeprefix("datasource:"),
|
||||
)
|
||||
if datasource is None:
|
||||
return None
|
||||
current = _datasource_package(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
datasource=datasource,
|
||||
)
|
||||
else:
|
||||
model, resource_type = {
|
||||
"governance_reference_id": (
|
||||
DatasourceGovernanceReferenceRecord,
|
||||
"datasource_governance_reference",
|
||||
),
|
||||
"materialization_id": (
|
||||
DatasourceMaterializationRecord,
|
||||
"datasource_materialization",
|
||||
),
|
||||
"payload_id": (DatasourcePayloadRecord, "datasource_payload"),
|
||||
"stage_id": (DatasourceStageRecord, "datasource_stage"),
|
||||
"publication_id": (
|
||||
DatasourcePublicationRecord,
|
||||
"datasource_publication",
|
||||
),
|
||||
}[selector]
|
||||
row = _one(
|
||||
session,
|
||||
model,
|
||||
tenant_id=tenant_id,
|
||||
field="id",
|
||||
value=_strip_prefix(value),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
current = [
|
||||
_Match(
|
||||
resource_type, row, _direct_category(session, resource_type, row)
|
||||
)
|
||||
]
|
||||
matches.extend(current)
|
||||
if len(matches) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Datasources DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
roots = {
|
||||
root
|
||||
for match in matches
|
||||
for root in _root_datasource_ids(session, match)
|
||||
if root
|
||||
}
|
||||
if len(roots) > 1:
|
||||
return None
|
||||
return matches
|
||||
|
||||
|
||||
def _datasource_package(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
datasource: DatasourceRecord,
|
||||
) -> list[_Match]:
|
||||
matches = [_Match("datasource", datasource, "datasource_configuration")]
|
||||
specs = (
|
||||
(
|
||||
DatasourceGovernanceReferenceRecord,
|
||||
"datasource_governance_reference",
|
||||
),
|
||||
(DatasourceMaterializationRecord, "datasource_materialization"),
|
||||
(DatasourceStageRecord, "datasource_stage"),
|
||||
(DatasourcePublicationRecord, "datasource_publication"),
|
||||
)
|
||||
materializations: list[DatasourceMaterializationRecord] = []
|
||||
for model, resource_type in specs:
|
||||
rows = (
|
||||
session.query(model)
|
||||
.filter(
|
||||
model.tenant_id == tenant_id,
|
||||
(
|
||||
model.target_datasource_id == datasource.id
|
||||
if model is DatasourceStageRecord
|
||||
else model.datasource_id == datasource.id
|
||||
),
|
||||
)
|
||||
.order_by(model.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if model is DatasourceMaterializationRecord:
|
||||
materializations = rows
|
||||
matches.extend(
|
||||
_Match(
|
||||
resource_type,
|
||||
row,
|
||||
(
|
||||
"datasource_related_stage"
|
||||
if resource_type == "datasource_stage"
|
||||
else _direct_category(session, resource_type, row)
|
||||
),
|
||||
)
|
||||
for row in rows
|
||||
)
|
||||
payload_ids = {row.payload_id for row in materializations if row.payload_id}
|
||||
if payload_ids:
|
||||
payloads = (
|
||||
session.query(DatasourcePayloadRecord)
|
||||
.filter(
|
||||
DatasourcePayloadRecord.tenant_id == tenant_id,
|
||||
DatasourcePayloadRecord.id.in_(payload_ids),
|
||||
)
|
||||
.order_by(DatasourcePayloadRecord.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
matches.extend(
|
||||
_Match(
|
||||
"datasource_payload",
|
||||
row,
|
||||
_direct_category(session, "datasource_payload", row),
|
||||
)
|
||||
for row in payloads
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _canonical_matches(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_ids: tuple[str, ...],
|
||||
) -> list[_Match]:
|
||||
if not actor_ids:
|
||||
return []
|
||||
specs = (
|
||||
(
|
||||
DatasourceRecord,
|
||||
or_(
|
||||
DatasourceRecord.created_by.in_(actor_ids),
|
||||
DatasourceRecord.updated_by.in_(actor_ids),
|
||||
),
|
||||
"datasource",
|
||||
),
|
||||
(
|
||||
DatasourceMaterializationRecord,
|
||||
DatasourceMaterializationRecord.created_by.in_(actor_ids),
|
||||
"datasource_materialization",
|
||||
),
|
||||
(
|
||||
DatasourcePayloadRecord,
|
||||
DatasourcePayloadRecord.created_by.in_(actor_ids),
|
||||
"datasource_payload",
|
||||
),
|
||||
(
|
||||
DatasourceStageRecord,
|
||||
DatasourceStageRecord.created_by.in_(actor_ids),
|
||||
"datasource_stage",
|
||||
),
|
||||
(
|
||||
DatasourcePublicationRecord,
|
||||
DatasourcePublicationRecord.created_by.in_(actor_ids),
|
||||
"datasource_publication",
|
||||
),
|
||||
)
|
||||
matches: list[_Match] = []
|
||||
for model, condition, resource_type in specs:
|
||||
rows = (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, condition)
|
||||
.order_by(model.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
matches.extend(
|
||||
_Match(resource_type, row, "datasource_operator_attribution")
|
||||
for row in rows
|
||||
)
|
||||
if len(matches) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Datasources DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _one(
|
||||
session: Session,
|
||||
model: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
field: str,
|
||||
value: str,
|
||||
) -> Any | None:
|
||||
return (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, getattr(model, field) == value)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def _direct_category(session: Session, resource_type: str, row: Any) -> str:
|
||||
if resource_type == "datasource_stage" and row.promoted_at is None:
|
||||
return "unpromoted_datasource_stage"
|
||||
if resource_type == "datasource_payload":
|
||||
referenced = (
|
||||
session.query(DatasourceMaterializationRecord.id)
|
||||
.filter(
|
||||
DatasourceMaterializationRecord.tenant_id == row.tenant_id,
|
||||
DatasourceMaterializationRecord.payload_id == row.id,
|
||||
)
|
||||
.limit(1)
|
||||
.count()
|
||||
)
|
||||
if not referenced:
|
||||
return "unreferenced_datasource_payload"
|
||||
return {
|
||||
"datasource": "datasource_configuration",
|
||||
"datasource_governance_reference": "datasource_governance_configuration",
|
||||
"datasource_materialization": "immutable_datasource_materialization",
|
||||
"datasource_payload": "referenced_datasource_payload",
|
||||
"datasource_stage": "promoted_datasource_stage",
|
||||
"datasource_publication": "immutable_datasource_publication",
|
||||
}[resource_type]
|
||||
|
||||
|
||||
def _root_datasource_ids(session: Session, match: _Match) -> set[str]:
|
||||
row = match.row
|
||||
if match.resource_type == "datasource":
|
||||
return {row.id}
|
||||
if match.resource_type == "datasource_stage":
|
||||
return {row.target_datasource_id} if row.target_datasource_id else set()
|
||||
if match.resource_type == "datasource_payload":
|
||||
return {
|
||||
value
|
||||
for (value,) in session.query(DatasourceMaterializationRecord.datasource_id)
|
||||
.filter(
|
||||
DatasourceMaterializationRecord.tenant_id == row.tenant_id,
|
||||
DatasourceMaterializationRecord.payload_id == row.id,
|
||||
)
|
||||
.all()
|
||||
}
|
||||
return {row.datasource_id}
|
||||
|
||||
|
||||
def _correlates(match: _Match, actor_ids: tuple[str, ...]) -> bool:
|
||||
row = match.row
|
||||
return any(
|
||||
str(getattr(row, field, "") or "") in actor_ids
|
||||
for field in ("created_by", "updated_by")
|
||||
)
|
||||
|
||||
|
||||
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
|
||||
row = match.row
|
||||
selector, field = {
|
||||
"datasource": ("datasource_id", "id"),
|
||||
"datasource_governance_reference": (
|
||||
"governance_reference_id",
|
||||
"id",
|
||||
),
|
||||
"datasource_materialization": ("materialization_id", "id"),
|
||||
"datasource_payload": ("payload_id", "id"),
|
||||
"datasource_stage": ("stage_id", "id"),
|
||||
"datasource_publication": ("publication_id", "id"),
|
||||
}[match.resource_type]
|
||||
value = _strip_prefix(selectors.direct.get(selector, ""))
|
||||
if value == str(getattr(row, field)):
|
||||
return True
|
||||
datasource_selector = _strip_prefix(selectors.direct.get("datasource_id", ""))
|
||||
return bool(
|
||||
datasource_selector
|
||||
and datasource_selector in _root_datasource_ids_for_row(match)
|
||||
)
|
||||
|
||||
|
||||
def _root_datasource_ids_for_row(match: _Match) -> set[str]:
|
||||
row = match.row
|
||||
if match.resource_type == "datasource":
|
||||
return {row.id}
|
||||
if match.resource_type == "datasource_stage":
|
||||
return {row.target_datasource_id} if row.target_datasource_id else set()
|
||||
if match.resource_type == "datasource_payload":
|
||||
return set()
|
||||
return {row.datasource_id}
|
||||
|
||||
|
||||
def _assert_deletable(session: Session, *, resource_type: str, row: Any) -> None:
|
||||
if resource_type == "datasource_stage":
|
||||
if row.promoted_at is not None or row.promoted_materialization_id is not None:
|
||||
raise ValueError("Promoted Datasource stages require manual review.")
|
||||
return
|
||||
referenced = (
|
||||
session.query(DatasourceMaterializationRecord.id)
|
||||
.filter(
|
||||
DatasourceMaterializationRecord.tenant_id == row.tenant_id,
|
||||
DatasourceMaterializationRecord.payload_id == row.id,
|
||||
)
|
||||
.limit(1)
|
||||
.count()
|
||||
)
|
||||
if referenced:
|
||||
raise ValueError("Referenced Datasource payloads require manual review.")
|
||||
|
||||
|
||||
def _record(match: _Match) -> DsarRecordRef:
|
||||
row = match.row
|
||||
immutable = match.category == "datasource_operator_attribution"
|
||||
return DsarRecordRef(
|
||||
provider_id="datasources",
|
||||
module_id="datasources",
|
||||
resource_type=match.resource_type,
|
||||
resource_id=str(row.id),
|
||||
category=match.category,
|
||||
title=_title(match.resource_type),
|
||||
data={
|
||||
key: value
|
||||
for key, value in _record_data(match.resource_type, row).items()
|
||||
if value is not None
|
||||
},
|
||||
observed_at=_observed_at(row),
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=(
|
||||
"Institutional datasource creation, publication, and revision "
|
||||
"activity remains attributable for governance and audit."
|
||||
if immutable
|
||||
else None
|
||||
),
|
||||
source_path="/datasources",
|
||||
)
|
||||
|
||||
|
||||
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
|
||||
if resource_type == "datasource":
|
||||
return {
|
||||
"kind": row.kind,
|
||||
"mode": row.mode,
|
||||
"shape": row.shape,
|
||||
"status": row.status,
|
||||
"schema_version": row.schema_version,
|
||||
"row_count": row.row_count,
|
||||
"byte_count": row.byte_count,
|
||||
"authority_mode": row.authority_mode,
|
||||
"classification": row.classification,
|
||||
"publication_state": row.publication_state,
|
||||
"deleted_at": _iso(row.deleted_at),
|
||||
"created_at": _iso(row.created_at),
|
||||
"updated_at": _iso(row.updated_at),
|
||||
}
|
||||
if resource_type == "datasource_governance_reference":
|
||||
return {"relation": row.relation}
|
||||
if resource_type == "datasource_materialization":
|
||||
return {
|
||||
"revision": row.revision,
|
||||
"state": row.state,
|
||||
"schema_version": row.schema_version,
|
||||
"row_count": row.row_count,
|
||||
"byte_count": row.byte_count,
|
||||
"frozen_at": _iso(row.frozen_at),
|
||||
"source_timestamp": _iso(row.source_timestamp),
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
if resource_type == "datasource_payload":
|
||||
return {
|
||||
"backend": row.backend,
|
||||
"state": row.state,
|
||||
"media_type": row.media_type,
|
||||
"row_count": row.row_count,
|
||||
"byte_count": row.byte_count,
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
if resource_type == "datasource_stage":
|
||||
return {
|
||||
"kind": row.kind,
|
||||
"mode": row.mode,
|
||||
"shape": row.shape,
|
||||
"state": row.state,
|
||||
"row_count": row.row_count,
|
||||
"byte_count": row.byte_count,
|
||||
"promoted": row.promoted_at is not None,
|
||||
"promoted_at": _iso(row.promoted_at),
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
return {
|
||||
"producer_module": row.producer_module,
|
||||
"status": row.status,
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||
references = subject.external_references
|
||||
account_id = _coalesce(
|
||||
subject.account_id,
|
||||
references.get("datasources.account"),
|
||||
references.get("access.account"),
|
||||
)
|
||||
identity_id = _coalesce(
|
||||
subject.identity_id,
|
||||
references.get("datasources.identity"),
|
||||
references.get("identity.id"),
|
||||
)
|
||||
membership_id = _coalesce(
|
||||
subject.membership_id,
|
||||
references.get("datasources.membership"),
|
||||
references.get("tenancy.membership"),
|
||||
)
|
||||
if any(value is _CONFLICT for value in (account_id, identity_id, membership_id)):
|
||||
return None
|
||||
direct: dict[str, str] = {}
|
||||
for selector, aliases in _DIRECT_ALIASES.items():
|
||||
value = _coalesce(*(references.get(alias) for alias in aliases))
|
||||
if value is _CONFLICT:
|
||||
return None
|
||||
if value:
|
||||
direct[selector] = str(value)
|
||||
return _Selectors(
|
||||
account_id=_optional(account_id),
|
||||
identity_id=_optional(identity_id),
|
||||
membership_id=_optional(membership_id),
|
||||
direct=direct,
|
||||
)
|
||||
|
||||
|
||||
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(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _strip_prefix(value: str) -> str:
|
||||
return value.partition(":")[2] if ":" in value else value
|
||||
|
||||
|
||||
def _title(resource_type: str) -> str:
|
||||
return resource_type.replace("_", " ").title()
|
||||
|
||||
|
||||
def _observed_at(row: Any) -> datetime | None:
|
||||
for field in ("promoted_at", "source_timestamp", "updated_at", "created_at"):
|
||||
value = getattr(row, field, None)
|
||||
if isinstance(value, datetime):
|
||||
return _aware(value)
|
||||
return None
|
||||
|
||||
|
||||
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("Datasources DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "datasources" or record.module_id != "datasources":
|
||||
raise ValueError("Datasources DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
|
||||
raise ValueError("Datasources DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "datasources" or action.module_id != "datasources":
|
||||
raise ValueError("Datasources DSAR cannot execute a foreign provider action.")
|
||||
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
|
||||
"datasources:"
|
||||
):
|
||||
raise ValueError("Datasources DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["DATASOURCES_DSAR_CAPABILITY", "DatasourcesDsarProvider"]
|
||||
@@ -7,16 +7,20 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.datasources import (
|
||||
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS,
|
||||
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||
CAPABILITY_DATASOURCE_ORIGINS,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY,
|
||||
datasource_artifact_backend_provider,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -28,6 +32,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
@@ -35,9 +40,17 @@ from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_datasources.backend.db import models as datasource_models
|
||||
from govoplan_datasources.backend.dsar_provider import (
|
||||
DATASOURCES_DSAR_CAPABILITY,
|
||||
DatasourcesDsarProvider,
|
||||
)
|
||||
from govoplan_datasources.backend.search_source import (
|
||||
create_datasources_search_source,
|
||||
)
|
||||
from govoplan_datasources.backend.service import (
|
||||
ADMIN_SCOPE,
|
||||
CATALOGUE_READ_SCOPE,
|
||||
@@ -45,12 +58,13 @@ from govoplan_datasources.backend.service import (
|
||||
STAGE_WRITE_SCOPE,
|
||||
SqlDatasourceProvider,
|
||||
)
|
||||
from govoplan_datasources.backend.payloads import ExternalArtifactPayloadBackend
|
||||
|
||||
|
||||
MODULE_ID = "datasources"
|
||||
MODULE_NAME = "Datasources"
|
||||
MODULE_VERSION = "0.1.16"
|
||||
DATASOURCE_INTERFACE_VERSION = "0.1.0"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
DATASOURCE_INTERFACE_VERSION = "0.2.0"
|
||||
|
||||
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
layer="data_reporting_integration",
|
||||
@@ -75,7 +89,7 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
),
|
||||
known_limits=(
|
||||
"Governance references are stable provider-neutral refs; dedicated selectors depend on the owning optional modules.",
|
||||
"Quality and freshness policies are stored and snapshotted but enforcement remains provider-specific.",
|
||||
"External artifact content validation depends on an installed payload backend and checksum-bound producer evidence.",
|
||||
),
|
||||
supported_authority_modes=(
|
||||
"native_authoritative",
|
||||
@@ -172,7 +186,24 @@ def _router(context: ModuleContext):
|
||||
|
||||
|
||||
def _provider(context: ModuleContext) -> SqlDatasourceProvider:
|
||||
return SqlDatasourceProvider(registry=context.registry)
|
||||
provider = datasource_artifact_backend_provider(context.registry)
|
||||
backends = (
|
||||
tuple(
|
||||
ExternalArtifactPayloadBackend(backend)
|
||||
for backend in provider.artifact_backends()
|
||||
)
|
||||
if provider is not None
|
||||
else ()
|
||||
)
|
||||
return SqlDatasourceProvider(
|
||||
registry=context.registry,
|
||||
payload_backends=backends,
|
||||
)
|
||||
|
||||
|
||||
def _dsar_provider(context: ModuleContext) -> DatasourcesDsarProvider:
|
||||
del context
|
||||
return DatasourcesDsarProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
@@ -219,11 +250,14 @@ manifest = ModuleManifest(
|
||||
"files",
|
||||
"notifications",
|
||||
"policy",
|
||||
"search",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_DATASOURCE_ORIGINS,
|
||||
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS,
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(
|
||||
@@ -246,6 +280,7 @@ manifest = ModuleManifest(
|
||||
name="datasources.publication",
|
||||
version=DATASOURCE_INTERFACE_VERSION,
|
||||
),
|
||||
ModuleInterfaceProvider(name=DATASOURCES_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -254,6 +289,18 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="policy.datasource_visibility",
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -286,13 +333,63 @@ manifest = ModuleManifest(
|
||||
order=70,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="data-assurance",
|
||||
module_id=MODULE_ID,
|
||||
label="i18n:govoplan-core.product_area.data_assurance",
|
||||
icon="database-zap",
|
||||
description="i18n:govoplan-core.product_area.data_assurance_description",
|
||||
surface_ids=(
|
||||
"datasources.nav.datasources",
|
||||
"datasources.route.datasources",
|
||||
),
|
||||
order=60,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(id="datasources.page", module_id=MODULE_ID, kind="route", label="Datasources", order=70),
|
||||
ViewSurface(id="datasources.catalogue", module_id=MODULE_ID, kind="section", label="Datasource catalogue", order=10),
|
||||
ViewSurface(id="datasources.staging", module_id=MODULE_ID, kind="section", label="Datasource staging", order=20),
|
||||
ViewSurface(id="datasources.origins", module_id=MODULE_ID, kind="section", label="Datasource origins", order=30),
|
||||
ViewSurface(id="datasources.governance", module_id=MODULE_ID, kind="action", label="Datasource governance", order=40),
|
||||
ViewSurface(id="datasources.preview", module_id=MODULE_ID, kind="section", label="Datasource preview and materializations", order=50),
|
||||
ViewSurface(
|
||||
id="datasources.page",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Datasources",
|
||||
order=70,
|
||||
),
|
||||
ViewSurface(
|
||||
id="datasources.catalogue",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Datasource catalogue",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="datasources.staging",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Datasource staging",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="datasources.origins",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Datasource origins",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="datasources.governance",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Datasource governance",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
id="datasources.preview",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Datasource preview and materializations",
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
),
|
||||
route_factory=_router,
|
||||
@@ -300,8 +397,24 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_DATASOURCE_CATALOGUE: _provider,
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE: _provider,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION: _provider,
|
||||
DATASOURCES_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
DATASOURCES_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Datasources data-subject request provider",
|
||||
summary="Finds governed datasource copies without exposing credentials or row payloads.",
|
||||
contract_version="0.1.0",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("privacy_officer", "data_steward", "user"),
|
||||
),
|
||||
},
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="datasources.catalogue",
|
||||
factory=create_datasources_search_source,
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
@@ -334,6 +447,29 @@ manifest = ModuleManifest(
|
||||
),
|
||||
architecture=ARCHITECTURE,
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="datasources.data-subject-requests",
|
||||
title="Datasources data-subject requests",
|
||||
summary="Identify governed datasource copies while preserving credential, payload, and immutable-evidence boundaries.",
|
||||
body=(
|
||||
"Datasources matches exact tenant-scoped catalogue, governance-reference, materialization, payload, stage, and publication identifiers plus minimized account, identity, or membership operator attribution. DSAR results never copy connector/provider references, locators, credentials, arbitrary rows, schemas, validation samples, metadata, provenance bodies, checkpoints, idempotency material, or hashes. Arbitrary tabular payloads are not scanned for identifiers because that would be incomplete, schema-dependent, and liable to disclose unrelated people; the authoritative source module must locate and correct subject facts. "
|
||||
"An unpromoted stage or unreferenced payload can be deleted idempotently. Published catalogue state, promoted stages, referenced payloads, immutable materializations, governance references, publications, holds, and operator attribution require data-steward review and source correction. Downstream Dataflow and Reporting outputs must be refreshed after correction."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "data_steward", "auditor"),
|
||||
related_modules=("core", "connectors", "dataflow", "reporting"),
|
||||
order=69,
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"datasources.data-subject-requests",
|
||||
"datasources.catalogue",
|
||||
"datasources.staging",
|
||||
"datasources.preview",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="datasources.lifecycle",
|
||||
title="Datasource lifecycle",
|
||||
@@ -350,7 +486,11 @@ manifest = ModuleManifest(
|
||||
"reports, controls, and decisions. It preserves origin source mode, "
|
||||
"structured health, declared pushdown, and effective row, byte, and time "
|
||||
"limits for live previews. Governance metadata visibility does not "
|
||||
"grant access to protected rows."
|
||||
"grant access to protected rows. When Search is enabled, the module "
|
||||
"indexes only bounded catalogue labels and governance-safe facets, "
|
||||
"then rechecks the current catalogue permission before returning a "
|
||||
"result. Rows, schemas, connector references, credentials, arbitrary "
|
||||
"metadata, and provenance are never copied into the search index."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -362,6 +502,7 @@ manifest = ModuleManifest(
|
||||
"files",
|
||||
"reporting",
|
||||
"risk_compliance",
|
||||
"search",
|
||||
),
|
||||
order=70,
|
||||
metadata={
|
||||
@@ -383,7 +524,10 @@ manifest = ModuleManifest(
|
||||
"Authority mode states whether GovOPlaN, an external system, a synchronized projection, an overlay, or a linked reference "
|
||||
"controls the data. The authoritative source, owner, steward, responsible organization/function, schema owner, privacy "
|
||||
"profile, retention policy, transfer agreement, legal basis, holds, correction procedure, purposes, official keys, and "
|
||||
"known limits provide discoverable institutional context. Freshness and quality policies are typed JSON contracts retained "
|
||||
"known limits provide discoverable institutional context. A retention-policy reference identifies the owning Policy rule; it "
|
||||
"does not itself delete materializations, override legal holds, or prove that a scheduler is active. A transfer-agreement "
|
||||
"reference records the governed agreement for external exchange; it does not grant connector credentials, recipient access, "
|
||||
"or authority to export classified rows. Freshness and quality policies are typed JSON contracts retained "
|
||||
"with materialization evidence. Datasources enforces the declared bounded tabular stage rules and schema policy; origin-specific "
|
||||
"or consumer-specific controls still remain with the provider or consuming control that declares support. Metadata "
|
||||
"visibility never grants row access."
|
||||
@@ -391,7 +535,14 @@ manifest = ModuleManifest(
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "data_steward", "product_owner"),
|
||||
related_modules=("policy", "organizations", "idm", "dataflow", "reporting", "risk_compliance"),
|
||||
related_modules=(
|
||||
"policy",
|
||||
"organizations",
|
||||
"idm",
|
||||
"dataflow",
|
||||
"reporting",
|
||||
"risk_compliance",
|
||||
),
|
||||
order=71,
|
||||
metadata={
|
||||
"seed": True,
|
||||
@@ -403,6 +554,38 @@ manifest = ModuleManifest(
|
||||
"datasources.field.publication-state",
|
||||
"datasources.field.freshness-policy",
|
||||
"datasources.field.quality-policy",
|
||||
"datasources.field.retention-policy",
|
||||
"datasources.field.access-policy",
|
||||
"datasources.field.visibility-policy",
|
||||
"datasources.field.transfer-agreement",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="datasources.visibility",
|
||||
title="Datasource and field visibility",
|
||||
summary="Apply source, materialization, field, and row controls before protected rows leave Datasources.",
|
||||
body=(
|
||||
"A local visibility policy can restrict discovery and reads by account, membership, identity, group, role, service account, or authentication method. "
|
||||
"Materialization ACLs additionally protect current and frozen revisions. Field rules classify a field and either replace its value with null or omit the field unless an allow ACL matches. Row filters compare a configured field with a principal account, membership, identity, service-account, group, role, or function-assignment claim; all configured filters and all policy overlays must allow a row. "
|
||||
"Datasources applies these controls before rows leave its provider boundary and returns an opaque permitted-view fingerprint bound to the base revision, effective policies, and relevant principal facts. Denied and filtered reads emit audit evidence containing outcomes and policy diagnostics, never protected values, provider locators, arbitrary metadata, or provenance bodies. "
|
||||
"An optional access-policy reference delegates hierarchical overlays to Policy. Policy can only tighten local behavior. An unresolved reference, unavailable configured provider, or malformed policy fails closed. Without Policy and without a reference, the local visibility policy remains fully usable. Frozen reads enforce the current local policy together with a distinct restrictive policy captured in the frozen governance snapshot."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "data_steward", "auditor"),
|
||||
related_modules=("policy", "access", "audit", "connectors"),
|
||||
order=72,
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"datasources.field.access-policy",
|
||||
"datasources.field.visibility-policy",
|
||||
"datasources.preview",
|
||||
],
|
||||
"limitations": [
|
||||
"Row filters support deterministic equality or membership against principal claims; arbitrary expressions are not executed.",
|
||||
"Live filtered previews scan at most 10,000 origin rows and disclose a structured limitation diagnostic if the origin is larger.",
|
||||
],
|
||||
},
|
||||
),
|
||||
@@ -416,7 +599,11 @@ manifest = ModuleManifest(
|
||||
"promotion. Updates compare the detected schema with the current target and classify each change as compatible, warning, or "
|
||||
"breaking. Diagnostics expose counts and bounded row numbers, never field values. The policy version and hash, diagnostics, and "
|
||||
"schema diff are copied into immutable materialization provenance when promotion succeeds. Producer publication applies the same "
|
||||
"gate before any catalogue effect, retains validation evidence on the immutable output revision, serializes a publication identity across PostgreSQL worker nodes before replay lookup, and emits a transactional terminal event. Approval and retention execution "
|
||||
"gate before any catalogue effect, retains validation evidence on the immutable output revision, serializes a publication identity across PostgreSQL worker nodes before replay lookup, and emits a transactional terminal event. Large outputs may instead supply a "
|
||||
"durable provider-neutral artifact reference with a pinned locator, SHA-256 checksum, declared schema, fingerprint, and size. "
|
||||
"The configured payload backend verifies the artifact and provides bounded reads. Content-level rules require checksum- and "
|
||||
"policy-bound producer evidence; absent evidence creates an immutable review-required materialization without changing the "
|
||||
"Datasource's current state. Warning and review-required outcomes are preserved for Workflow handoffs. Approval and retention execution "
|
||||
"are not inferred from arbitrary JSON flags and remain separate governed lifecycle work."
|
||||
),
|
||||
layer="available",
|
||||
@@ -429,8 +616,14 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("policy", "approvals", "audit", "dataflow", "workflow_engine"),
|
||||
order=72,
|
||||
related_modules=(
|
||||
"policy",
|
||||
"approvals",
|
||||
"audit",
|
||||
"dataflow",
|
||||
"workflow_engine",
|
||||
),
|
||||
order=73,
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
@@ -442,6 +635,7 @@ manifest = ModuleManifest(
|
||||
"limitations": [
|
||||
"Referential rules currently use a bounded embedded value set rather than reading another protected Datasource.",
|
||||
"Approval authority and automatic retention execution are not part of the current stage contract.",
|
||||
"Artifact bytes remain owned by their payload backend; Datasources stores an immutable reference and integrity evidence.",
|
||||
],
|
||||
},
|
||||
),
|
||||
@@ -461,8 +655,14 @@ manifest = ModuleManifest(
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||
related_modules=("connectors", "dataflow", "workflow_engine", "reporting", "audit"),
|
||||
order=73,
|
||||
related_modules=(
|
||||
"connectors",
|
||||
"dataflow",
|
||||
"workflow_engine",
|
||||
"reporting",
|
||||
"audit",
|
||||
),
|
||||
order=74,
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"""v0.1.20 datasource visibility policy
|
||||
|
||||
Revision ID: c9e3a6f1d4b8
|
||||
Revises: b8d2f5a0c3e7
|
||||
Create Date: 2026-08-21 20:15:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c9e3a6f1d4b8"
|
||||
down_revision = "b8d2f5a0c3e7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"datasource_catalogue",
|
||||
sa.Column("access_policy_ref", sa.String(length=500), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"datasource_catalogue",
|
||||
sa.Column(
|
||||
"visibility_policy",
|
||||
sa.JSON(),
|
||||
server_default=sa.text("'{}'"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("datasource_catalogue", "visibility_policy")
|
||||
op.drop_column("datasource_catalogue", "access_policy_ref")
|
||||
@@ -9,6 +9,9 @@ from sqlalchemy import delete, func, insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.datasources import (
|
||||
DatasourceArtifactBackend,
|
||||
DatasourceArtifactReference,
|
||||
DatasourceField,
|
||||
DatasourceUnavailableError,
|
||||
DatasourceValidationError,
|
||||
)
|
||||
@@ -116,6 +119,91 @@ class DatabaseRowsPayloadBackend:
|
||||
)
|
||||
|
||||
|
||||
class ExternalArtifactPayloadBackend:
|
||||
"""Adapts a Core artifact backend to persisted Datasources payload rows."""
|
||||
|
||||
def __init__(self, backend: DatasourceArtifactBackend) -> None:
|
||||
self._backend = backend
|
||||
self.backend = backend.backend
|
||||
|
||||
def read_rows(
|
||||
self,
|
||||
session: Session,
|
||||
payload: DatasourcePayloadRecord,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> Sequence[Mapping[str, object]]:
|
||||
return self._backend.read_rows(
|
||||
session,
|
||||
tenant_id=payload.tenant_id,
|
||||
artifact=_artifact_from_payload(payload),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
session: Session,
|
||||
payload: DatasourcePayloadRecord,
|
||||
) -> None:
|
||||
self._backend.verify(
|
||||
session,
|
||||
tenant_id=payload.tenant_id,
|
||||
artifact=_artifact_from_payload(payload),
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
payload: DatasourcePayloadRecord,
|
||||
) -> None:
|
||||
self._backend.delete(
|
||||
session,
|
||||
tenant_id=payload.tenant_id,
|
||||
artifact=_artifact_from_payload(payload),
|
||||
)
|
||||
|
||||
|
||||
def _artifact_from_payload(
|
||||
payload: DatasourcePayloadRecord,
|
||||
) -> DatasourceArtifactReference:
|
||||
metadata = dict(payload.metadata_)
|
||||
raw_schema = metadata.get("artifact_schema")
|
||||
schema = tuple(
|
||||
DatasourceField(
|
||||
name=str(item.get("name") or ""),
|
||||
data_type=str(item.get("data_type") or "unknown"),
|
||||
nullable=bool(item.get("nullable", True)),
|
||||
)
|
||||
for item in raw_schema
|
||||
if isinstance(item, Mapping)
|
||||
) if isinstance(raw_schema, Sequence) and not isinstance(
|
||||
raw_schema, (str, bytes)
|
||||
) else ()
|
||||
return DatasourceArtifactReference(
|
||||
backend=payload.backend,
|
||||
locator=str(payload.locator or ""),
|
||||
checksum=payload.checksum,
|
||||
row_count=payload.row_count,
|
||||
byte_count=payload.byte_count,
|
||||
schema=schema,
|
||||
fingerprint=str(metadata.get("publication_fingerprint") or ""),
|
||||
media_type=payload.media_type,
|
||||
checkpoint=dict(payload.checkpoint_),
|
||||
metadata={
|
||||
str(key): value
|
||||
for key, value in metadata.items()
|
||||
if key not in {"artifact_schema", "artifact_validation"}
|
||||
},
|
||||
validation=(
|
||||
dict(metadata.get("artifact_validation"))
|
||||
if isinstance(metadata.get("artifact_validation"), Mapping)
|
||||
else {}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PayloadBackendRegistry:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -363,6 +451,7 @@ __all__ = [
|
||||
"DATABASE_ROWS_BACKEND",
|
||||
"DatabaseRowsPayloadBackend",
|
||||
"DatasourcePayloadBackend",
|
||||
"ExternalArtifactPayloadBackend",
|
||||
"PayloadBackendRegistry",
|
||||
"create_database_rows_payload",
|
||||
"create_external_payload_reference",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -370,7 +372,17 @@ def api_update_datasource_governance(
|
||||
action="datasources.governance.updated",
|
||||
object_type="datasource",
|
||||
object_id=item.ref,
|
||||
details={"governance": item.governance.to_dict()},
|
||||
details={
|
||||
"classification": item.governance.classification,
|
||||
"publication_state": item.governance.publication_state,
|
||||
"access_policy_ref": item.governance.access_policy_ref,
|
||||
"visibility_policy_configured": bool(
|
||||
item.governance.visibility_policy
|
||||
),
|
||||
"visibility_policy_hash": _mapping_hash(
|
||||
item.governance.visibility_policy
|
||||
),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _datasource_response(item)
|
||||
@@ -580,6 +592,7 @@ def _datasource_response(item: DatasourceDescriptor) -> DatasourceResponse:
|
||||
name=field.name,
|
||||
data_type=field.data_type,
|
||||
nullable=field.nullable,
|
||||
classification=field.classification,
|
||||
)
|
||||
for field in item.schema
|
||||
],
|
||||
@@ -610,6 +623,7 @@ def _materialization_response(
|
||||
name=field.name,
|
||||
data_type=field.data_type,
|
||||
nullable=field.nullable,
|
||||
classification=field.classification,
|
||||
)
|
||||
for field in item.schema
|
||||
],
|
||||
@@ -643,6 +657,7 @@ def _stage_response(item: DatasourceStage) -> DatasourceStageResponse:
|
||||
name=field.name,
|
||||
data_type=field.data_type,
|
||||
nullable=field.nullable,
|
||||
classification=field.classification,
|
||||
)
|
||||
for field in item.schema
|
||||
],
|
||||
@@ -673,6 +688,7 @@ def _origin_response(item: DatasourceOrigin) -> DatasourceOriginResponse:
|
||||
name=field.name,
|
||||
data_type=field.data_type,
|
||||
nullable=field.nullable,
|
||||
classification=field.classification,
|
||||
)
|
||||
for field in item.schema
|
||||
],
|
||||
@@ -715,6 +731,13 @@ def _safe_mapping(value: object) -> Mapping[str, object]:
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _mapping_hash(value: Mapping[str, object]) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
|
||||
@@ -43,6 +43,8 @@ class DatasourceGovernancePayload(BaseModel):
|
||||
classification: str = Field(default="internal", min_length=1, max_length=80)
|
||||
privacy_profile_ref: str | None = Field(default=None, max_length=500)
|
||||
retention_policy_ref: str | None = Field(default=None, max_length=500)
|
||||
access_policy_ref: str | None = Field(default=None, max_length=500)
|
||||
visibility_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
hold_refs: list[str] = Field(default_factory=list, max_length=100)
|
||||
publication_state: str = Field(default="draft", min_length=1, max_length=50)
|
||||
transfer_agreement_ref: str | None = Field(default=None, max_length=500)
|
||||
@@ -70,6 +72,7 @@ class DatasourceFieldResponse(BaseModel):
|
||||
name: str
|
||||
data_type: str
|
||||
nullable: bool
|
||||
classification: str = "internal"
|
||||
|
||||
|
||||
class DatasourceResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_datasources.backend.db.models import DatasourceRecord
|
||||
from govoplan_datasources.backend.service import ADMIN_SCOPE, CATALOGUE_READ_SCOPE
|
||||
|
||||
|
||||
PROVIDER_ID = "datasources.catalogue"
|
||||
RESOURCE_TYPE = "datasource"
|
||||
|
||||
|
||||
class DatasourcesSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="datasources",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Datasources",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
_assert_source(request.provider_id, request.resource_type)
|
||||
db = _session(session)
|
||||
statement = select(DatasourceRecord).where(
|
||||
DatasourceRecord.tenant_id == request.tenant_id,
|
||||
DatasourceRecord.deleted_at.is_(None),
|
||||
)
|
||||
if request.cursor:
|
||||
statement = statement.where(DatasourceRecord.id > request.cursor)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
statement.order_by(DatasourceRecord.id).limit(request.limit + 1)
|
||||
).all()
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(DatasourceRecord.updated_at)).where(
|
||||
DatasourceRecord.tenant_id == request.tenant_id,
|
||||
DatasourceRecord.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(_document(row) for row in selected),
|
||||
next_cursor=selected[-1].id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
if not isinstance(principal, ApiPrincipal) or not (
|
||||
principal.has(CATALOGUE_READ_SCOPE) or principal.has(ADMIN_SCOPE)
|
||||
):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
eligible = [
|
||||
request
|
||||
for request in requests
|
||||
if request.reference.tenant_id == principal.tenant_id
|
||||
and request.reference.module_id == "datasources"
|
||||
and request.reference.resource_type == RESOURCE_TYPE
|
||||
]
|
||||
resource_ids = {request.reference.resource_id for request in eligible}
|
||||
available_ids = (
|
||||
set(
|
||||
db.scalars(
|
||||
select(DatasourceRecord.id).where(
|
||||
DatasourceRecord.tenant_id == principal.tenant_id,
|
||||
DatasourceRecord.id.in_(resource_ids),
|
||||
DatasourceRecord.deleted_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if resource_ids
|
||||
else set()
|
||||
)
|
||||
for request in eligible:
|
||||
decisions[request.reference.key] = (
|
||||
request.reference.resource_id in available_ids
|
||||
)
|
||||
return decisions
|
||||
|
||||
|
||||
def create_datasources_search_source(
|
||||
_context: ModuleContext,
|
||||
) -> DatasourcesSearchSource:
|
||||
return DatasourcesSearchSource()
|
||||
|
||||
|
||||
def _document(row: DatasourceRecord) -> SearchDocument:
|
||||
datasource_ref = quote(f"datasource:{row.id}", safe="")
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="datasources",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=row.id,
|
||||
title=row.name,
|
||||
url=f"/datasources?datasource={datasource_ref}",
|
||||
summary=(row.description or row.source_name)[:4000],
|
||||
keywords=tuple(
|
||||
value[:200]
|
||||
for value in (
|
||||
row.source_name,
|
||||
row.kind,
|
||||
row.mode,
|
||||
row.shape,
|
||||
row.status,
|
||||
row.classification,
|
||||
row.publication_state,
|
||||
)
|
||||
if value
|
||||
),
|
||||
visibility="restricted",
|
||||
acl_tokens=(
|
||||
f"scope:{CATALOGUE_READ_SCOPE}",
|
||||
f"scope:{ADMIN_SCOPE}",
|
||||
),
|
||||
metadata={
|
||||
"kind": row.kind,
|
||||
"mode": row.mode,
|
||||
"shape": row.shape,
|
||||
"status": row.status,
|
||||
"classification": row.classification,
|
||||
"publication_state": row.publication_state,
|
||||
"authority_mode": row.authority_mode,
|
||||
},
|
||||
source_revision=f"{row.schema_version}:{row.updated_at.isoformat()}",
|
||||
source_updated_at=row.updated_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Datasources search source.")
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Datasources search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DatasourcesSearchSource",
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPE",
|
||||
"create_datasources_search_source",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ def field_payload(field: DatasourceField) -> dict[str, object]:
|
||||
"name": field.name,
|
||||
"data_type": field.data_type,
|
||||
"nullable": field.nullable,
|
||||
"classification": field.classification,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.datasources import (
|
||||
DatasourceAccessError,
|
||||
DatasourceField,
|
||||
DatasourceValidationError,
|
||||
)
|
||||
from govoplan_core.core.tabular_sources import TabularPreviewDiagnostic
|
||||
|
||||
|
||||
_ACL_KEYS = frozenset(
|
||||
{
|
||||
"account_ids",
|
||||
"membership_ids",
|
||||
"identity_ids",
|
||||
"group_ids",
|
||||
"role_ids",
|
||||
"service_account_ids",
|
||||
"auth_methods",
|
||||
}
|
||||
)
|
||||
_CLAIM_KEYS = frozenset(
|
||||
{
|
||||
"account_id",
|
||||
"membership_id",
|
||||
"identity_id",
|
||||
"service_account_id",
|
||||
"group_ids",
|
||||
"role_ids",
|
||||
"function_assignment_ids",
|
||||
}
|
||||
)
|
||||
_POLICY_KEYS = frozenset({"source_acl", "materialization_acl", "fields", "row_filters"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FieldRule:
|
||||
name: str
|
||||
classification: str
|
||||
action: str
|
||||
allowed: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RowFilter:
|
||||
field: str
|
||||
claim: str
|
||||
operator: str
|
||||
allow_null: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VisibilityPlan:
|
||||
applied: bool
|
||||
policy_hashes: tuple[str, ...]
|
||||
field_rules: tuple[FieldRule, ...]
|
||||
row_filters: tuple[RowFilter, ...]
|
||||
principal_fingerprint: str
|
||||
decision_refs: tuple[str, ...] = ()
|
||||
|
||||
def view_fingerprint(self, base_fingerprint: str) -> str:
|
||||
if not self.applied:
|
||||
return base_fingerprint
|
||||
return _digest(
|
||||
{
|
||||
"base_fingerprint": base_fingerprint,
|
||||
"policy_hashes": self.policy_hashes,
|
||||
"principal_fingerprint": self.principal_fingerprint,
|
||||
"decision_refs": self.decision_refs,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def visibility_plan(
|
||||
*,
|
||||
principal: PrincipalRef,
|
||||
policies: Sequence[Mapping[str, object]],
|
||||
materialized: bool,
|
||||
schema: Sequence[DatasourceField],
|
||||
decision_refs: Sequence[str] = (),
|
||||
) -> VisibilityPlan:
|
||||
normalized = tuple(_normalize_policy(policy) for policy in policies if policy)
|
||||
if not normalized:
|
||||
return VisibilityPlan(
|
||||
applied=False,
|
||||
policy_hashes=(),
|
||||
field_rules=(),
|
||||
row_filters=(),
|
||||
principal_fingerprint="",
|
||||
)
|
||||
|
||||
schema_names = {field.name for field in schema}
|
||||
field_rules: list[FieldRule] = []
|
||||
row_filters: list[RowFilter] = []
|
||||
for policy in normalized:
|
||||
source_acl = policy.get("source_acl")
|
||||
if isinstance(source_acl, Mapping) and not _acl_allows(source_acl, principal):
|
||||
raise DatasourceAccessError("Datasource visibility policy denied access.")
|
||||
materialization_acl = policy.get("materialization_acl")
|
||||
if (
|
||||
materialized
|
||||
and isinstance(materialization_acl, Mapping)
|
||||
and not _acl_allows(materialization_acl, principal)
|
||||
):
|
||||
raise DatasourceAccessError(
|
||||
"Datasource materialization policy denied access."
|
||||
)
|
||||
fields = policy.get("fields", {})
|
||||
if isinstance(fields, Mapping):
|
||||
for name, raw_rule in fields.items():
|
||||
if name not in schema_names:
|
||||
raise DatasourceValidationError(
|
||||
f"Visibility policy references unknown field {name!r}."
|
||||
)
|
||||
assert isinstance(raw_rule, Mapping)
|
||||
field_rules.append(
|
||||
FieldRule(
|
||||
name=name,
|
||||
classification=str(raw_rule["classification"]),
|
||||
action=str(raw_rule["action"]),
|
||||
allowed=_acl_allows(raw_rule["allow"], principal),
|
||||
)
|
||||
)
|
||||
for raw_filter in policy.get("row_filters", ()):
|
||||
assert isinstance(raw_filter, Mapping)
|
||||
field = str(raw_filter["field"])
|
||||
if field not in schema_names:
|
||||
raise DatasourceValidationError(
|
||||
f"Visibility policy references unknown row-filter field {field!r}."
|
||||
)
|
||||
row_filters.append(
|
||||
RowFilter(
|
||||
field=field,
|
||||
claim=str(raw_filter["claim"]),
|
||||
operator=str(raw_filter["operator"]),
|
||||
allow_null=bool(raw_filter["allow_null"]),
|
||||
)
|
||||
)
|
||||
|
||||
all_facts = _principal_policy_facts(principal)
|
||||
relevant_fact_keys = _relevant_policy_fact_keys(normalized)
|
||||
principal_payload = {key: all_facts[key] for key in sorted(relevant_fact_keys)}
|
||||
return VisibilityPlan(
|
||||
applied=True,
|
||||
policy_hashes=tuple(_digest(policy) for policy in normalized),
|
||||
field_rules=tuple(field_rules),
|
||||
row_filters=tuple(row_filters),
|
||||
principal_fingerprint=_digest(principal_payload),
|
||||
decision_refs=tuple(sorted(set(decision_refs))),
|
||||
)
|
||||
|
||||
|
||||
def normalize_visibility_policy(
|
||||
policy: Mapping[str, object],
|
||||
*,
|
||||
schema: Sequence[DatasourceField] = (),
|
||||
) -> Mapping[str, object]:
|
||||
normalized = _normalize_policy(policy)
|
||||
if schema:
|
||||
known = {field.name for field in schema}
|
||||
configured = set(cast(Mapping[str, object], normalized.get("fields", {})))
|
||||
configured.update(
|
||||
str(item["field"])
|
||||
for item in cast(
|
||||
Sequence[Mapping[str, object]], normalized.get("row_filters", ())
|
||||
)
|
||||
)
|
||||
unknown = sorted(configured - known)
|
||||
if unknown:
|
||||
raise DatasourceValidationError(
|
||||
f"Visibility policy references unknown fields: {', '.join(unknown)}."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def visible_schema(
|
||||
schema: Sequence[DatasourceField],
|
||||
plan: VisibilityPlan,
|
||||
) -> tuple[DatasourceField, ...]:
|
||||
classifications: dict[str, str] = {}
|
||||
omitted: set[str] = set()
|
||||
for rule in plan.field_rules:
|
||||
classifications[rule.name] = rule.classification
|
||||
if not rule.allowed and rule.action == "omit":
|
||||
omitted.add(rule.name)
|
||||
return tuple(
|
||||
DatasourceField(
|
||||
name=field.name,
|
||||
data_type=field.data_type,
|
||||
nullable=field.nullable,
|
||||
classification=classifications.get(field.name, field.classification),
|
||||
)
|
||||
for field in schema
|
||||
if field.name not in omitted
|
||||
)
|
||||
|
||||
|
||||
def apply_visibility(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
*,
|
||||
principal: PrincipalRef,
|
||||
plan: VisibilityPlan,
|
||||
columns: Sequence[str] = (),
|
||||
) -> tuple[tuple[Mapping[str, object], ...], int]:
|
||||
omitted = {
|
||||
rule.name
|
||||
for rule in plan.field_rules
|
||||
if not rule.allowed and rule.action == "omit"
|
||||
}
|
||||
redacted = {
|
||||
rule.name
|
||||
for rule in plan.field_rules
|
||||
if not rule.allowed and rule.action == "redact"
|
||||
}
|
||||
requested = tuple(dict.fromkeys(columns))
|
||||
result: list[Mapping[str, object]] = []
|
||||
filtered = 0
|
||||
for source_row in rows:
|
||||
if not all(
|
||||
_row_filter_allows(source_row, item, principal) for item in plan.row_filters
|
||||
):
|
||||
filtered += 1
|
||||
continue
|
||||
names = requested or tuple(source_row)
|
||||
result.append(
|
||||
{
|
||||
name: None if name in redacted else source_row.get(name)
|
||||
for name in names
|
||||
if name not in omitted
|
||||
}
|
||||
)
|
||||
return tuple(result), filtered
|
||||
|
||||
|
||||
def visibility_diagnostics(
|
||||
plan: VisibilityPlan,
|
||||
*,
|
||||
scanned_rows: int,
|
||||
permitted_rows: int,
|
||||
filtered_rows: int,
|
||||
scan_limited: bool,
|
||||
) -> tuple[TabularPreviewDiagnostic, ...]:
|
||||
if not plan.applied:
|
||||
return ()
|
||||
diagnostics = [
|
||||
TabularPreviewDiagnostic(
|
||||
severity="info",
|
||||
code="datasource.visibility_applied",
|
||||
message="Datasource visibility policy was applied before rows left the provider.",
|
||||
details={
|
||||
"policy_count": len(plan.policy_hashes),
|
||||
"field_rule_count": len(plan.field_rules),
|
||||
"row_filter_count": len(plan.row_filters),
|
||||
"scanned_rows": scanned_rows,
|
||||
"permitted_rows": permitted_rows,
|
||||
"filtered_rows": filtered_rows,
|
||||
},
|
||||
)
|
||||
]
|
||||
if scan_limited:
|
||||
diagnostics.append(
|
||||
TabularPreviewDiagnostic(
|
||||
severity="warning",
|
||||
code="datasource.visibility_scan_limited",
|
||||
message="The bounded visibility scan ended before the origin was exhausted.",
|
||||
details={"scanned_rows": scanned_rows},
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def _normalize_policy(policy: Mapping[str, object]) -> dict[str, object]:
|
||||
unknown = set(policy) - _POLICY_KEYS
|
||||
if unknown:
|
||||
raise DatasourceValidationError(
|
||||
f"Unsupported visibility policy keys: {', '.join(sorted(unknown))}."
|
||||
)
|
||||
result: dict[str, object] = {}
|
||||
for name in ("source_acl", "materialization_acl"):
|
||||
if name in policy:
|
||||
result[name] = _normalize_acl(policy[name], path=name)
|
||||
|
||||
raw_fields = policy.get("fields", {})
|
||||
if not isinstance(raw_fields, Mapping) or len(raw_fields) > 500:
|
||||
raise DatasourceValidationError(
|
||||
"Visibility policy fields must be a mapping of at most 500 fields."
|
||||
)
|
||||
fields: dict[str, object] = {}
|
||||
for raw_name, raw_rule in sorted(raw_fields.items(), key=lambda item: str(item[0])):
|
||||
name = str(raw_name).strip()
|
||||
if not name or not isinstance(raw_rule, Mapping):
|
||||
raise DatasourceValidationError(
|
||||
"Every visibility field rule needs a field name and mapping."
|
||||
)
|
||||
unknown_rule = set(raw_rule) - {"classification", "action", "allow"}
|
||||
if unknown_rule:
|
||||
raise DatasourceValidationError(
|
||||
f"Unsupported visibility field keys for {name!r}: {', '.join(sorted(unknown_rule))}."
|
||||
)
|
||||
classification = str(raw_rule.get("classification") or "restricted").strip()
|
||||
if not classification or len(classification) > 80:
|
||||
raise DatasourceValidationError(
|
||||
"Field classifications must contain 1 to 80 characters."
|
||||
)
|
||||
action = str(raw_rule.get("action") or "redact").strip()
|
||||
if action not in {"redact", "omit"}:
|
||||
raise DatasourceValidationError(
|
||||
"Field visibility action must be redact or omit."
|
||||
)
|
||||
if "allow" not in raw_rule:
|
||||
raise DatasourceValidationError(
|
||||
f"Visibility field rule {name!r} needs an allow ACL."
|
||||
)
|
||||
fields[name] = {
|
||||
"classification": classification,
|
||||
"action": action,
|
||||
"allow": _normalize_acl(raw_rule["allow"], path=f"fields.{name}.allow"),
|
||||
}
|
||||
if fields:
|
||||
result["fields"] = fields
|
||||
|
||||
raw_filters = policy.get("row_filters", ())
|
||||
if not isinstance(raw_filters, Sequence) or isinstance(raw_filters, (str, bytes)):
|
||||
raise DatasourceValidationError("Visibility row_filters must be a list.")
|
||||
if len(raw_filters) > 50:
|
||||
raise DatasourceValidationError(
|
||||
"Visibility policy supports at most 50 row filters."
|
||||
)
|
||||
filters: list[dict[str, object]] = []
|
||||
for raw_filter in raw_filters:
|
||||
if not isinstance(raw_filter, Mapping):
|
||||
raise DatasourceValidationError(
|
||||
"Every visibility row filter must be a mapping."
|
||||
)
|
||||
unknown_filter = set(raw_filter) - {"field", "claim", "operator", "allow_null"}
|
||||
if unknown_filter:
|
||||
raise DatasourceValidationError(
|
||||
f"Unsupported row-filter keys: {', '.join(sorted(unknown_filter))}."
|
||||
)
|
||||
field = str(raw_filter.get("field") or "").strip()
|
||||
claim = str(raw_filter.get("claim") or "").strip()
|
||||
operator = str(raw_filter.get("operator") or "equals").strip()
|
||||
if not field:
|
||||
raise DatasourceValidationError(
|
||||
"Every visibility row filter needs a field."
|
||||
)
|
||||
if claim not in _CLAIM_KEYS:
|
||||
raise DatasourceValidationError(
|
||||
f"Unsupported visibility row-filter claim {claim!r}."
|
||||
)
|
||||
if operator not in {"equals", "in"}:
|
||||
raise DatasourceValidationError("Row-filter operator must be equals or in.")
|
||||
filters.append(
|
||||
{
|
||||
"field": field,
|
||||
"claim": claim,
|
||||
"operator": operator,
|
||||
"allow_null": bool(raw_filter.get("allow_null", False)),
|
||||
}
|
||||
)
|
||||
if filters:
|
||||
result["row_filters"] = filters
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_acl(value: object, *, path: str) -> dict[str, list[str]]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise DatasourceValidationError(
|
||||
f"Visibility policy {path} must be an ACL mapping."
|
||||
)
|
||||
unknown = set(value) - _ACL_KEYS
|
||||
if unknown:
|
||||
raise DatasourceValidationError(
|
||||
f"Unsupported ACL selectors in {path}: {', '.join(sorted(unknown))}."
|
||||
)
|
||||
result: dict[str, list[str]] = {}
|
||||
for key in sorted(value):
|
||||
raw_values = value[key]
|
||||
if not isinstance(raw_values, Sequence) or isinstance(raw_values, (str, bytes)):
|
||||
raise DatasourceValidationError(
|
||||
f"Visibility ACL selector {path}.{key} must be a list."
|
||||
)
|
||||
if len(raw_values) > 250:
|
||||
raise DatasourceValidationError(
|
||||
f"Visibility ACL selector {path}.{key} is limited to 250 entries."
|
||||
)
|
||||
normalized = sorted(
|
||||
{str(item).strip() for item in raw_values if str(item).strip()}
|
||||
)
|
||||
result[key] = normalized
|
||||
return result
|
||||
|
||||
|
||||
def _acl_allows(acl: Mapping[str, object], principal: PrincipalRef) -> bool:
|
||||
facts = _principal_policy_facts(principal)
|
||||
return any(set(values) & set(facts.get(key, ())) for key, values in acl.items())
|
||||
|
||||
|
||||
def _principal_policy_facts(principal: PrincipalRef) -> dict[str, tuple[str, ...]]:
|
||||
return {
|
||||
"account_ids": _optional_tuple(principal.account_id),
|
||||
"membership_ids": _optional_tuple(principal.membership_id),
|
||||
"identity_ids": _optional_tuple(principal.identity_id),
|
||||
"group_ids": tuple(sorted(principal.group_ids)),
|
||||
"role_ids": tuple(sorted(principal.role_ids)),
|
||||
"service_account_ids": _optional_tuple(principal.service_account_id),
|
||||
"auth_methods": (principal.auth_method,),
|
||||
"function_assignment_ids": tuple(sorted(principal.function_assignment_ids)),
|
||||
}
|
||||
|
||||
|
||||
def _claim_values(principal: PrincipalRef, claim: str) -> tuple[str, ...]:
|
||||
facts = _principal_policy_facts(principal)
|
||||
aliases = {
|
||||
"account_id": "account_ids",
|
||||
"membership_id": "membership_ids",
|
||||
"identity_id": "identity_ids",
|
||||
"service_account_id": "service_account_ids",
|
||||
}
|
||||
return facts.get(aliases.get(claim, claim), ())
|
||||
|
||||
|
||||
def _relevant_policy_fact_keys(
|
||||
policies: Sequence[Mapping[str, object]],
|
||||
) -> set[str]:
|
||||
keys: set[str] = set()
|
||||
aliases = {
|
||||
"account_id": "account_ids",
|
||||
"membership_id": "membership_ids",
|
||||
"identity_id": "identity_ids",
|
||||
"service_account_id": "service_account_ids",
|
||||
}
|
||||
for policy in policies:
|
||||
for acl_name in ("source_acl", "materialization_acl"):
|
||||
acl = policy.get(acl_name)
|
||||
if isinstance(acl, Mapping):
|
||||
keys.update(str(key) for key in acl)
|
||||
fields = policy.get("fields")
|
||||
if isinstance(fields, Mapping):
|
||||
for rule in fields.values():
|
||||
if isinstance(rule, Mapping) and isinstance(rule.get("allow"), Mapping):
|
||||
keys.update(str(key) for key in rule["allow"])
|
||||
for row_filter in policy.get("row_filters", ()):
|
||||
if isinstance(row_filter, Mapping):
|
||||
claim = str(row_filter.get("claim") or "")
|
||||
keys.add(aliases.get(claim, claim))
|
||||
return keys
|
||||
|
||||
|
||||
def _row_filter_allows(
|
||||
row: Mapping[str, object],
|
||||
item: RowFilter,
|
||||
principal: PrincipalRef,
|
||||
) -> bool:
|
||||
value = row.get(item.field)
|
||||
if value is None:
|
||||
return item.allow_null
|
||||
claims = set(_claim_values(principal, item.claim))
|
||||
if not claims:
|
||||
return False
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
values = {str(candidate) for candidate in value}
|
||||
return bool(values & claims)
|
||||
return str(value) in claims
|
||||
|
||||
|
||||
def _optional_tuple(value: object | None) -> tuple[str, ...]:
|
||||
return (str(value),) if value is not None and str(value) else ()
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"VisibilityPlan",
|
||||
"apply_visibility",
|
||||
"normalize_visibility_policy",
|
||||
"visibility_diagnostics",
|
||||
"visibility_plan",
|
||||
"visible_schema",
|
||||
]
|
||||
@@ -0,0 +1,581 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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_datasources.backend.db.models import (
|
||||
DatasourceGovernanceReferenceRecord,
|
||||
DatasourceMaterializationRecord,
|
||||
DatasourcePayloadRecord,
|
||||
DatasourcePayloadRowRecord,
|
||||
DatasourcePublicationRecord,
|
||||
DatasourceRecord,
|
||||
DatasourceStageRecord,
|
||||
)
|
||||
from govoplan_datasources.backend.dsar_provider import (
|
||||
DATASOURCES_DSAR_CAPABILITY,
|
||||
DatasourcesDsarProvider,
|
||||
)
|
||||
from govoplan_datasources.backend.manifest import manifest
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 19, 0, tzinfo=UTC)
|
||||
SECRET = "private-datasource-detail-do-not-export"
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(
|
||||
self,
|
||||
provider: DatasourcesDsarProvider,
|
||||
*,
|
||||
active: bool = True,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.active = active
|
||||
|
||||
def capability_names(self):
|
||||
return (DATASOURCES_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "datasources"
|
||||
|
||||
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": ("datasources",) 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": "datasources"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != DATASOURCES_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class DatasourcesDsarProviderTests(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 = DatasourcesDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
self._seed()
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed(self) -> None:
|
||||
datasource = self._datasource("datasource-1", "tenant-1")
|
||||
other = self._datasource("datasource-other", "tenant-2")
|
||||
self.session.add_all((datasource, other))
|
||||
self.session.flush()
|
||||
referenced_payload = self._payload(
|
||||
"payload-referenced",
|
||||
"tenant-1",
|
||||
created_by="account-1",
|
||||
)
|
||||
orphan_payload = self._payload(
|
||||
"payload-orphan",
|
||||
"tenant-1",
|
||||
created_by="account-1",
|
||||
)
|
||||
self.session.add_all((referenced_payload, orphan_payload))
|
||||
self.session.flush()
|
||||
self.session.add(
|
||||
DatasourcePayloadRowRecord(
|
||||
payload_id=referenced_payload.id,
|
||||
row_index=0,
|
||||
row_={"secret": SECRET},
|
||||
checksum="a" * 64,
|
||||
)
|
||||
)
|
||||
materialization = DatasourceMaterializationRecord(
|
||||
id="materialization-1",
|
||||
tenant_id="tenant-1",
|
||||
datasource_id=datasource.id,
|
||||
revision=1,
|
||||
state="published",
|
||||
schema_version=1,
|
||||
schema_=[{"secret": SECRET}],
|
||||
payload_id=referenced_payload.id,
|
||||
payload_checksum="b" * 64,
|
||||
rows=[{"secret": SECRET}],
|
||||
fingerprint="c" * 64,
|
||||
row_count=1,
|
||||
byte_count=100,
|
||||
source_timestamp=NOW,
|
||||
provenance_={"secret": SECRET},
|
||||
metadata_={"secret": SECRET},
|
||||
governance_snapshot_={"secret": SECRET},
|
||||
created_by="account-1",
|
||||
)
|
||||
self.session.add(materialization)
|
||||
self.session.flush()
|
||||
datasource.current_materialization_id = materialization.id
|
||||
self.session.add_all(
|
||||
(
|
||||
DatasourceGovernanceReferenceRecord(
|
||||
id="governance-1",
|
||||
tenant_id="tenant-1",
|
||||
datasource_id=datasource.id,
|
||||
relation="authoritative_source",
|
||||
reference=SECRET,
|
||||
),
|
||||
self._stage(
|
||||
"stage-1",
|
||||
target_datasource_id=datasource.id,
|
||||
promoted=False,
|
||||
),
|
||||
self._stage(
|
||||
"stage-promoted",
|
||||
target_datasource_id=datasource.id,
|
||||
promoted=True,
|
||||
),
|
||||
DatasourcePublicationRecord(
|
||||
id="publication-1",
|
||||
tenant_id="tenant-1",
|
||||
producer_module="dataflow",
|
||||
producer_run_ref=SECRET,
|
||||
idempotency_key=SECRET,
|
||||
request_hash="d" * 64,
|
||||
datasource_id=datasource.id,
|
||||
materialization_id=materialization.id,
|
||||
status="published",
|
||||
details_={"secret": SECRET},
|
||||
created_by="account-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _datasource(row_id: str, tenant_id: str) -> DatasourceRecord:
|
||||
return DatasourceRecord(
|
||||
id=row_id,
|
||||
tenant_id=tenant_id,
|
||||
source_name=f"source-{row_id}",
|
||||
name=SECRET,
|
||||
description=SECRET,
|
||||
kind="table",
|
||||
mode="cached",
|
||||
shape="tabular",
|
||||
status="active",
|
||||
provider="connector.provider",
|
||||
provider_ref=SECRET,
|
||||
schema_version=1,
|
||||
schema_=[{"secret": SECRET}],
|
||||
fingerprint="e" * 64,
|
||||
row_count=1,
|
||||
byte_count=100,
|
||||
provenance_={"secret": SECRET},
|
||||
metadata_={"secret": SECRET},
|
||||
owner_ref=SECRET,
|
||||
steward_ref=SECRET,
|
||||
authoritative_source_ref=SECRET,
|
||||
authority_mode="external_mirror",
|
||||
legal_basis_refs=[SECRET],
|
||||
purposes=[SECRET],
|
||||
semantic_definition=SECRET,
|
||||
official_keys=[SECRET],
|
||||
classification="personal",
|
||||
privacy_profile_ref=SECRET,
|
||||
retention_policy_ref=SECRET,
|
||||
hold_refs=[SECRET],
|
||||
publication_state="published",
|
||||
transfer_agreement_ref=SECRET,
|
||||
freshness_policy={"secret": SECRET},
|
||||
quality_policy={"secret": SECRET},
|
||||
known_limits=[SECRET],
|
||||
correction_procedure_ref=SECRET,
|
||||
affected_refs=[SECRET],
|
||||
dependency_refs=[SECRET],
|
||||
created_by="account-1",
|
||||
updated_by="account-1",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _payload(
|
||||
row_id: str,
|
||||
tenant_id: str,
|
||||
*,
|
||||
created_by: str,
|
||||
) -> DatasourcePayloadRecord:
|
||||
return DatasourcePayloadRecord(
|
||||
id=row_id,
|
||||
tenant_id=tenant_id,
|
||||
backend="database_rows",
|
||||
state="published",
|
||||
locator=SECRET,
|
||||
media_type="application/x-ndjson",
|
||||
checksum="f" * 64,
|
||||
row_count=1,
|
||||
byte_count=100,
|
||||
checkpoint_={"secret": SECRET},
|
||||
metadata_={"secret": SECRET},
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _stage(
|
||||
row_id: str,
|
||||
*,
|
||||
target_datasource_id: str,
|
||||
promoted: bool,
|
||||
) -> DatasourceStageRecord:
|
||||
return DatasourceStageRecord(
|
||||
id=row_id,
|
||||
tenant_id="tenant-1",
|
||||
target_datasource_id=target_datasource_id,
|
||||
name=SECRET,
|
||||
source_name=SECRET,
|
||||
description=SECRET,
|
||||
kind="table",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
state="promoted" if promoted else "ready",
|
||||
provider="upload",
|
||||
provider_ref=SECRET,
|
||||
schema_=[{"secret": SECRET}],
|
||||
rows=[{"secret": SECRET}],
|
||||
fingerprint="0" * 64,
|
||||
row_count=1,
|
||||
byte_count=100,
|
||||
validation_={"secret": SECRET},
|
||||
provenance_={"secret": SECRET},
|
||||
metadata_={"secret": SECRET},
|
||||
governance_={"secret": SECRET},
|
||||
promoted_at=NOW if promoted else None,
|
||||
promoted_materialization_id="materialization-1" if promoted else None,
|
||||
created_by="account-1",
|
||||
)
|
||||
|
||||
def test_canonical_selector_exports_only_minimized_attribution(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
)
|
||||
|
||||
self.assertEqual(7, len(records))
|
||||
self.assertEqual(
|
||||
{"datasource_operator_attribution"},
|
||||
{record.category for record in records},
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertNotIn(SECRET, exported)
|
||||
self.assertNotIn("account-1", exported)
|
||||
self.assertNotIn("datasource-other", exported)
|
||||
|
||||
def test_exact_datasource_returns_minimized_lifecycle_package(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={
|
||||
"datasources.datasource": "datasource:datasource-1"
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(7, len(records))
|
||||
self.assertEqual(
|
||||
{
|
||||
"datasource",
|
||||
"datasource_governance_reference",
|
||||
"datasource_materialization",
|
||||
"datasource_payload",
|
||||
"datasource_stage",
|
||||
"datasource_publication",
|
||||
},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertNotIn(SECRET, exported)
|
||||
self.assertNotIn("payload-orphan", exported)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"datasources.datasource": "datasource-1"}
|
||||
),
|
||||
records=records,
|
||||
)
|
||||
self.assertEqual({"manual_review"}, {action.kind for action in actions})
|
||||
|
||||
def test_exact_references_conflicts_and_tenants_fail_closed(self) -> None:
|
||||
references = {
|
||||
"datasources.governance_reference": "governance-1",
|
||||
"datasources.materialization": "materialization-1",
|
||||
"datasources.payload": "payload-referenced",
|
||||
"datasources.stage": "stage-1",
|
||||
"datasources.publication": "publication-1",
|
||||
}
|
||||
for key, value in references.items():
|
||||
with self.subTest(key=key):
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(external_references={key: value}),
|
||||
)
|
||||
self.assertEqual(1, len(records))
|
||||
|
||||
mismatch = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-2",
|
||||
external_references={"datasources.stage": "stage-1"},
|
||||
),
|
||||
)
|
||||
wrong_tenant = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-2",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"datasources.stage": "stage-1"}
|
||||
),
|
||||
)
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={
|
||||
"datasources.datasource": "datasource-1",
|
||||
"datasources.catalogue": "different",
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual((), mismatch)
|
||||
self.assertEqual((), wrong_tenant)
|
||||
self.assertEqual((), conflict)
|
||||
|
||||
def test_transient_deletion_is_safe_and_idempotent(self) -> None:
|
||||
subject = DsarSubjectRef(
|
||||
external_references={
|
||||
"datasources.stage": "stage-1",
|
||||
"datasources.payload": "payload-orphan",
|
||||
}
|
||||
)
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=records,
|
||||
)
|
||||
self.assertEqual({"delete"}, {action.kind for action in actions})
|
||||
first = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
second = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=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(DatasourceStageRecord, "stage-1"))
|
||||
self.assertIsNone(self.session.get(DatasourcePayloadRecord, "payload-orphan"))
|
||||
self.assertIsNotNone(
|
||||
self.session.get(DatasourcePayloadRecord, "payload-referenced")
|
||||
)
|
||||
|
||||
def test_published_and_attribution_state_requires_review_or_retention(self) -> None:
|
||||
direct_subject = DsarSubjectRef(
|
||||
external_references={
|
||||
"datasources.materialization": "materialization-1",
|
||||
"datasources.payload": "payload-referenced",
|
||||
"datasources.stage": "stage-promoted",
|
||||
}
|
||||
)
|
||||
direct = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=direct_subject,
|
||||
)
|
||||
direct_actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=direct_subject,
|
||||
records=direct,
|
||||
)
|
||||
self.assertEqual(
|
||||
{"manual_review"},
|
||||
{action.kind for action in direct_actions},
|
||||
)
|
||||
|
||||
canonical_subject = DsarSubjectRef(account_id="account-1")
|
||||
canonical = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=canonical_subject,
|
||||
)
|
||||
canonical_actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=canonical_subject,
|
||||
records=canonical,
|
||||
)
|
||||
self.assertEqual({"retain"}, {action.kind for action in canonical_actions})
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=canonical_subject,
|
||||
actions=canonical_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(account_id="account-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-3",
|
||||
)
|
||||
|
||||
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-DATASOURCES-1",
|
||||
request_kind="access_and_erasure",
|
||||
subject=DsarSubjectRef(account_id="account-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(
|
||||
[DATASOURCES_DSAR_CAPABILITY],
|
||||
row.coverage["provider_capabilities"],
|
||||
)
|
||||
self.assertEqual(7, row.search_result["record_count"])
|
||||
|
||||
inactive = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-DATASOURCES-2",
|
||||
request_kind="access",
|
||||
subject=DsarSubjectRef(account_id="account-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(
|
||||
[DATASOURCES_DSAR_CAPABILITY],
|
||||
inactive.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
self.assertEqual(0, inactive.search_result["record_count"])
|
||||
|
||||
def test_manifest_registers_and_documents_capability(self) -> None:
|
||||
self.assertIn(DATASOURCES_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(
|
||||
DATASOURCES_DSAR_CAPABILITY,
|
||||
manifest.capability_documentation,
|
||||
)
|
||||
self.assertIn(
|
||||
DATASOURCES_DSAR_CAPABILITY,
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
topic.id == "datasources.data-subject-requests"
|
||||
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||
for topic in manifest.documentation
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -27,11 +27,17 @@ class DatasourcesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
lifecycle = topics["datasources.lifecycle"]
|
||||
governance = topics["datasources.governance"]
|
||||
quality = topics["datasources.quality-gates"]
|
||||
visibility = topics["datasources.visibility"]
|
||||
reference = topics["datasources.reference.fields-and-consequences"]
|
||||
|
||||
self.assertIn("datasources.staging", lifecycle.metadata["help_contexts"])
|
||||
self.assertIn("datasources.field.authority-mode", governance.metadata["help_contexts"])
|
||||
self.assertIn("datasources.staging.validation", quality.metadata["help_contexts"])
|
||||
self.assertIn(
|
||||
"datasources.field.visibility-policy",
|
||||
visibility.metadata["help_contexts"],
|
||||
)
|
||||
self.assertIn("fails closed", visibility.body)
|
||||
self.assertIn("policy version and hash", quality.body)
|
||||
self.assertTrue(quality.metadata["limitations"])
|
||||
self.assertIn("datasources.action.promote", reference.metadata["help_contexts"])
|
||||
|
||||
@@ -11,6 +11,7 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.datasources import (
|
||||
CAPABILITY_DATASOURCE_ORIGINS,
|
||||
DatasourceAccessError,
|
||||
DatasourceArtifactReference,
|
||||
DatasourceField,
|
||||
DatasourceGovernance,
|
||||
DatasourceOrigin,
|
||||
@@ -44,6 +45,7 @@ from govoplan_datasources.backend.service import (
|
||||
SqlDatasourceProvider,
|
||||
)
|
||||
from govoplan_datasources.backend.payloads import (
|
||||
ExternalArtifactPayloadBackend,
|
||||
create_database_rows_payload,
|
||||
finalize_payload_deletion,
|
||||
mark_unreferenced_payload_for_deletion,
|
||||
@@ -166,6 +168,56 @@ class FakeRegistry:
|
||||
return self.origin_provider
|
||||
|
||||
|
||||
class FakeArtifactBackend:
|
||||
backend = "test_artifact"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.verified: list[str] = []
|
||||
self.deleted: list[str] = []
|
||||
|
||||
def read_rows(
|
||||
self,
|
||||
_session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
artifact: DatasourceArtifactReference,
|
||||
offset: int,
|
||||
limit: int,
|
||||
):
|
||||
self.assert_tenant(tenant_id)
|
||||
stop = min(artifact.row_count, offset + limit)
|
||||
return tuple(
|
||||
{"id": index, "result": "match"}
|
||||
for index in range(offset, stop)
|
||||
)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
_session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
artifact: DatasourceArtifactReference,
|
||||
) -> None:
|
||||
self.assert_tenant(tenant_id)
|
||||
if not artifact.locator.startswith("artifact:"):
|
||||
raise DatasourceUnavailableError("Unknown test artifact.")
|
||||
self.verified.append(artifact.locator)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
_session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
artifact: DatasourceArtifactReference,
|
||||
) -> None:
|
||||
self.assert_tenant(tenant_id)
|
||||
self.deleted.append(artifact.locator)
|
||||
|
||||
def assert_tenant(self, tenant_id: str) -> None:
|
||||
if tenant_id != "tenant-1":
|
||||
raise AssertionError("Artifact backend crossed a tenant boundary.")
|
||||
|
||||
|
||||
class DatasourceLifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
@@ -185,8 +237,10 @@ class DatasourceLifecycleTests(unittest.TestCase):
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.origins = FakeOriginProvider()
|
||||
self.artifacts = FakeArtifactBackend()
|
||||
self.provider = SqlDatasourceProvider(
|
||||
registry=FakeRegistry(self.origins),
|
||||
payload_backends=(ExternalArtifactPayloadBackend(self.artifacts),),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
@@ -667,6 +721,147 @@ class DatasourceLifecycleTests(unittest.TestCase):
|
||||
self.session.query(DatasourcePublicationRecord).count(),
|
||||
)
|
||||
|
||||
def test_artifact_publication_pins_large_payload_and_supports_bounded_reads(
|
||||
self,
|
||||
) -> None:
|
||||
artifact = DatasourceArtifactReference(
|
||||
backend="test_artifact",
|
||||
locator="artifact:monthly-output",
|
||||
checksum="a" * 64,
|
||||
row_count=25_000,
|
||||
byte_count=12_000_000,
|
||||
schema=(
|
||||
DatasourceField("id", "integer", nullable=False),
|
||||
DatasourceField("result", "string", nullable=False),
|
||||
),
|
||||
fingerprint="b" * 64,
|
||||
)
|
||||
|
||||
published = self.provider.publish_rows(
|
||||
self.session,
|
||||
principal(scopes=(SOURCE_WRITE_SCOPE, CATALOGUE_READ_SCOPE)),
|
||||
request=DatasourcePublicationRequest(
|
||||
producer_module="dataflow",
|
||||
producer_run_ref="dataflow-run:large-output",
|
||||
idempotency_key="large-output",
|
||||
name="Large output",
|
||||
source_name="large_output",
|
||||
artifact=artifact,
|
||||
),
|
||||
)
|
||||
preview = self.provider.read_datasource(
|
||||
self.session,
|
||||
principal(scopes=(CATALOGUE_READ_SCOPE,)),
|
||||
request=DatasourceReadRequest(
|
||||
datasource_ref=published.datasource.ref,
|
||||
offset=10,
|
||||
limit=3,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("published", published.status)
|
||||
self.assertEqual(25_000, published.materialization.row_count)
|
||||
self.assertEqual(
|
||||
[{"id": 10, "result": "match"},
|
||||
{"id": 11, "result": "match"},
|
||||
{"id": 12, "result": "match"}],
|
||||
list(preview.rows),
|
||||
)
|
||||
self.assertEqual(
|
||||
["artifact:monthly-output", "artifact:monthly-output"],
|
||||
self.artifacts.verified,
|
||||
)
|
||||
|
||||
def test_unattested_artifact_quality_rules_require_review_without_becoming_current(
|
||||
self,
|
||||
) -> None:
|
||||
published = self.provider.publish_rows(
|
||||
self.session,
|
||||
principal(scopes=(SOURCE_WRITE_SCOPE,)),
|
||||
request=DatasourcePublicationRequest(
|
||||
producer_module="reporting",
|
||||
producer_run_ref="report-run:review",
|
||||
idempotency_key="review-output",
|
||||
name="Review output",
|
||||
source_name="review_output",
|
||||
artifact=DatasourceArtifactReference(
|
||||
backend="test_artifact",
|
||||
locator="artifact:review-output",
|
||||
checksum="c" * 64,
|
||||
row_count=2,
|
||||
byte_count=128,
|
||||
schema=(DatasourceField("id", "integer", False),),
|
||||
fingerprint="d" * 64,
|
||||
),
|
||||
governance=DatasourceGovernance(
|
||||
quality_policy={
|
||||
"version": "unique-id-v1",
|
||||
"rules": [
|
||||
{
|
||||
"id": "unique-id",
|
||||
"type": "unique",
|
||||
"fields": ["id"],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
record = self.session.get(
|
||||
DatasourceRecord,
|
||||
published.datasource.ref.removeprefix("datasource:"),
|
||||
)
|
||||
|
||||
self.assertEqual("review_required", published.status)
|
||||
self.assertEqual("review_required", published.materialization.state)
|
||||
self.assertIsNotNone(record)
|
||||
assert record is not None
|
||||
self.assertIsNone(record.current_materialization_id)
|
||||
|
||||
def test_artifact_warning_is_a_notification_ready_terminal_state(self) -> None:
|
||||
published = self.provider.publish_rows(
|
||||
self.session,
|
||||
principal(scopes=(SOURCE_WRITE_SCOPE,)),
|
||||
request=DatasourcePublicationRequest(
|
||||
producer_module="dataflow",
|
||||
producer_run_ref="dataflow-run:warning",
|
||||
idempotency_key="warning-output",
|
||||
name="Warning output",
|
||||
source_name="warning_output",
|
||||
artifact=DatasourceArtifactReference(
|
||||
backend="test_artifact",
|
||||
locator="artifact:warning-output",
|
||||
checksum="e" * 64,
|
||||
row_count=1,
|
||||
byte_count=64,
|
||||
schema=(DatasourceField("id", "integer", False),),
|
||||
fingerprint="f" * 64,
|
||||
validation={
|
||||
"status": "warning",
|
||||
"warnings": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"code": "producer.partial_match",
|
||||
"message": "One source used a fallback match.",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
record = self.session.get(
|
||||
DatasourcePublicationRecord,
|
||||
published.ref.removeprefix("publication:"),
|
||||
)
|
||||
|
||||
self.assertEqual("published_with_warnings", published.status)
|
||||
self.assertIsNotNone(record)
|
||||
assert record is not None
|
||||
self.assertEqual(
|
||||
"published_with_warnings",
|
||||
record.status,
|
||||
)
|
||||
|
||||
def test_publication_idempotency_key_rejects_different_output(self) -> None:
|
||||
producer = principal(scopes=(SOURCE_WRITE_SCOPE,))
|
||||
base = DatasourcePublicationRequest(
|
||||
|
||||
@@ -34,7 +34,7 @@ class DatasourceMigrationTests(unittest.TestCase):
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"b8d2f5a0c3e7",
|
||||
"c9e3a6f1d4b8",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
catalogue_columns = {
|
||||
@@ -50,6 +50,8 @@ class DatasourceMigrationTests(unittest.TestCase):
|
||||
"publication_state",
|
||||
"owner_ref",
|
||||
"quality_policy",
|
||||
"access_policy_ref",
|
||||
"visibility_policy",
|
||||
"dependency_refs",
|
||||
}.issubset(catalogue_columns)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_datasources.backend.db.models import DatasourceRecord
|
||||
from govoplan_datasources.backend.manifest import get_manifest
|
||||
from govoplan_datasources.backend.search_source import (
|
||||
DatasourcesSearchSource,
|
||||
PROVIDER_ID,
|
||||
RESOURCE_TYPE,
|
||||
)
|
||||
from govoplan_datasources.backend.service import ADMIN_SCOPE, CATALOGUE_READ_SCOPE
|
||||
|
||||
|
||||
class DatasourcesSearchSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(DatasourceRecord.__table__,),
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
self.session.add_all(
|
||||
(
|
||||
_datasource("source-1", "tenant-1", "Monthly source"),
|
||||
_datasource("source-other", "tenant-2", "Other tenant"),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
self.source = DatasourcesSearchSource()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_manifest_registers_optional_search_source(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertIn("search", manifest.optional_dependencies)
|
||||
self.assertIn(
|
||||
PROVIDER_ID,
|
||||
{registration.id for registration in manifest.search_sources},
|
||||
)
|
||||
|
||||
def test_backfill_is_tenant_bound_and_excludes_protected_payloads(self) -> None:
|
||||
page = self.source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(("source-1",), tuple(doc.resource_id for doc in page.documents))
|
||||
document = page.documents[0]
|
||||
serialized = repr(document)
|
||||
self.assertNotIn("must-not-be-indexed", serialized)
|
||||
self.assertNotIn("secret_field", serialized)
|
||||
self.assertNotIn("credential-1", serialized)
|
||||
self.assertTrue(document.requires_authorization_recheck)
|
||||
self.assertEqual(
|
||||
"/datasources?datasource=datasource%3Asource-1",
|
||||
document.url,
|
||||
)
|
||||
|
||||
def test_authorization_rechecks_scope_tenant_and_current_existence(self) -> None:
|
||||
reference = SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="datasources",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id="source-1",
|
||||
)
|
||||
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||
|
||||
self.assertTrue(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal({CATALOGUE_READ_SCOPE}),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
self.assertTrue(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal({ADMIN_SCOPE}),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
self.assertFalse(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal(set()),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
other_reference = SearchResourceReference(
|
||||
tenant_id="tenant-2",
|
||||
module_id="datasources",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id="source-other",
|
||||
)
|
||||
other_request = SearchAuthorizationRequest(
|
||||
reference=other_reference,
|
||||
source_revision="1",
|
||||
)
|
||||
self.assertFalse(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal({CATALOGUE_READ_SCOPE}),
|
||||
requests=(other_request,),
|
||||
)[other_reference.key]
|
||||
)
|
||||
|
||||
|
||||
def _datasource(identifier: str, tenant_id: str, name: str) -> DatasourceRecord:
|
||||
return DatasourceRecord(
|
||||
id=identifier,
|
||||
tenant_id=tenant_id,
|
||||
source_name=identifier.replace("-", "_"),
|
||||
name=name,
|
||||
description="Safe catalogue description",
|
||||
kind="database",
|
||||
mode="cached",
|
||||
shape="tabular",
|
||||
status="active",
|
||||
provider="connectors.sql",
|
||||
provider_ref="credential-1",
|
||||
schema_=[{"name": "secret_field", "type": "string"}],
|
||||
provenance_={"query": "must-not-be-indexed"},
|
||||
metadata_={"password": "must-not-be-indexed"},
|
||||
)
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,468 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.datasources import (
|
||||
CAPABILITY_DATASOURCE_ORIGINS,
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY,
|
||||
DatasourceAccessError,
|
||||
DatasourceGovernance,
|
||||
DatasourceNotFoundError,
|
||||
DatasourceField,
|
||||
DatasourceOrigin,
|
||||
DatasourceOriginReadResult,
|
||||
DatasourceReadRequest,
|
||||
DatasourceVisibilityPolicyDecision,
|
||||
)
|
||||
from govoplan_core.db.base import Base, utcnow
|
||||
from govoplan_datasources.backend.db.models import (
|
||||
DatasourceGovernanceReferenceRecord,
|
||||
DatasourceMaterializationRecord,
|
||||
DatasourcePayloadRecord,
|
||||
DatasourceRecord,
|
||||
)
|
||||
from govoplan_datasources.backend.service import (
|
||||
CATALOGUE_READ_SCOPE,
|
||||
SqlDatasourceProvider,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = [
|
||||
{"name": "id", "data_type": "integer", "nullable": False},
|
||||
{"name": "owner_id", "data_type": "string", "nullable": False},
|
||||
{"name": "secret", "data_type": "string", "nullable": False},
|
||||
{"name": "internal_note", "data_type": "string", "nullable": True},
|
||||
]
|
||||
ROWS = [
|
||||
{
|
||||
"id": 1,
|
||||
"owner_id": "account-1",
|
||||
"secret": "protected-one",
|
||||
"internal_note": "hidden-one",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"owner_id": "account-2",
|
||||
"secret": "protected-two",
|
||||
"internal_note": "hidden-two",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def principal(
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
account_id: str = "account-1",
|
||||
role_ids: tuple[str, ...] = ("reader",),
|
||||
auth_method: str = "session",
|
||||
service_account_id: str | None = None,
|
||||
) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=(None if auth_method == "service_account" else "member-1"),
|
||||
tenant_id=tenant_id,
|
||||
scopes=frozenset({CATALOGUE_READ_SCOPE}),
|
||||
role_ids=frozenset(role_ids),
|
||||
auth_method=auth_method,
|
||||
service_account_id=service_account_id,
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
class _PolicyProvider:
|
||||
def __init__(self, decision: DatasourceVisibilityPolicyDecision) -> None:
|
||||
self.decision = decision
|
||||
self.requests = []
|
||||
|
||||
def decide_datasource_visibility(self, _session, *, request):
|
||||
self.requests.append(request)
|
||||
return self.decision
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, policy_provider: _PolicyProvider) -> None:
|
||||
self.policy_provider = policy_provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_POLICY_DATASOURCE_VISIBILITY
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.policy_provider
|
||||
|
||||
|
||||
class _OriginProvider:
|
||||
origin = DatasourceOrigin(
|
||||
ref="secret:origin",
|
||||
source_name="live_cases",
|
||||
name="Live cases",
|
||||
kind="database",
|
||||
shape="tabular",
|
||||
supported_modes=("live",),
|
||||
provider="test",
|
||||
schema=tuple(
|
||||
DatasourceField(
|
||||
name=str(field["name"]),
|
||||
data_type=str(field["data_type"]),
|
||||
nullable=bool(field["nullable"]),
|
||||
)
|
||||
for field in SCHEMA
|
||||
),
|
||||
fingerprint="live-base-fingerprint",
|
||||
row_count=2,
|
||||
)
|
||||
|
||||
def list_origins(self, _session, _principal, *, query="", limit=100):
|
||||
del query
|
||||
return (self.origin,)[:limit]
|
||||
|
||||
def get_origin(self, _session, _principal, *, origin_ref):
|
||||
return self.origin if origin_ref == self.origin.ref else None
|
||||
|
||||
def read_origin(self, _session, _principal, *, request):
|
||||
rows = ROWS[request.offset : request.offset + request.limit]
|
||||
return DatasourceOriginReadResult(
|
||||
origin=self.origin,
|
||||
rows=tuple(rows),
|
||||
total_rows=2,
|
||||
truncated=request.offset + len(rows) < 2,
|
||||
elapsed_ms=1,
|
||||
)
|
||||
|
||||
|
||||
class _LiveRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.origin_provider = _OriginProvider()
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_DATASOURCE_ORIGINS
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.origin_provider
|
||||
|
||||
|
||||
class DatasourceVisibilityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
DatasourceRecord.__table__,
|
||||
DatasourceGovernanceReferenceRecord.__table__,
|
||||
DatasourcePayloadRecord.__table__,
|
||||
DatasourceMaterializationRecord.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
DatasourceMaterializationRecord.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
DatasourcePayloadRecord.__table__,
|
||||
DatasourceGovernanceReferenceRecord.__table__,
|
||||
DatasourceRecord.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def _datasource(
|
||||
self,
|
||||
*,
|
||||
policy: dict[str, object] | None = None,
|
||||
access_policy_ref: str | None = None,
|
||||
frozen: bool = False,
|
||||
snapshot_policy: dict[str, object] | None = None,
|
||||
) -> DatasourceRecord:
|
||||
item = DatasourceRecord(
|
||||
tenant_id="tenant-1",
|
||||
source_name="governed_cases",
|
||||
name="Governed cases",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
status="active",
|
||||
provider="private.provider",
|
||||
provider_ref="secret:origin",
|
||||
schema_=SCHEMA,
|
||||
fingerprint="base-fingerprint",
|
||||
row_count=2,
|
||||
byte_count=250,
|
||||
provenance_={"secret_locator": "private://rows"},
|
||||
metadata_={"credential_ref": "credential:secret"},
|
||||
access_policy_ref=access_policy_ref,
|
||||
visibility_policy=policy or {},
|
||||
)
|
||||
self.session.add(item)
|
||||
self.session.flush()
|
||||
snapshot = DatasourceGovernance(
|
||||
publication_state="internal",
|
||||
visibility_policy=snapshot_policy or policy or {},
|
||||
access_policy_ref=access_policy_ref,
|
||||
)
|
||||
materialization = DatasourceMaterializationRecord(
|
||||
tenant_id="tenant-1",
|
||||
datasource_id=item.id,
|
||||
revision=1,
|
||||
state="published",
|
||||
schema_=SCHEMA,
|
||||
rows=ROWS,
|
||||
fingerprint="materialization-fingerprint",
|
||||
row_count=2,
|
||||
byte_count=250,
|
||||
frozen_at=utcnow() if frozen else None,
|
||||
frozen_label="Evidence" if frozen else None,
|
||||
provenance_={"protected": "source details"},
|
||||
metadata_={"protected": "materialization details"},
|
||||
governance_snapshot_=snapshot.to_dict(),
|
||||
)
|
||||
self.session.add(materialization)
|
||||
self.session.flush()
|
||||
item.current_materialization_id = materialization.id
|
||||
self.session.flush()
|
||||
return item
|
||||
|
||||
def test_role_acl_filters_discovery_and_denies_reads(self) -> None:
|
||||
item = self._datasource(policy={"source_acl": {"role_ids": ["reader"]}})
|
||||
provider = SqlDatasourceProvider()
|
||||
|
||||
self.assertEqual(1, len(provider.list_datasources(self.session, principal())))
|
||||
denied = principal(role_ids=("other",))
|
||||
self.assertEqual((), provider.list_datasources(self.session, denied))
|
||||
self.assertIsNone(
|
||||
provider.get_datasource(
|
||||
self.session,
|
||||
denied,
|
||||
datasource_ref=f"datasource:{item.id}",
|
||||
)
|
||||
)
|
||||
with self.assertRaises(DatasourceAccessError):
|
||||
provider.read_datasource(
|
||||
self.session,
|
||||
denied,
|
||||
request=DatasourceReadRequest(datasource_ref=f"datasource:{item.id}"),
|
||||
)
|
||||
other_tenant = principal(tenant_id="tenant-2")
|
||||
self.assertEqual((), provider.list_datasources(self.session, other_tenant))
|
||||
with self.assertRaises(DatasourceNotFoundError):
|
||||
provider.read_datasource(
|
||||
self.session,
|
||||
other_tenant,
|
||||
request=DatasourceReadRequest(datasource_ref=f"datasource:{item.id}"),
|
||||
)
|
||||
|
||||
def test_rows_and_fields_are_filtered_into_an_opaque_permitted_view(self) -> None:
|
||||
policy = {
|
||||
"source_acl": {"role_ids": ["reader"]},
|
||||
"fields": {
|
||||
"secret": {
|
||||
"classification": "restricted",
|
||||
"action": "redact",
|
||||
"allow": {"role_ids": ["privileged"]},
|
||||
},
|
||||
"internal_note": {
|
||||
"classification": "confidential",
|
||||
"action": "omit",
|
||||
"allow": {"role_ids": ["privileged"]},
|
||||
},
|
||||
},
|
||||
"row_filters": [
|
||||
{"field": "owner_id", "claim": "account_id", "operator": "equals"}
|
||||
],
|
||||
}
|
||||
item = self._datasource(policy=policy)
|
||||
provider = SqlDatasourceProvider()
|
||||
|
||||
with patch(
|
||||
"govoplan_datasources.backend.service.audit_event"
|
||||
) as audit_event:
|
||||
result = provider.read_datasource(
|
||||
self.session,
|
||||
principal(),
|
||||
request=DatasourceReadRequest(
|
||||
datasource_ref=f"datasource:{item.id}"
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
({"id": 1, "owner_id": "account-1", "secret": None},),
|
||||
result.rows,
|
||||
)
|
||||
self.assertEqual(
|
||||
["id", "owner_id", "secret"],
|
||||
[field.name for field in result.datasource.schema],
|
||||
)
|
||||
self.assertEqual("restricted", result.datasource.schema[-1].classification)
|
||||
self.assertNotEqual(
|
||||
"materialization-fingerprint", result.datasource.fingerprint
|
||||
)
|
||||
self.assertIsNone(result.datasource.provider)
|
||||
self.assertIsNone(result.datasource.provider_ref)
|
||||
self.assertNotIn("credential_ref", result.datasource.metadata)
|
||||
self.assertEqual(1, result.total_rows)
|
||||
self.assertEqual(
|
||||
["datasource.visibility_applied"],
|
||||
[diagnostic.code for diagnostic in result.diagnostics],
|
||||
)
|
||||
audit_details = audit_event.call_args.kwargs["details"]
|
||||
self.assertEqual(2, audit_details["visibility_summary"]["field_rule_count"])
|
||||
self.assertNotIn("protected-one", str(audit_details))
|
||||
|
||||
repeated = provider.read_datasource(
|
||||
self.session,
|
||||
principal(),
|
||||
request=DatasourceReadRequest(
|
||||
datasource_ref=f"datasource:{item.id}",
|
||||
expected_fingerprint=result.datasource.fingerprint,
|
||||
),
|
||||
)
|
||||
self.assertEqual(result.datasource.fingerprint, repeated.datasource.fingerprint)
|
||||
|
||||
def test_materialization_acl_supports_service_principals(self) -> None:
|
||||
item = self._datasource(
|
||||
policy={
|
||||
"source_acl": {"auth_methods": ["session", "service_account"]},
|
||||
"materialization_acl": {"service_account_ids": ["service:reporting"]},
|
||||
}
|
||||
)
|
||||
provider = SqlDatasourceProvider()
|
||||
|
||||
with self.assertRaises(DatasourceAccessError):
|
||||
provider.read_datasource(
|
||||
self.session,
|
||||
principal(),
|
||||
request=DatasourceReadRequest(datasource_ref=f"datasource:{item.id}"),
|
||||
)
|
||||
result = provider.read_datasource(
|
||||
self.session,
|
||||
principal(
|
||||
account_id="service-account",
|
||||
auth_method="service_account",
|
||||
service_account_id="service:reporting",
|
||||
),
|
||||
request=DatasourceReadRequest(datasource_ref=f"datasource:{item.id}"),
|
||||
)
|
||||
self.assertEqual(2, result.total_rows)
|
||||
|
||||
def test_frozen_state_keeps_its_restrictive_local_policy(self) -> None:
|
||||
snapshot_policy = {"source_acl": {"role_ids": ["evidence-reader"]}}
|
||||
item = self._datasource(
|
||||
policy={},
|
||||
frozen=True,
|
||||
snapshot_policy=snapshot_policy,
|
||||
)
|
||||
provider = SqlDatasourceProvider()
|
||||
|
||||
with self.assertRaises(DatasourceAccessError):
|
||||
provider.read_datasource(
|
||||
self.session,
|
||||
principal(role_ids=("reader",)),
|
||||
request=DatasourceReadRequest(
|
||||
datasource_ref=f"datasource:{item.id}",
|
||||
consistency="frozen",
|
||||
),
|
||||
)
|
||||
result = provider.read_datasource(
|
||||
self.session,
|
||||
principal(role_ids=("evidence-reader",)),
|
||||
request=DatasourceReadRequest(
|
||||
datasource_ref=f"datasource:{item.id}",
|
||||
consistency="frozen",
|
||||
),
|
||||
)
|
||||
self.assertEqual(2, result.total_rows)
|
||||
|
||||
def test_external_policy_tightens_local_behavior_and_missing_provider_denies(
|
||||
self,
|
||||
) -> None:
|
||||
item = self._datasource(access_policy_ref="case-workers")
|
||||
with self.assertRaises(DatasourceAccessError):
|
||||
SqlDatasourceProvider().read_datasource(
|
||||
self.session,
|
||||
principal(),
|
||||
request=DatasourceReadRequest(datasource_ref=f"datasource:{item.id}"),
|
||||
)
|
||||
|
||||
external = _PolicyProvider(
|
||||
DatasourceVisibilityPolicyDecision(
|
||||
allowed=True,
|
||||
policies=({"source_acl": {"role_ids": ["case-worker"]}},),
|
||||
decision_ref="policy-decision:1",
|
||||
)
|
||||
)
|
||||
provider = SqlDatasourceProvider(registry=_Registry(external))
|
||||
result = provider.read_datasource(
|
||||
self.session,
|
||||
principal(role_ids=("case-worker",)),
|
||||
request=DatasourceReadRequest(datasource_ref=f"datasource:{item.id}"),
|
||||
)
|
||||
self.assertEqual(2, result.total_rows)
|
||||
self.assertEqual("case-workers", external.requests[-1].policy_ref)
|
||||
|
||||
def test_denied_audit_contains_no_protected_values(self) -> None:
|
||||
item = self._datasource(policy={"source_acl": {"role_ids": ["authorized"]}})
|
||||
provider = SqlDatasourceProvider()
|
||||
with patch("govoplan_datasources.backend.service.audit_event") as audit_event:
|
||||
with self.assertRaises(DatasourceAccessError):
|
||||
provider.read_datasource(
|
||||
self.session,
|
||||
principal(role_ids=("denied",)),
|
||||
request=DatasourceReadRequest(
|
||||
datasource_ref=f"datasource:{item.id}"
|
||||
),
|
||||
)
|
||||
|
||||
details = audit_event.call_args.kwargs["details"]
|
||||
self.assertEqual("denied", details["outcome"])
|
||||
serialized = str(details)
|
||||
self.assertNotIn("protected-one", serialized)
|
||||
self.assertNotIn("hidden-one", serialized)
|
||||
self.assertNotIn("credential:secret", serialized)
|
||||
|
||||
def test_live_rows_are_filtered_before_origin_data_is_returned(self) -> None:
|
||||
item = self._datasource(
|
||||
policy={
|
||||
"source_acl": {"role_ids": ["reader"]},
|
||||
"row_filters": [
|
||||
{"field": "owner_id", "claim": "account_id"}
|
||||
],
|
||||
}
|
||||
)
|
||||
item.mode = "live"
|
||||
self.session.flush()
|
||||
|
||||
result = SqlDatasourceProvider(registry=_LiveRegistry()).read_datasource(
|
||||
self.session,
|
||||
principal(),
|
||||
request=DatasourceReadRequest(
|
||||
datasource_ref=f"datasource:{item.id}",
|
||||
consistency="live",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(1, result.total_rows)
|
||||
self.assertEqual("account-1", result.rows[0]["owner_id"])
|
||||
self.assertNotEqual("live-base-fingerprint", result.datasource.fingerprint)
|
||||
self.assertIsNone(result.datasource.provider_ref)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/datasources-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -28,6 +28,8 @@ export type DatasourceGovernance = {
|
||||
classification: string;
|
||||
privacy_profile_ref?: string | null;
|
||||
retention_policy_ref?: string | null;
|
||||
access_policy_ref?: string | null;
|
||||
visibility_policy: Record<string, unknown>;
|
||||
hold_refs: string[];
|
||||
publication_state: string;
|
||||
transfer_agreement_ref?: string | null;
|
||||
@@ -43,6 +45,7 @@ export type DatasourceField = {
|
||||
name: string;
|
||||
data_type: string;
|
||||
nullable: boolean;
|
||||
classification: string;
|
||||
};
|
||||
|
||||
export type Datasource = {
|
||||
|
||||
@@ -13,24 +13,34 @@ import {
|
||||
Layers3,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Snowflake,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Upload
|
||||
} from "lucide-react";
|
||||
import {
|
||||
import { FormGrid, DialogSection, ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
ContentSection,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FilterBar,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
MetricGrid,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceFrame,
|
||||
WorkspaceLayout,
|
||||
hasScope,
|
||||
isApiError,
|
||||
useUnsavedChanges,
|
||||
@@ -63,6 +73,7 @@ import {
|
||||
import {
|
||||
DATASOURCE_FIELDS_DOCUMENTATION,
|
||||
DATASOURCE_GOVERNANCE_DOCUMENTATION,
|
||||
DATASOURCE_VISIBILITY_DOCUMENTATION,
|
||||
DATASOURCES_DOCUMENTATION,
|
||||
DATASOURCES_I18N
|
||||
} from "./interfacePatterns";
|
||||
@@ -83,7 +94,9 @@ export default function DatasourcesPage({
|
||||
const [stages, setStages] = useState<DatasourceStage[]>([]);
|
||||
const [origins, setOrigins] = useState<DatasourceOrigin[]>([]);
|
||||
const [originsAvailable, setOriginsAvailable] = useState(false);
|
||||
const [selectedDatasourceRef, setSelectedDatasourceRef] = useState("");
|
||||
const [selectedDatasourceRef, setSelectedDatasourceRef] = useState(
|
||||
initialDatasourceRef
|
||||
);
|
||||
const [selectedStageRef, setSelectedStageRef] = useState("");
|
||||
const [selectedOriginRef, setSelectedOriginRef] = useState("");
|
||||
const [preview, setPreview] = useState<DatasourcePreview | null>(null);
|
||||
@@ -282,30 +295,32 @@ export default function DatasourcesPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="datasources-page">
|
||||
<div className="datasources-shell">
|
||||
<aside className="datasources-sidebar" aria-label="Datasource catalogue">
|
||||
<div className="datasources-sidebar-toolbar">
|
||||
<strong>Data</strong>
|
||||
<span className="datasources-toolbar-actions">
|
||||
<IconButton
|
||||
label="Refresh"
|
||||
icon={<RefreshCw size={16} />}
|
||||
variant="ghost"
|
||||
onClick={() => void reload(selectedDatasourceRef)}
|
||||
disabled={loading || working}
|
||||
disabledReason={loading ? DATASOURCES_I18N.loading : working ? DATASOURCES_I18N.working : undefined}
|
||||
/>
|
||||
<IconButton
|
||||
label="Add datasource or stage"
|
||||
icon={<Plus size={17} />}
|
||||
variant="primary"
|
||||
onClick={() => setAddOpen(true)}
|
||||
disabled={!canStage && !canManage}
|
||||
disabledReason={!canStage && !canManage ? DATASOURCES_I18N.manageReason : undefined}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<WorkspaceFrame as="main" height="viewport" surface="plain" className="datasources-page" label="Datasource workspace">
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
primaryLabel="Datasource catalogue"
|
||||
contentLabel="Datasource workspace"
|
||||
contentClassName="datasources-workspace"
|
||||
primary={<>
|
||||
<WorkspaceActionBar
|
||||
scope="collection-pane"
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void reload(selectedDatasourceRef), loading: loading || working }}
|
||||
contextActions={<strong>Data</strong>}
|
||||
createAction={<IconButton
|
||||
label="Add datasource or stage"
|
||||
icon={<Plus size={17} />}
|
||||
variant="primary"
|
||||
onClick={() => setAddOpen(true)}
|
||||
disabled={!canStage && !canManage}
|
||||
disabledReason={!canStage && !canManage ? DATASOURCES_I18N.manageReason : undefined}
|
||||
/>}
|
||||
/>
|
||||
<div className="datasources-view-switch">
|
||||
<SegmentedControl<CatalogueView>
|
||||
ariaLabel="Datasource view"
|
||||
@@ -324,7 +339,7 @@ export default function DatasourcesPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="datasources-search">
|
||||
<FilterBar surface="panel">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
@@ -332,72 +347,60 @@ export default function DatasourcesPage({
|
||||
placeholder={`Search ${view}`}
|
||||
aria-label={`Search ${view}`}
|
||||
/>
|
||||
</div>
|
||||
</FilterBar>
|
||||
<LoadingFrame
|
||||
loading={loading}
|
||||
className="datasources-list-frame"
|
||||
>
|
||||
<div className="datasources-list">
|
||||
<SelectionList variant="navigation" label="Datasources">
|
||||
{view === "catalogue" ? visibleDatasources.map((item) => (
|
||||
<button
|
||||
<SelectionListItem
|
||||
key={item.ref}
|
||||
type="button"
|
||||
className={item.ref === selectedDatasourceRef ? "is-selected" : ""}
|
||||
selected={item.ref === selectedDatasourceRef}
|
||||
onClick={() => setSelectedDatasourceRef(item.ref)}
|
||||
>
|
||||
<span>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.source_name} · {item.kind}</small>
|
||||
</span>
|
||||
<SelectionListItemContent title={item.name} description={`${item.source_name} · ${item.kind}`} />
|
||||
<StatusBadge status={item.mode} label={item.mode} />
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
)) : null}
|
||||
{view === "staging" ? visibleStages.map((item) => (
|
||||
<button
|
||||
<SelectionListItem
|
||||
key={item.ref}
|
||||
type="button"
|
||||
className={item.ref === selectedStageRef ? "is-selected" : ""}
|
||||
selected={item.ref === selectedStageRef}
|
||||
onClick={() => setSelectedStageRef(item.ref)}
|
||||
>
|
||||
<span>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.source_name} · {formatCount(item.row_count, "row")}</small>
|
||||
</span>
|
||||
<SelectionListItemContent title={item.name} description={`${item.source_name} · ${formatCount(item.row_count, "row")}`} />
|
||||
<StatusBadge status={item.state} label={item.state} />
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
)) : null}
|
||||
{view === "origins" ? visibleOrigins.map((item) => (
|
||||
<button
|
||||
<SelectionListItem
|
||||
key={item.ref}
|
||||
type="button"
|
||||
className={item.ref === selectedOriginRef ? "is-selected" : ""}
|
||||
selected={item.ref === selectedOriginRef}
|
||||
onClick={() => setSelectedOriginRef(item.ref)}
|
||||
>
|
||||
<span>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.provider} · {item.kind}</small>
|
||||
</span>
|
||||
<SelectionListItemContent title={item.name} description={`${item.provider} · ${item.kind}`} />
|
||||
<StatusBadge status={item.shape} label={item.shape} />
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
)) : null}
|
||||
{view === "catalogue" && !visibleDatasources.length ? (
|
||||
<div className="datasources-list-empty">No datasources</div>
|
||||
<StatePanel size="compact" description="No datasources" />
|
||||
) : null}
|
||||
{view === "staging" && !visibleStages.length ? (
|
||||
<div className="datasources-list-empty">No staged data</div>
|
||||
<StatePanel size="compact" description="No staged data" />
|
||||
) : null}
|
||||
{view === "origins" && !visibleOrigins.length ? (
|
||||
<div className="datasources-list-empty">
|
||||
{originsAvailable ? "No connector origins" : "Connectors unavailable"}
|
||||
</div>
|
||||
<StatePanel size="compact" description={originsAvailable ? "No connector origins" : "Connectors unavailable"} />
|
||||
) : null}
|
||||
</div>
|
||||
</SelectionList>
|
||||
</LoadingFrame>
|
||||
</aside>
|
||||
|
||||
<section className="datasources-workspace">
|
||||
<div className="datasources-workspace-toolbar">
|
||||
<span className="datasources-current-title">
|
||||
</>}
|
||||
>
|
||||
<WorkspaceActionBar
|
||||
scope="detail-pane"
|
||||
variant="detail"
|
||||
className="datasources-workspace-toolbar"
|
||||
contextActions={<span className="datasources-current-title">
|
||||
{view === "catalogue" ? <DatabaseZap size={19} />
|
||||
: view === "staging" ? <Layers3 size={19} />
|
||||
: <Database size={19} />}
|
||||
@@ -417,9 +420,9 @@ export default function DatasourcesPage({
|
||||
: selectedOrigin?.provider ?? "External acquisition"}
|
||||
</small>
|
||||
</span>
|
||||
</span>
|
||||
<span className="datasources-toolbar-actions">
|
||||
<DocumentationHelpLink reference={DATASOURCES_DOCUMENTATION} />
|
||||
</span>}
|
||||
helpAction={<DocumentationHelpLink reference={DATASOURCES_DOCUMENTATION} />}
|
||||
primaryActions={<>
|
||||
{view === "catalogue" && selectedDatasource?.mode === "cached" ? (
|
||||
<Button onClick={() => void refreshSelected()} disabled={!canManage || working} disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}>
|
||||
<Download size={16} /> Refresh
|
||||
@@ -437,14 +440,6 @@ export default function DatasourcesPage({
|
||||
<Button onClick={() => setFreezeOpen(true)} disabled={!canManage || working} disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}>
|
||||
<Snowflake size={16} /> Freeze
|
||||
</Button>
|
||||
<IconButton
|
||||
label="Retire datasource"
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="danger"
|
||||
onClick={() => setRetireOpen(true)}
|
||||
disabled={!canManage || working}
|
||||
disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{view === "staging" && selectedStage?.state === "ready" ? (
|
||||
@@ -467,8 +462,18 @@ export default function DatasourcesPage({
|
||||
<Plus size={16} /> Register
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</>}
|
||||
destructiveActions={view === "catalogue" && selectedDatasource ? (
|
||||
<IconButton
|
||||
label="Retire datasource"
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="danger"
|
||||
onClick={() => setRetireOpen(true)}
|
||||
disabled={!canManage || working}
|
||||
disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<div className="datasources-alerts">
|
||||
{error ? (
|
||||
@@ -520,8 +525,7 @@ export default function DatasourcesPage({
|
||||
) : null}
|
||||
</div>
|
||||
{working ? <div className="datasources-working" role="status">Working...</div> : null}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceLayout>
|
||||
|
||||
<AddDatasourceDialog
|
||||
open={addOpen}
|
||||
@@ -605,7 +609,7 @@ export default function DatasourcesPage({
|
||||
onCancel={() => setRetireOpen(false)}
|
||||
onConfirm={() => void retireSelected()}
|
||||
/>
|
||||
</main>
|
||||
</WorkspaceFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -622,24 +626,24 @@ function DatasourceDetail({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="datasources-metrics">
|
||||
<Metric label="Mode" value={datasource.mode} />
|
||||
<Metric label="Rows" value={formatNumber(datasource.row_count)} />
|
||||
<Metric label="Fields" value={String(datasource.schema.length)} />
|
||||
<Metric label="Revisions" value={String(materializations.length)} />
|
||||
<Metric label="Updated" value={formatDate(datasource.updated_at)} />
|
||||
</div>
|
||||
<MetricGrid columns={5} density="compact" minimum="compact">
|
||||
<MetricCard density="compact" label="Mode" value={datasource.mode} valueTitle={datasource.mode} />
|
||||
<MetricCard density="compact" label="Rows" value={formatNumber(datasource.row_count)} />
|
||||
<MetricCard density="compact" label="Fields" value={String(datasource.schema.length)} />
|
||||
<MetricCard density="compact" label="Revisions" value={String(materializations.length)} />
|
||||
<MetricCard density="compact" label="Updated" value={formatDate(datasource.updated_at)} />
|
||||
</MetricGrid>
|
||||
{datasource.description ? (
|
||||
<div className="datasources-description">{datasource.description}</div>
|
||||
) : null}
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span><ShieldCheck size={16} /> Governance</span>
|
||||
<StatusBadge
|
||||
status={datasource.governance.publication_state}
|
||||
label={datasource.governance.publication_state}
|
||||
/>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<div className="datasources-key-values">
|
||||
<span><small>Authority</small><strong>{readableToken(datasource.governance.authority_mode)}</strong></span>
|
||||
<span><small>Classification</small><strong>{datasource.governance.classification}</strong></span>
|
||||
@@ -651,25 +655,25 @@ function DatasourceDetail({
|
||||
{datasource.governance.semantic_definition ? (
|
||||
<p className="datasources-dialog-copy">{datasource.governance.semantic_definition}</p>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
</ContentSection>
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span><Eye size={16} /> Preview</span>
|
||||
<small>{preview ? `${formatNumber(preview.total_rows)} total rows` : ""}</small>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
{loading ? (
|
||||
<div className="datasources-inline-loading">Loading preview...</div>
|
||||
) : preview ? (
|
||||
<PreviewTable datasource={datasource} rows={preview.rows} />
|
||||
) : (
|
||||
<div className="datasources-inline-empty">No preview available</div>
|
||||
<StatePanel size="inline" description="No preview available" />
|
||||
)}
|
||||
</section>
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
</ContentSection>
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span><Archive size={16} /> Materializations</span>
|
||||
<small>Immutable revisions</small>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<div className="datasources-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -706,14 +710,14 @@ function DatasourceDetail({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
</ContentSection>
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span>Schema</span>
|
||||
<small>Version {datasource.schema_version}</small>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<SchemaTable fields={datasource.schema} />
|
||||
</section>
|
||||
</ContentSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -725,21 +729,21 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
|
||||
const schemaClassification = stage.validation.schema_change?.classification ?? "new";
|
||||
return (
|
||||
<>
|
||||
<div className="datasources-metrics">
|
||||
<Metric label="State" value={stage.state} />
|
||||
<Metric label="Mode" value={stage.mode} />
|
||||
<Metric label="Rows" value={formatNumber(stage.row_count)} />
|
||||
<Metric label="Fields" value={String(stage.schema.length)} />
|
||||
<Metric label="Created" value={formatDate(stage.created_at)} />
|
||||
</div>
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
<MetricGrid columns={5} density="compact" minimum="compact">
|
||||
<MetricCard density="compact" label="State" value={stage.state} valueTitle={stage.state} />
|
||||
<MetricCard density="compact" label="Mode" value={stage.mode} valueTitle={stage.mode} />
|
||||
<MetricCard density="compact" label="Rows" value={formatNumber(stage.row_count)} />
|
||||
<MetricCard density="compact" label="Fields" value={String(stage.schema.length)} />
|
||||
<MetricCard density="compact" label="Created" value={formatDate(stage.created_at)} />
|
||||
</MetricGrid>
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span>Validation</span>
|
||||
<StatusBadge
|
||||
status={stage.validation.valid === false ? "invalid" : "ready"}
|
||||
label={stage.validation.valid === false ? "Invalid" : "Ready"}
|
||||
/>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<div className="datasources-key-values">
|
||||
<span><small>Fingerprint</small><strong>{shortFingerprint(stage.fingerprint)}</strong></span>
|
||||
<span><small>Target</small><strong>{stage.target_datasource_ref || "New datasource"}</strong></span>
|
||||
@@ -768,27 +772,27 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
|
||||
All configured quality rules passed and no blocking schema change was detected.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentSection>
|
||||
{schemaChanges.length ? (
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span>Schema comparison</span>
|
||||
<StatusBadge status={schemaClassification} label={readableToken(schemaClassification)} />
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<ul className="datasources-schema-changes">
|
||||
{schemaChanges.map((change, index) => (
|
||||
<SchemaChangeItem key={`${change.code}-${change.field ?? index}`} change={change} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</ContentSection>
|
||||
) : null}
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span>Detected schema</span>
|
||||
<small>{stage.shape}</small>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<SchemaTable fields={stage.schema} />
|
||||
</section>
|
||||
</ContentSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -832,22 +836,22 @@ function SchemaChangeItem({ change }: { change: DatasourceSchemaChange }) {
|
||||
function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
|
||||
return (
|
||||
<>
|
||||
<div className="datasources-metrics">
|
||||
<Metric label="Provider" value={origin.provider} />
|
||||
<Metric label="Mode" value={readableToken(origin.source_mode)} />
|
||||
<Metric label="Health" value={readableToken(origin.health.status)} />
|
||||
<Metric label="Rows" value={formatNumber(origin.row_count)} />
|
||||
<Metric label="Fields" value={String(origin.schema.length)} />
|
||||
<Metric label="Updated" value={formatDate(origin.updated_at)} />
|
||||
</div>
|
||||
<MetricGrid columns="auto" density="compact" minimum="compact">
|
||||
<MetricCard density="compact" label="Provider" value={origin.provider} valueTitle={origin.provider} />
|
||||
<MetricCard density="compact" label="Mode" value={readableToken(origin.source_mode)} />
|
||||
<MetricCard density="compact" label="Health" value={readableToken(origin.health.status)} />
|
||||
<MetricCard density="compact" label="Rows" value={formatNumber(origin.row_count)} />
|
||||
<MetricCard density="compact" label="Fields" value={String(origin.schema.length)} />
|
||||
<MetricCard density="compact" label="Updated" value={formatDate(origin.updated_at)} />
|
||||
</MetricGrid>
|
||||
{origin.description ? (
|
||||
<div className="datasources-description">{origin.description}</div>
|
||||
) : null}
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span>Registration options</span>
|
||||
<small>{origin.supported_modes.join(", ")}</small>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<div className="datasources-key-values">
|
||||
<span><small>Origin reference</small><strong>{origin.ref}</strong></span>
|
||||
<span><small>Kind</small><strong>{readableToken(origin.kind)}</strong></span>
|
||||
@@ -856,14 +860,14 @@ function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
|
||||
<span><small>Health</small><strong>{origin.health.summary}</strong></span>
|
||||
<span><small>Pushdown</small><strong>{pushdownSummary(origin)}</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
<section className="datasources-detail-section">
|
||||
<div className="datasources-section-heading">
|
||||
</ContentSection>
|
||||
<ContentSection>
|
||||
<ActionToolbar surface="section-header" className="datasources-section-heading">
|
||||
<span>Discovered schema</span>
|
||||
<small>Version {origin.schema_version}</small>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<SchemaTable fields={origin.schema} />
|
||||
</section>
|
||||
</ContentSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -895,6 +899,7 @@ function GovernanceDialog({
|
||||
const [draft, setDraft] = useState<DatasourceGovernance | null>(null);
|
||||
const [freshness, setFreshness] = useState("{}");
|
||||
const [quality, setQuality] = useState("{}");
|
||||
const [visibility, setVisibility] = useState("{}");
|
||||
const [baselineKey, setBaselineKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -905,14 +910,16 @@ function GovernanceDialog({
|
||||
const nextDraft = structuredClone(datasource.governance);
|
||||
const nextFreshness = JSON.stringify(datasource.governance.freshness_policy, null, 2);
|
||||
const nextQuality = JSON.stringify(datasource.governance.quality_policy, null, 2);
|
||||
const nextVisibility = JSON.stringify(datasource.governance.visibility_policy ?? {}, null, 2);
|
||||
setDraft(nextDraft);
|
||||
setFreshness(nextFreshness);
|
||||
setQuality(nextQuality);
|
||||
setBaselineKey(JSON.stringify({ draft: nextDraft, freshness: nextFreshness, quality: nextQuality }));
|
||||
setVisibility(nextVisibility);
|
||||
setBaselineKey(JSON.stringify({ draft: nextDraft, freshness: nextFreshness, quality: nextQuality, visibility: nextVisibility }));
|
||||
setError("");
|
||||
}, [datasource, open]);
|
||||
|
||||
const dirty = Boolean(open && draft && JSON.stringify({ draft, freshness, quality }) !== baselineKey);
|
||||
const dirty = Boolean(open && draft && JSON.stringify({ draft, freshness, quality, visibility }) !== baselineKey);
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!datasource || !draft) return false;
|
||||
@@ -922,7 +929,8 @@ function GovernanceDialog({
|
||||
const updated = await updateDatasourceGovernance(settings, datasource.ref, {
|
||||
...draft,
|
||||
freshness_policy: parseObject(freshness, "Freshness policy"),
|
||||
quality_policy: parseObject(quality, "Quality policy")
|
||||
quality_policy: parseObject(quality, "Quality policy"),
|
||||
visibility_policy: parseObject(visibility, "Visibility policy")
|
||||
});
|
||||
await onSaved(updated);
|
||||
return true;
|
||||
@@ -970,8 +978,8 @@ function GovernanceDialog({
|
||||
>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{draft ? (
|
||||
<div className="datasources-dialog-fields">
|
||||
<div className="datasources-dialog-grid">
|
||||
<DialogSection className="datasources-dialog-fields">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<FormField label="Authority mode" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<select
|
||||
value={draft.authority_mode}
|
||||
@@ -1014,20 +1022,23 @@ function GovernanceDialog({
|
||||
<FormField label="Privacy profile">
|
||||
<input value={draft.privacy_profile_ref ?? ""} onChange={(event) => setValue("privacy_profile_ref", event.target.value || null)} />
|
||||
</FormField>
|
||||
<FormField label="Retention policy">
|
||||
<FormField label="Retention policy" interfaceId="datasources.field.retention-policy" helpContextId="datasources.field.retention-policy" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<input value={draft.retention_policy_ref ?? ""} onChange={(event) => setValue("retention_policy_ref", event.target.value || null)} />
|
||||
</FormField>
|
||||
<FormField label="Transfer agreement">
|
||||
<FormField label="Access policy reference" interfaceId="datasources.field.access-policy" helpContextId="datasources.field.access-policy" helpModuleId="datasources" documentation={DATASOURCE_VISIBILITY_DOCUMENTATION}>
|
||||
<input value={draft.access_policy_ref ?? ""} onChange={(event) => setValue("access_policy_ref", event.target.value || null)} placeholder="Optional Policy module target" />
|
||||
</FormField>
|
||||
<FormField label="Transfer agreement" interfaceId="datasources.field.transfer-agreement" helpContextId="datasources.field.transfer-agreement" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<input value={draft.transfer_agreement_ref ?? ""} onChange={(event) => setValue("transfer_agreement_ref", event.target.value || null)} />
|
||||
</FormField>
|
||||
<FormField label="Correction procedure">
|
||||
<input value={draft.correction_procedure_ref ?? ""} onChange={(event) => setValue("correction_procedure_ref", event.target.value || null)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<FormField label="Semantic definition" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<textarea value={draft.semantic_definition ?? ""} onChange={(event) => setValue("semantic_definition", event.target.value || null)} />
|
||||
</FormField>
|
||||
<div className="datasources-dialog-grid">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<GovernanceListField label="Purposes" values={draft.purposes} onChange={(values) => setValue("purposes", values)} />
|
||||
<GovernanceListField label="Legal basis references" values={draft.legal_basis_refs} onChange={(values) => setValue("legal_basis_refs", values)} />
|
||||
<GovernanceListField label="Official keys" values={draft.official_keys} onChange={(values) => setValue("official_keys", values)} />
|
||||
@@ -1035,16 +1046,20 @@ function GovernanceDialog({
|
||||
<GovernanceListField label="Affected services and processes" values={draft.affected_refs} onChange={(values) => setValue("affected_refs", values)} />
|
||||
<GovernanceListField label="Dependent flows, reports, controls and decisions" values={draft.dependency_refs} onChange={(values) => setValue("dependency_refs", values)} />
|
||||
<GovernanceListField label="Known limits" values={draft.known_limits} onChange={(values) => setValue("known_limits", values)} />
|
||||
</div>
|
||||
<div className="datasources-dialog-grid">
|
||||
</FormGrid>
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<FormField label="Visibility policy (JSON)" interfaceId="datasources.field.visibility-policy" helpContextId="datasources.field.visibility-policy" helpModuleId="datasources" documentation={DATASOURCE_VISIBILITY_DOCUMENTATION}>
|
||||
<textarea value={visibility} onChange={(event) => setVisibility(event.target.value)} spellCheck={false} />
|
||||
<small>Configure source and materialization ACLs, field redaction or omission, and principal-bound row filters.</small>
|
||||
</FormField>
|
||||
<FormField label="Freshness policy (JSON)" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<textarea value={freshness} onChange={(event) => setFreshness(event.target.value)} spellCheck={false} />
|
||||
</FormField>
|
||||
<FormField label="Quality policy (JSON)" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
|
||||
<textarea value={quality} onChange={(event) => setQuality(event.target.value)} spellCheck={false} />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</DialogSection>
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -1281,7 +1296,7 @@ function AddDatasourceDialog({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="datasources-dialog-fields">
|
||||
<DialogSection className="datasources-dialog-fields">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
|
||||
) : null}
|
||||
@@ -1334,7 +1349,7 @@ function AddDatasourceDialog({
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<div className="datasources-dialog-grid">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<FormField label="Name" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</FormField>
|
||||
@@ -1347,7 +1362,7 @@ function AddDatasourceDialog({
|
||||
disabled={Boolean(targetRef)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<FormField label="Description">
|
||||
<input value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</FormField>
|
||||
@@ -1419,7 +1434,7 @@ function AddDatasourceDialog({
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogSection>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1460,34 +1475,26 @@ function SchemaTable({ fields }: { fields: Datasource["schema"] }) {
|
||||
<div className="datasources-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Field</th><th>Type</th><th>Nullable</th></tr>
|
||||
<tr><th>Field</th><th>Type</th><th>Classification</th><th>Nullable</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field) => (
|
||||
<tr key={field.name}>
|
||||
<td>{field.name}</td>
|
||||
<td>{field.data_type}</td>
|
||||
<td>{readableToken(field.classification)}</td>
|
||||
<td>{field.nullable ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!fields.length ? <tr><td colSpan={3}>Schema not available</td></tr> : null}
|
||||
{!fields.length ? <tr><td colSpan={4}>Schema not available</td></tr> : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span>
|
||||
<small>{label}</small>
|
||||
<strong title={value}>{value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyWorkspace({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return <div className="datasources-workspace-empty">{icon}<strong>{label}</strong></div>;
|
||||
return <StatePanel size="fill" icon={icon} title={label} />;
|
||||
}
|
||||
|
||||
function filterItems<T>(
|
||||
@@ -1501,6 +1508,11 @@ function filterItems<T>(
|
||||
: items;
|
||||
}
|
||||
|
||||
function initialDatasourceRef(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("datasource") ?? "";
|
||||
}
|
||||
|
||||
function parseRows(text: string): Record<string, unknown>[] {
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(value) || value.some((row) => !isRecord(row))) {
|
||||
|
||||
@@ -15,6 +15,11 @@ export const DATASOURCE_GOVERNANCE_DOCUMENTATION = {
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const DATASOURCE_VISIBILITY_DOCUMENTATION = {
|
||||
topicId: "datasources.visibility",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const DATASOURCES_I18N = {
|
||||
loading: "i18n:govoplan-datasources.loading_reason",
|
||||
working: "i18n:govoplan-datasources.working_reason",
|
||||
|
||||
@@ -63,6 +63,10 @@ const en = {
|
||||
"Semantic definition": "Semantic definition",
|
||||
"Freshness policy (JSON)": "Freshness policy (JSON)",
|
||||
"Quality policy (JSON)": "Quality policy (JSON)",
|
||||
"Access policy reference": "Access policy reference",
|
||||
"Optional Policy module target": "Optional Policy module target",
|
||||
"Visibility policy (JSON)": "Visibility policy (JSON)",
|
||||
"Configure source and materialization ACLs, field redaction or omission, and principal-bound row filters.": "Configure source and materialization ACLs, field redaction or omission, and principal-bound row filters.",
|
||||
"Quality policy": "Quality policy",
|
||||
"Policy hash": "Policy hash",
|
||||
"Schema change": "Schema change",
|
||||
@@ -136,6 +140,10 @@ const de: Record<keyof typeof en, string> = {
|
||||
"Semantic definition": "Semantische Definition",
|
||||
"Freshness policy (JSON)": "Aktualitätsrichtlinie (JSON)",
|
||||
"Quality policy (JSON)": "Qualitätsrichtlinie (JSON)",
|
||||
"Access policy reference": "Zugriffsrichtlinienreferenz",
|
||||
"Optional Policy module target": "Optionales Ziel im Richtlinienmodul",
|
||||
"Visibility policy (JSON)": "Sichtbarkeitsrichtlinie (JSON)",
|
||||
"Configure source and materialization ACLs, field redaction or omission, and principal-bound row filters.": "Konfigurieren Sie Zugriffslisten für Quellen und Materialisierungen, Feldschwärzung oder -auslassung sowie an den Akteur gebundene Zeilenfilter.",
|
||||
"Quality policy": "Qualitätsrichtlinie",
|
||||
"Policy hash": "Richtlinien-Hash",
|
||||
"Schema change": "Schemaänderung",
|
||||
|
||||
+3
-2
@@ -10,14 +10,15 @@ const readScopes = ["datasources:catalogue:read", "datasources:source:admin"];
|
||||
export const datasourcesModule: PlatformWebModule = {
|
||||
id: "datasources",
|
||||
label: "i18n:govoplan-datasources.datasources",
|
||||
version: "0.1.14",
|
||||
version: "0.1.18",
|
||||
optionalDependencies: [
|
||||
"access",
|
||||
"audit",
|
||||
"connectors",
|
||||
"files",
|
||||
"notifications",
|
||||
"policy"
|
||||
"policy",
|
||||
"search"
|
||||
],
|
||||
translations: generatedTranslations,
|
||||
viewSurfaces: [
|
||||
|
||||
@@ -1,65 +1,9 @@
|
||||
.datasources-page {
|
||||
position: relative;
|
||||
height: calc(100vh - 115px);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.datasources-page *,
|
||||
.datasources-page *::before,
|
||||
.datasources-page *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.datasources-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.datasources-sidebar,
|
||||
.datasources-workspace,
|
||||
.datasources-content,
|
||||
.datasources-list-frame {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.datasources-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.datasources-sidebar-toolbar,
|
||||
.datasources-workspace-toolbar,
|
||||
.datasources-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.datasources-sidebar-toolbar {
|
||||
min-height: 52px;
|
||||
padding: 8px 10px 8px 14px;
|
||||
}
|
||||
|
||||
.datasources-toolbar-actions,
|
||||
.datasources-current-title,
|
||||
.datasources-current-title > span,
|
||||
@@ -75,80 +19,11 @@
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.datasources-search {
|
||||
padding: 9px;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.datasources-search input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.datasources-list-frame {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.datasources-list {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.datasources-list > button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.datasources-list > button:hover,
|
||||
.datasources-list > button:focus-visible {
|
||||
background: var(--primary-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.datasources-list > button.is-selected {
|
||||
background: var(--primary-soft-strong);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.datasources-list > button > span:first-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.datasources-list strong,
|
||||
.datasources-list small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datasources-list strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.datasources-list small {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.datasources-list-empty,
|
||||
.datasources-inline-empty,
|
||||
.datasources-inline-loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -158,17 +33,8 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.datasources-workspace {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.datasources-workspace-toolbar {
|
||||
min-height: 58px;
|
||||
padding: 8px 10px 8px 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.datasources-current-title {
|
||||
@@ -218,43 +84,6 @@
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.datasources-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.datasources-metrics > span {
|
||||
min-width: 0;
|
||||
min-height: 61px;
|
||||
padding: 9px 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.datasources-metrics small,
|
||||
.datasources-metrics strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datasources-metrics small {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.datasources-metrics strong {
|
||||
margin-top: 6px;
|
||||
color: var(--text-strong);
|
||||
font-size: 14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.datasources-description {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
@@ -265,18 +94,6 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.datasources-detail-section {
|
||||
min-width: 0;
|
||||
margin-bottom: 14px;
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.datasources-section-heading {
|
||||
min-height: 42px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.datasources-section-heading > span {
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
@@ -487,12 +304,6 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.datasources-dialog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.datasources-dialog-copy {
|
||||
margin: 0 0 14px;
|
||||
color: var(--muted);
|
||||
@@ -507,26 +318,10 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.datasources-shell {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.datasources-sidebar {
|
||||
max-height: 42vh;
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.datasources-workspace {
|
||||
min-height: 58vh;
|
||||
}
|
||||
|
||||
.datasources-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.datasources-workspace-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
@@ -538,9 +333,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.datasources-metrics,
|
||||
.datasources-key-values,
|
||||
.datasources-dialog-grid {
|
||||
.datasources-key-values {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user