bdcf793db2
Phase 2 (Integration + Tech-Debt + NFR Measurement) complete. 4 slices, 2 waves, 9 tasks. 4 REQs covered. 60 new tests (469 total). 8 v0.4 P1+ tech-debt findings addressed. Verify: APPROVE_WITH_NOTES. NFR measurement (p95 latency + guardrail FP/FN), cohort aggregation assist metrics (5 new metrics, no schema change), assist cost tracking + C-3 budget check, tech-debt wave (argon2id offload, cookie-secret validation, credential enum, f-string SQL, cache persistence, zoneinfo, audit log, 429 mock). ---ci--- project: praxis phase: 2 milestone: v0.5 status: complete requirements: covered: [REQ-NFR-ASSIST-01, REQ-IDEATE-04, REQ-IDEATE-06, REQ-IDEATE-07] partial: [] ---/ci---
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""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:
|
|
"""Set a credential's status (TASK-12-03, P1+ #4/#8 from v0.4 REVIEW).
|
|
|
|
Validates `status` against the allowed enum ('active', 'revoked') +
|
|
uses two explicit parameterized queries (no f-string interpolation in
|
|
SQL — P1+ #8 code smell fix). 'revoked' sets revoked_at=now(); 'active'
|
|
clears revoked_at=NULL (re-activation).
|
|
|
|
P1+ #4: the status field is now validated (raises ValueError on invalid
|
|
status — previously accepted any string).
|
|
P1+ #8: the f-string interpolation (`, revoked_at = now()` or empty)
|
|
is replaced with two explicit parameterized queries.
|
|
"""
|
|
if status not in ("active", "revoked"):
|
|
raise ValueError(f"Invalid credential status: {status!r}")
|
|
async with self.pool.acquire() as conn:
|
|
if status == "revoked":
|
|
await conn.execute(
|
|
"UPDATE issued_credentials SET status = $1, revoked_at = now() "
|
|
"WHERE id = $2",
|
|
status, cred_id,
|
|
)
|
|
else:
|
|
# 'active' clears revoked_at (re-activation).
|
|
await conn.execute(
|
|
"UPDATE issued_credentials SET status = $1, revoked_at = NULL "
|
|
"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"] |