Implement the engine spine: claim, outbox, machine, API
Contracts first (T02–T04): approval claim schema with issuer, freshness,
and binding digest; local transactional outbox wire; load-bearing cadence
as heartbeat or reconciliation (layer.yaml declared).
Then the object (T06–T08): SQLite closed state machine, CAS supersession,
distinct-approver fail-closed, revocation without holder cooperation,
outbox insert in the same transaction. Tests fail the mutation when
emission fails, and revoke while the drain sink is down.
Introspection GET /v1/approvals/{id}/claim is a PIP fact, not a decision.
No public consume (T05 waits on GH-WP-0002-T06). Canon T-06 coverage for
wrong binding, expiry, revoke, and supersede.
FLEX-WP-0017 T03 is unblocked on this object; T05 remains blocked only on
consumption ordering.
Assistant: grok
Assistant-Session: 01a04ceb-2057-7e20-b0f9-c282964d5dd9
This commit is contained in:
parent
624e43f554
commit
9c9528f5b2
29 changed files with 2121 additions and 26 deletions
24
approval_engine/__init__.py
Normal file
24
approval_engine/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""approval-engine — PIP for the approval object."""
|
||||
|
||||
from .binding import binding_digest, canonical_binding
|
||||
from .errors import (
|
||||
ApprovalError,
|
||||
Conflict,
|
||||
DuplicateApprover,
|
||||
NotFound,
|
||||
StoreUnavailable,
|
||||
Unprocessable,
|
||||
)
|
||||
from .store import Engine
|
||||
|
||||
__all__ = [
|
||||
"Engine",
|
||||
"ApprovalError",
|
||||
"Conflict",
|
||||
"DuplicateApprover",
|
||||
"NotFound",
|
||||
"StoreUnavailable",
|
||||
"Unprocessable",
|
||||
"binding_digest",
|
||||
"canonical_binding",
|
||||
]
|
||||
144
approval_engine/api.py
Normal file
144
approval_engine/api.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""HTTP surface. Introspection and mutation; never a decision; never consume."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from .errors import ApprovalError
|
||||
from .store import Engine
|
||||
|
||||
FORBIDDEN_DECISION_KEYS = frozenset({"effect", "decision", "allow", "deny"})
|
||||
|
||||
|
||||
def _read_json(environ: dict[str, Any]) -> dict[str, Any]:
|
||||
length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
if length == 0:
|
||||
return {}
|
||||
raw = environ["wsgi.input"].read(length)
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ApprovalError("invalid json") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ApprovalError("json object required")
|
||||
return data
|
||||
|
||||
|
||||
def _assert_not_decision(payload: Any) -> None:
|
||||
if isinstance(payload, dict):
|
||||
bad = FORBIDDEN_DECISION_KEYS & set(payload)
|
||||
if bad:
|
||||
raise RuntimeError(f"decision-shaped keys leaked: {sorted(bad)}")
|
||||
for value in payload.values():
|
||||
_assert_not_decision(value)
|
||||
elif isinstance(payload, list):
|
||||
for item in payload:
|
||||
_assert_not_decision(item)
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
def __call__(self, environ: dict[str, Any], start_response: Callable) -> list[bytes]:
|
||||
method = environ.get("REQUEST_METHOD", "GET").upper()
|
||||
path = environ.get("PATH_INFO") or "/"
|
||||
try:
|
||||
status, body = self.dispatch(method, path, environ)
|
||||
except ApprovalError as exc:
|
||||
status, body = exc.http_status, {"error": exc.reason_code, "message": str(exc)}
|
||||
except ValueError as exc:
|
||||
status, body = 422, {"error": "unprocessable", "message": str(exc)}
|
||||
_assert_not_decision(body)
|
||||
payload = json.dumps(body, sort_keys=True).encode("utf-8")
|
||||
start_response(
|
||||
f"{status} {'OK' if status < 400 else 'ERROR'}",
|
||||
[
|
||||
("Content-Type", "application/json"),
|
||||
("Content-Length", str(len(payload))),
|
||||
],
|
||||
)
|
||||
return [payload]
|
||||
|
||||
def dispatch(self, method: str, path: str, environ: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
if path in ("/readyz", "/v1/readyz") and method == "GET":
|
||||
self.engine.outbox_stats()
|
||||
return 200, {"status": "ok", "store": "ok"}
|
||||
if path == "/v1/cadence" and method == "GET":
|
||||
return 200, self.engine.transition_counts() | {"form": "heartbeat-or-reconciliation"}
|
||||
if path == "/v1/outbox/stats" and method == "GET":
|
||||
return 200, self.engine.outbox_stats()
|
||||
if path == "/v1/heartbeat" and method == "POST":
|
||||
return 200, self.engine.emit_heartbeat()
|
||||
if path == "/v1/approvals" and method == "POST":
|
||||
data = _read_json(environ)
|
||||
obj = self.engine.create(
|
||||
data.get("binding") or {},
|
||||
data.get("validity") or {},
|
||||
int(data.get("required_count") or 1),
|
||||
pdp_digest=data.get("pdp_digest"),
|
||||
approval_id=data.get("id"),
|
||||
)
|
||||
return 201, obj.as_dict()
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals":
|
||||
approval_id = parts[2]
|
||||
rest = parts[3:]
|
||||
if rest == ["consume"] or path.endswith("/consume"):
|
||||
return 404, {"error": "not_found", "message": "consume is not implemented"}
|
||||
if not rest and method == "GET":
|
||||
return 200, self.engine.get(approval_id).as_dict()
|
||||
if rest == ["claim"] and method == "GET":
|
||||
return 200, self.engine.claim(approval_id)
|
||||
if rest == ["entries"] and method == "POST":
|
||||
data = _read_json(environ)
|
||||
obj = self.engine.add_entry(
|
||||
approval_id,
|
||||
data.get("subject_id") or "",
|
||||
assurance=data.get("assurance"),
|
||||
evidence_ref=data.get("evidence_ref"),
|
||||
)
|
||||
return 200, obj.as_dict()
|
||||
if rest == ["revoke"] and method == "POST":
|
||||
return 200, self.engine.revoke(approval_id).as_dict()
|
||||
if rest == ["supersede"] and method == "POST":
|
||||
data = _read_json(environ)
|
||||
return 200, self.engine.supersede(approval_id, data.get("successor_id"))
|
||||
if "check" in path or path.endswith("/authorize") or path.endswith("/consume"):
|
||||
return 404, {"error": "not_found", "message": "no such surface"}
|
||||
return 404, {"error": "not_found", "message": path}
|
||||
|
||||
|
||||
def call(app: App, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
||||
"""In-process WSGI helper for tests."""
|
||||
raw = json.dumps(body or {}).encode("utf-8") if body is not None else b""
|
||||
environ = {
|
||||
"REQUEST_METHOD": method,
|
||||
"PATH_INFO": path,
|
||||
"wsgi.input": _Bytes(raw),
|
||||
"CONTENT_LENGTH": str(len(raw)) if body is not None else "0",
|
||||
"QUERY_STRING": "",
|
||||
}
|
||||
status_headers: list[tuple[str, list]] = []
|
||||
|
||||
def start_response(status: str, headers: list[tuple[str, str]]) -> None:
|
||||
status_headers.append((status, headers))
|
||||
|
||||
result = b"".join(app(environ, start_response))
|
||||
code = int(status_headers[0][0].split()[0])
|
||||
return code, json.loads(result.decode("utf-8"))
|
||||
|
||||
|
||||
class _Bytes:
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self._data = data
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
if n < 0:
|
||||
out, self._data = self._data, b""
|
||||
return out
|
||||
out, self._data = self._data[:n], self._data[n:]
|
||||
return out
|
||||
45
approval_engine/binding.py
Normal file
45
approval_engine/binding.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Canonical binding and native digest.
|
||||
|
||||
The native digest is SHA-256 over sorted-key JSON of five fields. It is not
|
||||
Go json.Marshal of a flex-auth CheckRequest — that value, when known at
|
||||
issue, is stored separately as pdp_digest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
BINDING_FIELDS = ("action", "actor", "principal", "purpose", "target")
|
||||
|
||||
|
||||
def canonical_binding(binding: dict[str, Any]) -> dict[str, Any]:
|
||||
missing = [k for k in BINDING_FIELDS if k not in binding or binding[k] in (None, "")]
|
||||
if missing:
|
||||
raise ValueError(f"binding missing {missing}")
|
||||
if not isinstance(binding["target"], dict):
|
||||
raise ValueError("binding.target must be an object")
|
||||
return {k: binding[k] for k in BINDING_FIELDS}
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def binding_digest(binding: dict[str, Any]) -> str:
|
||||
payload = canonical_json(canonical_binding(binding))
|
||||
return "sha256:" + hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def require_digest(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not DIGEST_RE.match(value):
|
||||
raise ValueError("digest must match sha256:<64 lowercase hex>")
|
||||
return value
|
||||
28
approval_engine/cli.py
Normal file
28
approval_engine/cli.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
from .api import App
|
||||
from .store import Engine
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="approval-engine")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
serve = sub.add_parser("serve", help="serve the introspection API")
|
||||
serve.add_argument("--db", default="approvals.sqlite")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8787)
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "serve":
|
||||
engine = Engine(args.db)
|
||||
httpd = make_server(args.host, args.port, App(engine))
|
||||
print(f"approval-engine on http://{args.host}:{args.port} db={args.db}")
|
||||
httpd.serve_forever()
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
28
approval_engine/errors.py
Normal file
28
approval_engine/errors.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
class ApprovalError(Exception):
|
||||
http_status = 400
|
||||
reason_code = "error"
|
||||
|
||||
|
||||
class NotFound(ApprovalError):
|
||||
http_status = 404
|
||||
reason_code = "not_found"
|
||||
|
||||
|
||||
class Conflict(ApprovalError):
|
||||
http_status = 409
|
||||
reason_code = "conflict"
|
||||
|
||||
|
||||
class DuplicateApprover(ApprovalError):
|
||||
http_status = 409
|
||||
reason_code = "duplicate_approver"
|
||||
|
||||
|
||||
class Unprocessable(ApprovalError):
|
||||
http_status = 422
|
||||
reason_code = "unprocessable"
|
||||
|
||||
|
||||
class StoreUnavailable(ApprovalError):
|
||||
http_status = 503
|
||||
reason_code = "store_unavailable"
|
||||
715
approval_engine/store.py
Normal file
715
approval_engine/store.py
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
"""SQLite-backed approval object, closed machine, local outbox.
|
||||
|
||||
All mutations run in BEGIN IMMEDIATE and insert the outbox row before COMMIT.
|
||||
There is no public consume; _cas_consume exists only so use-class emission
|
||||
can be tested without guessing GH-WP-0002-T06.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from .binding import binding_digest, canonical_binding, require_digest
|
||||
from .errors import (
|
||||
Conflict,
|
||||
DuplicateApprover,
|
||||
NotFound,
|
||||
StoreUnavailable,
|
||||
Unprocessable,
|
||||
)
|
||||
|
||||
ISSUER = "approval-engine"
|
||||
CLAIM_SCHEMA = "0.1"
|
||||
YIELDS_TO = "net-kingdom taxonomy request-claim schema (statute §17; unassigned)"
|
||||
DEFAULT_FRESHNESS_TTL = 30
|
||||
AUDIT_SCHEMA = "audit-core.event.v1alpha1"
|
||||
SOURCE = "approval-engine"
|
||||
SCOPE = "netkingdom-approvals"
|
||||
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS approvals (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
binding_json TEXT NOT NULL,
|
||||
binding_digest TEXT NOT NULL,
|
||||
pdp_digest TEXT,
|
||||
actor TEXT NOT NULL,
|
||||
principal TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
target_json TEXT NOT NULL,
|
||||
not_before TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
required_count INTEGER NOT NULL,
|
||||
superseded_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
approval_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
approved_at TEXT NOT NULL,
|
||||
assurance TEXT,
|
||||
evidence_ref TEXT,
|
||||
PRIMARY KEY (approval_id, subject_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
class TEXT NOT NULL,
|
||||
approval_id TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
drained_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
ACTIVE = ("requested", "approved")
|
||||
MUTABLE = ("requested", "approved")
|
||||
|
||||
|
||||
def utc_now(clock: Callable[[], datetime] | None = None) -> datetime:
|
||||
if clock is not None:
|
||||
now = clock()
|
||||
else:
|
||||
now = datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
return now.astimezone(timezone.utc).replace(microsecond=0)
|
||||
|
||||
|
||||
def iso(ts: datetime) -> str:
|
||||
return ts.astimezone(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def parse_iso(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Approval:
|
||||
id: str
|
||||
status: str
|
||||
binding: dict[str, Any]
|
||||
binding_digest: str
|
||||
pdp_digest: str | None
|
||||
actor: str
|
||||
principal: str
|
||||
action: str
|
||||
purpose: str
|
||||
target: dict[str, Any]
|
||||
not_before: str
|
||||
expires_at: str
|
||||
required_count: int
|
||||
superseded_by: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
entries: list[dict[str, Any]]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"status": self.status,
|
||||
"binding": {
|
||||
**self.binding,
|
||||
"digest": self.binding_digest,
|
||||
**({"pdp_digest": self.pdp_digest} if self.pdp_digest else {}),
|
||||
},
|
||||
"validity": {"not_before": self.not_before, "expires_at": self.expires_at},
|
||||
"required_count": self.required_count,
|
||||
"entries": self.entries,
|
||||
"superseded_by": self.superseded_by,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path = ":memory:",
|
||||
*,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
freshness_ttl: int = DEFAULT_FRESHNESS_TTL,
|
||||
fail_outbox: bool = False,
|
||||
tenant: str = "platform",
|
||||
) -> None:
|
||||
self.path = str(path)
|
||||
self.clock = clock
|
||||
self.freshness_ttl = freshness_ttl
|
||||
self.fail_outbox = fail_outbox
|
||||
self.tenant = tenant
|
||||
self._local = threading.local()
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
conn = sqlite3.connect(self.path, timeout=5, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
if self.path != ":memory:":
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is None:
|
||||
conn = self._connect()
|
||||
self._local.conn = conn
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
conn = self._conn()
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
|
||||
def close(self) -> None:
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
self._local.conn = None
|
||||
|
||||
def now(self) -> datetime:
|
||||
return utc_now(self.clock)
|
||||
|
||||
def create(
|
||||
self,
|
||||
binding: dict[str, Any],
|
||||
validity: dict[str, str],
|
||||
required_count: int = 1,
|
||||
*,
|
||||
pdp_digest: str | None = None,
|
||||
approval_id: str | None = None,
|
||||
) -> Approval:
|
||||
if required_count < 1:
|
||||
raise Unprocessable("required_count must be >= 1")
|
||||
canon = canonical_binding(binding)
|
||||
digest = binding_digest(canon)
|
||||
pdp = require_digest(pdp_digest)
|
||||
not_before = validity.get("not_before") or iso(self.now())
|
||||
expires_at = validity.get("expires_at")
|
||||
if not expires_at:
|
||||
raise Unprocessable("validity.expires_at is required")
|
||||
if parse_iso(expires_at) <= parse_iso(not_before):
|
||||
raise Unprocessable("expires_at must be after not_before")
|
||||
aid = approval_id or str(uuid4())
|
||||
now = iso(self.now())
|
||||
conn = self._conn()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"""INSERT INTO approvals (
|
||||
id, status, binding_json, binding_digest, pdp_digest,
|
||||
actor, principal, action, purpose, target_json,
|
||||
not_before, expires_at, required_count, superseded_by,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
aid,
|
||||
"requested",
|
||||
json.dumps(canon, sort_keys=True),
|
||||
digest,
|
||||
pdp,
|
||||
canon["actor"],
|
||||
canon["principal"],
|
||||
canon["action"],
|
||||
canon["purpose"],
|
||||
json.dumps(canon["target"], sort_keys=True),
|
||||
not_before,
|
||||
expires_at,
|
||||
required_count,
|
||||
None,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
raise Conflict("approval id already exists") from exc
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return self.get(aid)
|
||||
|
||||
def get(self, approval_id: str) -> Approval:
|
||||
conn = self._conn()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
if row is None:
|
||||
raise NotFound(approval_id)
|
||||
return self._hydrate(row, persist_expiry=True)
|
||||
|
||||
def _hydrate(self, row: sqlite3.Row, persist_expiry: bool) -> Approval:
|
||||
entries = [
|
||||
{
|
||||
"subject_id": e["subject_id"],
|
||||
"approved_at": e["approved_at"],
|
||||
"assurance": e["assurance"],
|
||||
"evidence_ref": e["evidence_ref"],
|
||||
}
|
||||
for e in self._conn()
|
||||
.execute(
|
||||
"SELECT subject_id, approved_at, assurance, evidence_ref "
|
||||
"FROM entries WHERE approval_id=? ORDER BY approved_at",
|
||||
(row["id"],),
|
||||
)
|
||||
.fetchall()
|
||||
]
|
||||
status = row["status"]
|
||||
if persist_expiry and status in ACTIVE:
|
||||
now = self.now()
|
||||
if now >= parse_iso(row["expires_at"]):
|
||||
status = self._expire(row["id"], status)
|
||||
binding = json.loads(row["binding_json"])
|
||||
return Approval(
|
||||
id=row["id"],
|
||||
status=status,
|
||||
binding=binding,
|
||||
binding_digest=row["binding_digest"],
|
||||
pdp_digest=row["pdp_digest"],
|
||||
actor=row["actor"],
|
||||
principal=row["principal"],
|
||||
action=row["action"],
|
||||
purpose=row["purpose"],
|
||||
target=json.loads(row["target_json"]),
|
||||
not_before=row["not_before"],
|
||||
expires_at=row["expires_at"],
|
||||
required_count=row["required_count"],
|
||||
superseded_by=row["superseded_by"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
def _expire(self, approval_id: str, from_status: str) -> str:
|
||||
conn = self._conn()
|
||||
now = iso(self.now())
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
cur = conn.execute(
|
||||
"UPDATE approvals SET status='expired', updated_at=? "
|
||||
"WHERE id=? AND status=?",
|
||||
(now, approval_id, from_status),
|
||||
)
|
||||
conn.commit()
|
||||
return "expired" if cur.rowcount == 1 else from_status
|
||||
except sqlite3.Error:
|
||||
conn.rollback()
|
||||
return from_status
|
||||
|
||||
def add_entry(
|
||||
self,
|
||||
approval_id: str,
|
||||
subject_id: str,
|
||||
*,
|
||||
assurance: str | None = None,
|
||||
evidence_ref: str | None = None,
|
||||
) -> Approval:
|
||||
if not subject_id:
|
||||
raise Unprocessable("subject_id is required")
|
||||
conn = self._conn()
|
||||
now = iso(self.now())
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
raise NotFound(approval_id)
|
||||
if row["status"] not in MUTABLE:
|
||||
conn.rollback()
|
||||
raise Conflict(f"cannot add entries in status {row['status']}")
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO entries (approval_id, subject_id, approved_at, assurance, evidence_ref) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(approval_id, subject_id, now, assurance, evidence_ref),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
raise DuplicateApprover(subject_id) from exc
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM entries WHERE approval_id=?", (approval_id,)
|
||||
).fetchone()[0]
|
||||
became_approved = False
|
||||
if row["status"] == "requested" and count >= row["required_count"]:
|
||||
conn.execute(
|
||||
"UPDATE approvals SET status='approved', updated_at=? WHERE id=? AND status='requested'",
|
||||
(now, approval_id),
|
||||
)
|
||||
became_approved = True
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE approvals SET updated_at=? WHERE id=?", (now, approval_id)
|
||||
)
|
||||
if became_approved:
|
||||
self._outbox_insert(
|
||||
conn,
|
||||
"issuance",
|
||||
approval_id,
|
||||
actor=row["actor"],
|
||||
extra={"binding_digest": row["binding_digest"], "required_count": row["required_count"]},
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return self.get(approval_id)
|
||||
|
||||
def revoke(self, approval_id: str) -> Approval:
|
||||
return self._terminal(approval_id, "revoked", "revocation")
|
||||
|
||||
def supersede(self, approval_id: str, successor_id: str | None = None) -> dict[str, Any]:
|
||||
successor_id = successor_id or str(uuid4())
|
||||
conn = self._conn()
|
||||
now = iso(self.now())
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
raise NotFound(approval_id)
|
||||
if row["status"] not in MUTABLE:
|
||||
conn.rollback()
|
||||
raise Conflict(f"cannot supersede in status {row['status']}")
|
||||
cur = conn.execute(
|
||||
"UPDATE approvals SET status='superseded', superseded_by=?, updated_at=? "
|
||||
"WHERE id=? AND status IN ('requested','approved')",
|
||||
(successor_id, now, approval_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
raise Conflict("supersession lost the compare-and-swap")
|
||||
existing = conn.execute("SELECT id FROM approvals WHERE id=?", (successor_id,)).fetchone()
|
||||
created_successor = False
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"""INSERT INTO approvals (
|
||||
id, status, binding_json, binding_digest, pdp_digest,
|
||||
actor, principal, action, purpose, target_json,
|
||||
not_before, expires_at, required_count, superseded_by,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
successor_id,
|
||||
"requested",
|
||||
row["binding_json"],
|
||||
row["binding_digest"],
|
||||
row["pdp_digest"],
|
||||
row["actor"],
|
||||
row["principal"],
|
||||
row["action"],
|
||||
row["purpose"],
|
||||
row["target_json"],
|
||||
row["not_before"],
|
||||
row["expires_at"],
|
||||
row["required_count"],
|
||||
None,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
created_successor = True
|
||||
self._outbox_insert(
|
||||
conn,
|
||||
"supersession",
|
||||
approval_id,
|
||||
actor=row["actor"],
|
||||
extra={
|
||||
"binding_digest": row["binding_digest"],
|
||||
"superseded_by": successor_id,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return {
|
||||
"superseded": self.get(approval_id).as_dict(),
|
||||
"successor_id": successor_id,
|
||||
"successor_created": created_successor,
|
||||
}
|
||||
|
||||
def _terminal(self, approval_id: str, status: str, event_class: str) -> Approval:
|
||||
conn = self._conn()
|
||||
now = iso(self.now())
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
raise NotFound(approval_id)
|
||||
cur = conn.execute(
|
||||
"UPDATE approvals SET status=?, updated_at=? "
|
||||
"WHERE id=? AND status IN ('requested','approved')",
|
||||
(status, now, approval_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
raise Conflict(f"cannot {status} from status {row['status']}")
|
||||
self._outbox_insert(
|
||||
conn,
|
||||
event_class,
|
||||
approval_id,
|
||||
actor=row["actor"],
|
||||
extra={"binding_digest": row["binding_digest"]},
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return self.get(approval_id)
|
||||
|
||||
def _cas_consume(self, approval_id: str) -> Approval:
|
||||
"""Unexported seam. Do not wire to HTTP. Blocked on GH-WP-0002-T06."""
|
||||
conn = self._conn()
|
||||
now = iso(self.now())
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
raise NotFound(approval_id)
|
||||
if row["status"] != "approved":
|
||||
conn.rollback()
|
||||
raise Conflict(f"cannot consume from status {row['status']}")
|
||||
if self.now() < parse_iso(row["not_before"]) or self.now() >= parse_iso(row["expires_at"]):
|
||||
conn.rollback()
|
||||
raise Conflict("cannot consume outside validity window")
|
||||
cur = conn.execute(
|
||||
"UPDATE approvals SET status='consumed', updated_at=? "
|
||||
"WHERE id=? AND status='approved'",
|
||||
(now, approval_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
raise Conflict("consumption lost the compare-and-swap")
|
||||
self._outbox_insert(
|
||||
conn,
|
||||
"use",
|
||||
approval_id,
|
||||
actor=row["actor"],
|
||||
extra={"binding_digest": row["binding_digest"]},
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return self.get(approval_id)
|
||||
|
||||
def _outbox_insert(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
event_class: str,
|
||||
approval_id: str | None,
|
||||
*,
|
||||
actor: str | None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
if self.fail_outbox:
|
||||
raise StoreUnavailable("outbox insert failed")
|
||||
if event_class not in EVENT_CLASSES:
|
||||
raise Unprocessable(f"unknown event class {event_class}")
|
||||
event_id = str(uuid4())
|
||||
created = iso(self.now())
|
||||
resource = f"approval:{approval_id}" if approval_id else "approval-engine:heartbeat"
|
||||
details = {"class": event_class, **(extra or {})}
|
||||
if approval_id:
|
||||
details["approval_id"] = approval_id
|
||||
payload = {
|
||||
"schema_version": AUDIT_SCHEMA,
|
||||
"event_id": event_id,
|
||||
"observed_at": created,
|
||||
"tenant": self.tenant,
|
||||
"scope": SCOPE,
|
||||
"source": SOURCE,
|
||||
"actor": actor,
|
||||
"action": f"approval.{event_class}",
|
||||
"resource": resource,
|
||||
"outcome": "success",
|
||||
"reason": None,
|
||||
"details": details,
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO outbox (event_id, class, approval_id, payload_json, created_at, drained_at) "
|
||||
"VALUES (?,?,?,?,?,NULL)",
|
||||
(event_id, event_class, approval_id, json.dumps(payload, sort_keys=True), created),
|
||||
)
|
||||
return event_id
|
||||
|
||||
def claim(self, approval_id: str) -> dict[str, Any]:
|
||||
obj = self.get(approval_id)
|
||||
observed = self.now()
|
||||
not_after = observed + timedelta(seconds=self.freshness_ttl)
|
||||
state, valid_now, consumed, reason = self._evaluate(obj, observed)
|
||||
binding = {
|
||||
**canonical_binding(obj.binding),
|
||||
"digest": obj.binding_digest,
|
||||
}
|
||||
if obj.pdp_digest:
|
||||
binding["pdp_digest"] = obj.pdp_digest
|
||||
return {
|
||||
"schema_version": CLAIM_SCHEMA,
|
||||
"kind": "approval-claim",
|
||||
"yields_to": YIELDS_TO,
|
||||
"issuer": ISSUER,
|
||||
"approval_id": obj.id,
|
||||
"state": state,
|
||||
"valid_now": valid_now,
|
||||
"consumed": consumed,
|
||||
"binding": binding,
|
||||
"freshness": {
|
||||
"observed_at": iso(observed),
|
||||
"ttl_seconds": self.freshness_ttl,
|
||||
"not_after": iso(not_after),
|
||||
},
|
||||
"validity": {"not_before": obj.not_before, "expires_at": obj.expires_at},
|
||||
"reason_code": reason,
|
||||
}
|
||||
|
||||
def _evaluate(self, obj: Approval, now: datetime) -> tuple[str, bool, bool, str]:
|
||||
consumed = obj.status == "consumed"
|
||||
if obj.status == "revoked":
|
||||
return "revoked", False, consumed, "revoked"
|
||||
if obj.status == "superseded":
|
||||
return "superseded", False, consumed, "superseded"
|
||||
if obj.status == "expired" or now >= parse_iso(obj.expires_at):
|
||||
return "expired", False, consumed, "expired"
|
||||
if obj.status == "consumed":
|
||||
return "consumed", False, True, "consumed"
|
||||
if obj.status == "requested":
|
||||
return "requested", False, False, "requested"
|
||||
if obj.status == "approved":
|
||||
if now < parse_iso(obj.not_before):
|
||||
return "approved", False, False, "not_yet_valid"
|
||||
if len({e["subject_id"] for e in obj.entries}) < obj.required_count:
|
||||
return "approved", False, False, "insufficient_approvers"
|
||||
return "valid", True, False, "ok"
|
||||
return obj.status, False, consumed, obj.status
|
||||
|
||||
def emit_heartbeat(self) -> dict[str, Any]:
|
||||
conn = self._conn()
|
||||
counts = self.transition_counts()
|
||||
counts["heartbeat"] += 1
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
event_id = self._outbox_insert(
|
||||
conn,
|
||||
"heartbeat",
|
||||
None,
|
||||
actor=None,
|
||||
extra={"assertion": "nothing-to-report", "counts": counts},
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return {"event_id": event_id, "assertion": "nothing-to-report", "counts": counts}
|
||||
|
||||
def transition_counts(self) -> dict[str, int]:
|
||||
conn = self._conn()
|
||||
try:
|
||||
rows = conn.execute("SELECT class, COUNT(*) AS n FROM outbox GROUP BY class").fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
counts = {c: 0 for c in EVENT_CLASSES}
|
||||
for row in rows:
|
||||
counts[row["class"]] = row["n"]
|
||||
return counts
|
||||
|
||||
def outbox_stats(self) -> dict[str, Any]:
|
||||
conn = self._conn()
|
||||
now = self.now()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, class, created_at, drained_at FROM outbox"
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
pending = [r for r in rows if r["drained_at"] is None]
|
||||
ages = [(now - parse_iso(r["created_at"])).total_seconds() for r in pending]
|
||||
return {
|
||||
"total": len(rows),
|
||||
"pending": len(pending),
|
||||
"max_age_seconds": max(ages) if ages else 0,
|
||||
"counts": self.transition_counts(),
|
||||
}
|
||||
|
||||
def undrained(self) -> list[dict[str, Any]]:
|
||||
conn = self._conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, class, approval_id, payload_json, created_at "
|
||||
"FROM outbox WHERE drained_at IS NULL ORDER BY created_at"
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
return [
|
||||
{
|
||||
"event_id": r["event_id"],
|
||||
"class": r["class"],
|
||||
"approval_id": r["approval_id"],
|
||||
"payload": json.loads(r["payload_json"]),
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def drain(self, sink: Callable[[dict[str, Any]], None]) -> dict[str, int]:
|
||||
"""Deliver undrained payloads. Sink failure leaves the row pending.
|
||||
|
||||
Object mutations are already committed; this must not roll them back.
|
||||
"""
|
||||
delivered = 0
|
||||
failed = 0
|
||||
conn = self._conn()
|
||||
for item in self.undrained():
|
||||
try:
|
||||
sink(item["payload"])
|
||||
except Exception:
|
||||
failed += 1
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE outbox SET drained_at=? WHERE event_id=? AND drained_at IS NULL",
|
||||
(iso(self.now()), item["event_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
delivered += 1
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
return {"delivered": delivered, "failed": failed}
|
||||
Loading…
Add table
Add a link
Reference in a new issue