"""Postgres store — operator-tier access layer (D-040, D-050, TASK-01-06). Async access via an asyncpg.Pool. Implements the IssuerKeyStore protocol (server/vc/issuer_keys.py) so VC verification can use either PraxisStore (SQLite, v0.3) or PgStore (Postgres, v0.4). No cross-DB joins (D-031); `learner_ref` is an opaque string in Postgres (not a FK to SQLite). """ from __future__ import annotations import json import uuid from typing import Any import asyncpg class PgStore: """Async Postgres store for the v0.4 operator tier.""" def __init__(self, pool: asyncpg.Pool) -> None: self.pool = pool # ── Operator CRUD ──────────────────────────────────────────────────── async def get_operator_by_username(self, username: str) -> dict | None: async with self.pool.acquire() as conn: row = await conn.fetchrow( "SELECT id, username, password_hash, display_name, role, " "is_active, created_at, last_login_at " "FROM operators WHERE username = $1", username, ) return dict(row) if row else None async def get_operator_by_id(self, operator_id: str) -> dict | None: async with self.pool.acquire() as conn: row = await conn.fetchrow( "SELECT id, username, password_hash, display_name, role, " "is_active, created_at, last_login_at " "FROM operators WHERE id = $1", operator_id, ) return dict(row) if row else None async def update_last_login(self, operator_id: str) -> None: async with self.pool.acquire() as conn: await conn.execute( "UPDATE operators SET last_login_at = now() WHERE id = $1", operator_id, ) async def insert_operator( self, username: str, password_hash: str, display_name: str | None = None, *, on_conflict_update: bool = False, ) -> str | None: """Insert an operator (idempotent on username). Returns the id, or None if the row already existed and on_conflict_update is False.""" async with self.pool.acquire() as conn: if on_conflict_update: row = await conn.fetchrow( "INSERT INTO operators (username, password_hash, display_name) " "VALUES ($1, $2, $3) " "ON CONFLICT (username) DO UPDATE SET " "password_hash = excluded.password_hash, " "display_name = excluded.display_name " "RETURNING id", username, password_hash, display_name, ) return str(row["id"]) if row else None row = await conn.fetchrow( "INSERT INTO operators (username, password_hash, display_name) " "VALUES ($1, $2, $3) " "ON CONFLICT (username) DO NOTHING " "RETURNING id", username, password_hash, display_name, ) return str(row["id"]) if row else None # ── Cohort aggregate read/write ────────────────────────────────────── async def get_cohort_aggregates( self, path: str, metric: str, since_date: Any, ) -> list[dict]: async with self.pool.acquire() as conn: rows = await conn.fetch( "SELECT path, metric, window_start, window_end, value, " "cell_count, cell_suppressed, updated_at " "FROM cohort_aggregates " "WHERE path = $1 AND metric = $2 AND window_start >= $3 " "ORDER BY window_start", path, metric, since_date, ) return [dict(r) for r in rows] async def upsert_cohort_aggregate( self, path: str, metric: str, window_start: Any, window_end: Any, value: float | None, cell_count: int, cell_suppressed: bool, ) -> None: async with self.pool.acquire() as conn: await conn.execute( "INSERT INTO cohort_aggregates " "(path, metric, window_start, window_end, value, cell_count, " "cell_suppressed, updated_at) " "VALUES ($1, $2, $3, $4, $5, $6, $7, now()) " "ON CONFLICT (path, metric, window_start) DO UPDATE SET " "window_end = excluded.window_end, value = excluded.value, " "cell_count = excluded.cell_count, " "cell_suppressed = excluded.cell_suppressed, " "updated_at = now()", path, metric, window_start, window_end, value, cell_count, cell_suppressed, ) # ── IssuerKeyStore protocol (D-051, TASK-04-02) ────────────────────── async def init_issuer_key( self, key_id: str, public_key: str, private_key_enc: bytes | None, ) -> None: async with self.pool.acquire() as conn: await conn.execute( "INSERT INTO issuer_keys (id, public_key, private_key_enc, status) " "VALUES ($1, $2, $3, 'active') " "ON CONFLICT (id) DO NOTHING", key_id, public_key, private_key_enc if private_key_enc is not None else b"", ) async def get_active_signing_key_row(self) -> dict | None: async with self.pool.acquire() as conn: row = await conn.fetchrow( "SELECT id, public_key, private_key_enc, status, created_at " "FROM issuer_keys WHERE status = 'active' " "ORDER BY created_at DESC LIMIT 1" ) return dict(row) if row else None async def get_public_key_row(self, key_id: str) -> dict | None: # Queries by id (NOT status) so superseded keys are found too — # this is the R-VC-MIG-01 verification fallback (D-051). async with self.pool.acquire() as conn: row = await conn.fetchrow( "SELECT id, public_key, private_key_enc, status, created_at " "FROM issuer_keys WHERE id = $1", key_id, ) return dict(row) if row else None async def set_issuer_key_superseded(self, key_id: str) -> None: async with self.pool.acquire() as conn: await conn.execute( "UPDATE issuer_keys SET status = 'superseded' WHERE id = $1", key_id, ) # ── Credential methods ─────────────────────────────────────────────── async def insert_credential( self, cred_id: str, learner_ref: str, payload_json: str, signature_b64: str, *, operator_id: str | None = None, vc_type: str = "MasteryCredential", ) -> None: async with self.pool.acquire() as conn: await conn.execute( "INSERT INTO issued_credentials " "(id, operator_id, learner_ref, vc_type, payload_jsonb, " "signature_b64, status) " "VALUES ($1, $2, $3, $4, $5::jsonb, $6, 'active')", cred_id, operator_id, learner_ref, vc_type, payload_json, signature_b64, ) async def get_credential(self, cred_id: str) -> dict | None: # Returns a row shaped like PraxisStore.get_credential so the # verification code can use either store interchangeably. async with self.pool.acquire() as conn: row = await conn.fetchrow( "SELECT id, learner_ref, " "payload_jsonb::text AS vc_payload_json, signature_b64, " "status, issued_at " "FROM issued_credentials WHERE id = $1", cred_id, ) return dict(row) if row else None async def set_credential_status(self, cred_id: str, status: str) -> None: extra = ", revoked_at = now()" if status == "revoked" else "" async with self.pool.acquire() as conn: await conn.execute( f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2", status, cred_id, ) async def list_credentials(self, operator_id: str | None = None) -> list[dict]: async with self.pool.acquire() as conn: if operator_id is None: rows = await conn.fetch( "SELECT id, learner_ref, vc_type, status, issued_at, " "revoked_at FROM issued_credentials ORDER BY issued_at DESC" ) else: rows = await conn.fetch( "SELECT id, learner_ref, vc_type, status, issued_at, " "revoked_at FROM issued_credentials " "WHERE operator_id = $1 ORDER BY issued_at DESC", operator_id, ) return [dict(r) for r in rows] # ── Mastery gate event ─────────────────────────────────────────────── async def record_gate_event( self, learner_ref: str, path_id: str, scenario_id: str | None = None, gate_outcome: str | None = None, rubric_scores_jsonb: Any | None = None, ) -> str: event_id = str(uuid.uuid4()) scores_json = ( rubric_scores_jsonb if isinstance(rubric_scores_jsonb, str) else (json.dumps(rubric_scores_jsonb) if rubric_scores_jsonb is not None else None) ) async with self.pool.acquire() as conn: await conn.execute( "INSERT INTO mastery_gate_events " "(id, learner_ref, scenario_id, path_id, gate_outcome, " "rubric_scores_jsonb, source) " "VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'sync')", event_id, learner_ref, scenario_id, path_id, gate_outcome, scores_json, ) return event_id __all__ = ["PgStore"]