teleo-infrastructure/observatory_read_adapter/db.py
twentyOne2x 92da54d804
Some checks are pending
CI / lint-and-test (push) Waiting to run
Add authenticated GCP Observatory read adapter (#154)
2026-07-15 03:43:05 +02:00

315 lines
12 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any
from .config import Settings
ConnectionFactory = Callable[[], Any]
class ReaderContractError(RuntimeError):
"""The live database session does not match the adapter's read-only contract."""
def _json_value(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, tuple):
return list(value)
return value
def _column_name(column: Any) -> str:
return str(column.name) if hasattr(column, "name") else str(column[0])
def _row_dict(cursor: Any, row: Any) -> dict[str, Any] | None:
if row is None:
return None
names = [_column_name(column) for column in cursor.description]
return {name: _json_value(value) for name, value in zip(names, row, strict=True)}
def _rows(cursor: Any) -> list[dict[str, Any]]:
names = [_column_name(column) for column in cursor.description]
return [
{name: _json_value(value) for name, value in zip(names, row, strict=True)}
for row in cursor.fetchall()
]
class CloudSqlClaimReader:
def __init__(self, settings: Settings, *, connection_factory: ConnectionFactory | None = None):
self.settings = settings
self._connector: Any | None = None
if connection_factory is not None:
self._connection_factory = connection_factory
return
from google.cloud.sql.connector import Connector, IPTypes
self._connector = Connector(refresh_strategy="LAZY")
def connect() -> Any:
return self._connector.connect(
settings.instance_connection_name,
"pg8000",
user=settings.iam_database_user,
db=settings.database,
enable_iam_auth=True,
ip_type=IPTypes.PRIVATE,
)
self._connection_factory = connect
def close(self) -> None:
if self._connector is not None:
self._connector.close()
def check_ready(self) -> dict[str, Any]:
connection = self._connection_factory()
try:
cursor = connection.cursor()
self._begin_read_only(cursor)
session = self._load_session_contract(cursor)
connection.rollback()
return {
"ready": True,
"database": session["database"],
"authorization_role": self.settings.authorization_role,
"transaction_read_only": True,
}
finally:
connection.close()
def read_claim(self, claim_id: str | None = None) -> dict[str, Any] | None:
connection = self._connection_factory()
try:
cursor = connection.cursor()
self._begin_read_only(cursor)
session = self._load_session_contract(cursor)
claim = self._load_claim(cursor, claim_id)
if claim is None:
connection.rollback()
return None
evidence = self._load_evidence(cursor, claim["id"])
edges = self._load_edges(cursor, claim["id"])
proposal_counts = self._load_proposal_counts(cursor)
related_proposals = self._load_related_proposals(cursor, claim["id"])
recent_proposals = self._load_recent_proposals(cursor)
connection.rollback()
finally:
connection.close()
generated_at = datetime.now(timezone.utc).isoformat()
return {
"schema": "livingip.observatory-canonical-claim.v1",
"generated_at": generated_at,
"read_only": True,
"provenance": {
"gcp_project": self.settings.project_id,
"cloud_sql_instance": self.settings.instance_connection_name,
"database": session["database"],
"database_principal": session["database_principal"],
"authorization_role": self.settings.authorization_role,
"transaction_read_only": True,
"write_privileges_denied": bool(session["required_writes_denied"]),
"service_revision": self.settings.service_revision,
},
"canonical": {
"relation": "public.claims",
"claim": claim,
"evidence_relation": "public.claim_evidence -> public.sources",
"evidence": evidence,
"edge_relation": "public.claim_edges",
"edges": edges,
},
"proposal_ledger": {
"relation": "kb_stage.kb_proposals",
"distinct_from_canonical": True,
"status_counts": proposal_counts,
"related": related_proposals,
"recent": recent_proposals,
"semantics": {
"pending_review": "staged only; not canonical",
"approved": "reviewed intent; not canonical until applied_at is set",
"applied": "proposal receipt only; canonical rows remain authoritative",
},
},
}
@staticmethod
def _begin_read_only(cursor: Any) -> None:
cursor.execute("set transaction read only")
cursor.execute("set local statement_timeout = '5000ms'")
cursor.execute("set local lock_timeout = '1000ms'")
def _load_session_contract(self, cursor: Any) -> dict[str, Any]:
cursor.execute(
"""
select current_database() as database,
current_user as database_principal,
current_setting('transaction_read_only') as transaction_read_only,
pg_has_role(current_user, %s, 'member') as has_authorization_role,
has_table_privilege(current_user, 'public.claims', 'select')
and has_table_privilege(current_user, 'public.sources', 'select')
and has_table_privilege(current_user, 'public.claim_evidence', 'select')
and has_table_privilege(current_user, 'public.claim_edges', 'select')
and has_table_privilege(current_user, 'kb_stage.kb_proposals', 'select')
as has_required_reads,
not (
has_table_privilege(current_user, 'public.claims', 'insert,update,delete,truncate')
or has_table_privilege(current_user, 'public.sources', 'insert,update,delete,truncate')
or has_table_privilege(current_user, 'public.claim_evidence', 'insert,update,delete,truncate')
or has_table_privilege(current_user, 'public.claim_edges', 'insert,update,delete,truncate')
or has_table_privilege(current_user, 'kb_stage.kb_proposals', 'insert,update,delete,truncate')
) as required_writes_denied
""",
(self.settings.authorization_role,),
)
session = _row_dict(cursor, cursor.fetchone())
if session is None:
raise ReaderContractError("database session contract returned no row")
if session["database"] != self.settings.database:
raise ReaderContractError("database session selected an unexpected database")
if session["database_principal"] != self.settings.iam_database_user:
raise ReaderContractError("database session selected an unexpected principal")
if session["transaction_read_only"] != "on":
raise ReaderContractError("database session is not read-only")
if not session["has_authorization_role"] or not session["has_required_reads"]:
raise ReaderContractError("database principal lacks the exact read authorization")
if not session["required_writes_denied"]:
raise ReaderContractError("database principal has an unexpected write privilege")
return session
@staticmethod
def _load_claim(cursor: Any, claim_id: str | None) -> dict[str, Any] | None:
columns = """
select c.id::text as id,
c.type::text as type,
left(c.text, 12000) as text,
c.status::text as status,
c.confidence,
coalesce(c.tags, '{}'::text[]) as tags,
c.superseded_by::text as superseded_by,
c.created_at,
c.updated_at
from public.claims c
"""
if claim_id is not None:
cursor.execute(columns + " where c.id = %s::uuid", (claim_id,))
return _row_dict(cursor, cursor.fetchone())
cursor.execute(
columns
+ """
where exists (
select 1 from public.claim_evidence ce where ce.claim_id = c.id
)
order by coalesce(c.updated_at, c.created_at) desc, c.id desc
limit 1
"""
)
claim = _row_dict(cursor, cursor.fetchone())
if claim is not None:
return claim
cursor.execute(columns + " order by coalesce(c.updated_at, c.created_at) desc, c.id desc limit 1")
return _row_dict(cursor, cursor.fetchone())
@staticmethod
def _load_evidence(cursor: Any, claim_id: str) -> list[dict[str, Any]]:
cursor.execute(
"""
select ce.id::text as evidence_id,
ce.role::text as role,
ce.weight,
ce.created_at as linked_at,
s.id::text as source_id,
s.source_type::text as source_type,
s.url,
s.storage_path,
left(coalesce(s.excerpt, ''), 2400) as excerpt,
s.hash as content_hash,
s.captured_at,
s.created_at as source_created_at
from public.claim_evidence ce
join public.sources s on s.id = ce.source_id
where ce.claim_id = %s::uuid
order by ce.weight desc nulls last, ce.created_at, ce.id
limit 30
""",
(claim_id,),
)
return _rows(cursor)
@staticmethod
def _load_edges(cursor: Any, claim_id: str) -> list[dict[str, Any]]:
cursor.execute(
"""
select e.id::text as edge_id,
case when e.from_claim = %s::uuid then 'outgoing' else 'incoming' end as direction,
e.edge_type::text as edge_type,
e.weight,
case when e.from_claim = %s::uuid then e.to_claim::text else e.from_claim::text end
as connected_claim_id,
e.created_at
from public.claim_edges e
where e.from_claim = %s::uuid or e.to_claim = %s::uuid
order by e.created_at, e.id
limit 40
""",
(claim_id, claim_id, claim_id, claim_id),
)
return _rows(cursor)
@staticmethod
def _load_proposal_counts(cursor: Any) -> dict[str, int]:
cursor.execute(
"select status::text as status, count(*)::int as count "
"from kb_stage.kb_proposals group by status order by status"
)
return {str(status): int(count) for status, count in cursor.fetchall()}
@staticmethod
def _load_related_proposals(cursor: Any, claim_id: str) -> list[dict[str, Any]]:
cursor.execute(
"""
select p.id::text as id,
p.proposal_type::text as proposal_type,
p.status::text as status,
to_jsonb(p)->>'source_ref' as source_ref,
p.reviewed_at,
p.applied_at,
p.updated_at
from kb_stage.kb_proposals p
where p.payload::text like %s
order by p.updated_at desc, p.id desc
limit 20
""",
(f"%{claim_id}%",),
)
return _rows(cursor)
@staticmethod
def _load_recent_proposals(cursor: Any) -> list[dict[str, Any]]:
cursor.execute(
"""
select p.id::text as id,
p.proposal_type::text as proposal_type,
p.status::text as status,
to_jsonb(p)->>'source_ref' as source_ref,
p.reviewed_at,
p.applied_at,
p.updated_at
from kb_stage.kb_proposals p
order by p.updated_at desc, p.id desc
limit 10
"""
)
return _rows(cursor)