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
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -3,3 +3,11 @@
|
||||||
.claude/*
|
.claude/*
|
||||||
!.claude/rules/
|
!.claude/rules/
|
||||||
!.claude/rules/*.md
|
!.claude/rules/*.md
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
approvals.sqlite
|
||||||
|
*.sqlite-wal
|
||||||
|
*.sqlite-shm
|
||||||
|
|
|
||||||
11
Makefile
Normal file
11
Makefile
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
SHELL := /usr/bin/env bash
|
||||||
|
.DEFAULT_GOAL := test
|
||||||
|
|
||||||
|
test: ## Run unit tests
|
||||||
|
python3 -m pytest -q
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} \
|
||||||
|
/^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
.PHONY: test help
|
||||||
|
|
@ -20,5 +20,13 @@ Flexibility here would be a defect. Graded, evidence-based progression belongs t
|
||||||
`maturity-engine`; the two engines are deliberate opposites.
|
`maturity-engine`; the two engines are deliberate opposites.
|
||||||
|
|
||||||
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
|
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
|
||||||
|
Claim: [docs/approval-claim.md](docs/approval-claim.md).
|
||||||
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
|
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
|
||||||
layer model.
|
layer model.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
python3 -m approval_engine.cli serve --db approvals.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no public `consume`. That waits on `GH-WP-0002-T06`.
|
||||||
|
|
|
||||||
49
SCOPE.md
49
SCOPE.md
|
|
@ -93,14 +93,14 @@ the other side has not assented to.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- Status: **seed**. The repository holds `INTENT.md`, this file, `layer.yaml`,
|
- Status: **first-cut spine**. SQLite-backed object, closed machine, local
|
||||||
and a README. There is no store, no API, no outbox, no tests, no runtime.
|
outbox, WSGI introspection API, claim contract. Not a production deploy.
|
||||||
- Layer declaration exists in this engine's own voice (INTENT frontmatter +
|
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
|
||||||
`layer.yaml`). Conformance checks against Tooling contact are vacuously
|
`cadence.yaml`. No Tooling contacts.
|
||||||
clean: there is no code to contact anything.
|
- Consumption is not a public API (`APPROVAL-WP-0001-T05` waits on
|
||||||
- Consumption ordering remains unresolved estate-wide (`GH-WP-0002-T06`).
|
`GH-WP-0002-T06`).
|
||||||
- Taxonomy request-claim schema is proposed, not assigned (statute §17).
|
- Taxonomy request-claim schema is still unassigned; the local claim yields.
|
||||||
- Work: `APPROVAL-WP-0001`.
|
- Work: `APPROVAL-WP-0001`. Tests: `make test`.
|
||||||
|
|
||||||
## How It Fits
|
## How It Fits
|
||||||
|
|
||||||
|
|
@ -143,9 +143,32 @@ it does not become a Railiance axis.
|
||||||
|
|
||||||
## Provided Capabilities
|
## Provided Capabilities
|
||||||
|
|
||||||
None. The spine described above is INTENT and first-cut SCOPE, not a shipped
|
```capability
|
||||||
surface. No store, no API, no claim, no outbox.
|
type: api
|
||||||
|
title: Approval introspection claim
|
||||||
|
description: >
|
||||||
|
GET /v1/approvals/{id}/claim returns an input claim with identifier,
|
||||||
|
canonical-binding digest, optional PDP digest, issuer, and freshness.
|
||||||
|
It does not decide whether an action is permitted.
|
||||||
|
keywords: [approval, claim, pip, digest, freshness]
|
||||||
|
```
|
||||||
|
|
||||||
When the first surface ships, this section gains `capability` blocks for the
|
```capability
|
||||||
introspection API, the mutation API, and the outbox. Until then, declaring
|
type: api
|
||||||
capabilities would advertise a PIP that does not exist.
|
title: Approval object lifecycle
|
||||||
|
description: >
|
||||||
|
Create, collect authenticated entries with distinct-approver counting,
|
||||||
|
atomically supersede, and revoke without holder cooperation. No public
|
||||||
|
consume until consumption ordering is settled.
|
||||||
|
keywords: [approval, state-machine, cas, revocation, supersession]
|
||||||
|
```
|
||||||
|
|
||||||
|
```capability
|
||||||
|
type: infrastructure
|
||||||
|
title: Local transactional outbox
|
||||||
|
description: >
|
||||||
|
Issuance, use, supersession, revocation, and heartbeat rows are inserted
|
||||||
|
in the same SQLite transaction as the mutation. Drain is asynchronous;
|
||||||
|
an audit-core outage does not block revocation.
|
||||||
|
keywords: [outbox, audit-core, emission-atomicity, heartbeat]
|
||||||
|
```
|
||||||
|
|
|
||||||
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}
|
||||||
38
cadence.yaml
Normal file
38
cadence.yaml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Source-side emission cadence for load-bearing approval evidence.
|
||||||
|
# Statute §9.6; detection surface GH-WP-0002-T04.
|
||||||
|
schema_version: "0.1"
|
||||||
|
source: approval-engine
|
||||||
|
kind: load-bearing
|
||||||
|
form: heartbeat-or-reconciliation
|
||||||
|
rate_monitoring: forbidden
|
||||||
|
|
||||||
|
heartbeat:
|
||||||
|
class: heartbeat
|
||||||
|
interval: 24h
|
||||||
|
assertion: nothing-to-report
|
||||||
|
missing: finding
|
||||||
|
|
||||||
|
reconciliation:
|
||||||
|
compare:
|
||||||
|
local: committed outbox counts per class
|
||||||
|
remote: "audit-core event counts where source=approval-engine"
|
||||||
|
divergence: finding
|
||||||
|
undrained_local: lag-not-divergence
|
||||||
|
|
||||||
|
lag_bound:
|
||||||
|
outbox_depth: 100
|
||||||
|
outbox_age: 1h
|
||||||
|
exceed: finding
|
||||||
|
|
||||||
|
classes:
|
||||||
|
issuance:
|
||||||
|
action: approval.issuance
|
||||||
|
use:
|
||||||
|
action: approval.use
|
||||||
|
note: "Internal CAS only until GH-WP-0002-T06."
|
||||||
|
supersession:
|
||||||
|
action: approval.supersession
|
||||||
|
revocation:
|
||||||
|
action: approval.revocation
|
||||||
|
heartbeat:
|
||||||
|
action: approval.heartbeat
|
||||||
120
docs/approval-claim.md
Normal file
120
docs/approval-claim.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Approval claim contract
|
||||||
|
|
||||||
|
**Schema:** [`../schemas/approval_claim.schema.json`](../schemas/approval_claim.schema.json)
|
||||||
|
**Version:** 0.1
|
||||||
|
**Issuer:** `approval-engine`
|
||||||
|
**Consumer:** `access-engine` (`flex-auth` until the governed rename)
|
||||||
|
|
||||||
|
This is the input claim `access-engine` consumes under statute §6.2. It is a
|
||||||
|
fact about an approval object. It is **not a decision**. An implementer can
|
||||||
|
satisfy this document without reading this engine's source.
|
||||||
|
|
||||||
|
Yields to the Taxonomy request-claim schema (statute §17) when that artifact
|
||||||
|
exists and is assented. This local shape is not permanent.
|
||||||
|
|
||||||
|
## Fetch
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /v1/approvals/{id}/claim
|
||||||
|
```
|
||||||
|
|
||||||
|
Fail-closed when this engine's store is unavailable (HTTP 503). An `audit-core`
|
||||||
|
outage does not affect this read.
|
||||||
|
|
||||||
|
There is no `/v1/check`, no `/authorize`, and no field named `effect`,
|
||||||
|
`decision`, `allow`, or `deny`. If a response contains those, it is out of
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## What the claim carries
|
||||||
|
|
||||||
|
| Field | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| `approval_id` | Reconstructable from the decision record. |
|
||||||
|
| `binding.digest` | Distinguishes *approved* from *approved for this exact request*. |
|
||||||
|
| `binding.pdp_digest` | Optional. When recorded at issue, compare to `NewDecisionBinding.request_digest`. |
|
||||||
|
| `issuer` | Always `approval-engine`. |
|
||||||
|
| `freshness` | So the PDP can state a deadline for this input class (§9.7.2), not a single fiction covering every source. |
|
||||||
|
| `valid_now` | Current-state predicate. Not permission. |
|
||||||
|
| `reason_code` | Why `valid_now` is false, when it is. |
|
||||||
|
|
||||||
|
`valid_now` is true only when all of:
|
||||||
|
|
||||||
|
1. enough distinct authenticated approvers have been recorded;
|
||||||
|
2. now is inside `validity.not_before` … `validity.expires_at`;
|
||||||
|
3. the object is not consumed, superseded, revoked, or expired.
|
||||||
|
|
||||||
|
Holding a claim with `valid_now: true` is not authority to act. It is one
|
||||||
|
input the decision point weighs.
|
||||||
|
|
||||||
|
## Canonical binding digest
|
||||||
|
|
||||||
|
The native digest is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sha256: + hex( SHA-256( canonical_json({action, actor, principal, purpose, target}) ) )
|
||||||
|
```
|
||||||
|
|
||||||
|
`canonical_json` is UTF-8 JSON with sorted keys at every object level and no
|
||||||
|
insignificant whitespace (`separators=(',', ':')`). `target` is an object; its
|
||||||
|
keys are sorted too.
|
||||||
|
|
||||||
|
Wrong-action, wrong-target, and wrong-scope are distinguishable because they
|
||||||
|
change that JSON and therefore the digest. A decision rendered against
|
||||||
|
approval A for request R cannot be replayed for request R' if the consumer
|
||||||
|
compares digests.
|
||||||
|
|
||||||
|
### Mapping from a flex-auth CheckRequest
|
||||||
|
|
||||||
|
| Claim binding | CheckRequest |
|
||||||
|
| --- | --- |
|
||||||
|
| `action` | `action` |
|
||||||
|
| `target` | `resource` (object) |
|
||||||
|
| `actor` | `subject.id` |
|
||||||
|
| `principal` | `subject.attributes.principal` if present, else `subject.id` |
|
||||||
|
| `purpose` | `context.purpose` |
|
||||||
|
|
||||||
|
Go's `json.Marshal` of a `CheckRequest` is **not** this canonical JSON (field
|
||||||
|
order and `omitempty` differ). Do not hash a CheckRequest with this function
|
||||||
|
and expect it to equal `NewDecisionBinding.request_digest`.
|
||||||
|
|
||||||
|
When the issuer recorded the PDP digest at issue time, it is in
|
||||||
|
`binding.pdp_digest`. **Prefer that comparison** at decision time:
|
||||||
|
|
||||||
|
```text
|
||||||
|
claim.binding.pdp_digest == decision.binding.request_digest
|
||||||
|
```
|
||||||
|
|
||||||
|
and still require `claim.approval_id` to match the approval named on the
|
||||||
|
request. Native `binding.digest` remains the identifier of *this engine's*
|
||||||
|
binding, and is what T-06 uses when no PDP digest was recorded.
|
||||||
|
|
||||||
|
## Freshness
|
||||||
|
|
||||||
|
Default TTL is **30 seconds** from `observed_at`. `not_after` is
|
||||||
|
`observed_at + ttl_seconds`. A PDP that needs a different deadline for the
|
||||||
|
approval input class states it in its own provenance; it must not invent
|
||||||
|
freshness this engine did not publish.
|
||||||
|
|
||||||
|
A claim used after `freshness.not_after` is stale. Stale is not the same as
|
||||||
|
`valid_now: false` — the object may still be valid; the *observation* is old.
|
||||||
|
Re-fetch.
|
||||||
|
|
||||||
|
## Required verification (consumer)
|
||||||
|
|
||||||
|
A production consumer of this claim, before treating it as an input, checks:
|
||||||
|
|
||||||
|
1. The claim resolved from this engine; an outage fails the action closed.
|
||||||
|
2. `issuer` is `approval-engine`.
|
||||||
|
3. `valid_now` is true and `consumed` is false.
|
||||||
|
4. `binding.digest` equals the digest of the binding the consumer computed
|
||||||
|
from the proposed action, **or** `binding.pdp_digest` equals the
|
||||||
|
`NewDecisionBinding` digest of that request.
|
||||||
|
5. `freshness.not_after` is still in the future.
|
||||||
|
6. `reason_code` is `ok`.
|
||||||
|
|
||||||
|
Local fixtures, workplan ids, and prose are not this claim.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See [`../examples/claim.valid.json`](../examples/claim.valid.json) and
|
||||||
|
[`../examples/claim.revoked.json`](../examples/claim.revoked.json).
|
||||||
62
docs/emission-cadence.md
Normal file
62
docs/emission-cadence.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Emission cadence — source declaration
|
||||||
|
|
||||||
|
Statute §9.6: approval evidence is **load-bearing** and **low-volume**. Rate
|
||||||
|
monitoring is the wrong form. A handful of revocations a month has no rate to
|
||||||
|
drop below; suppression is indistinguishable from a quiet month.
|
||||||
|
|
||||||
|
This file is the source side of `GH-WP-0002-T04`. That task is the detection
|
||||||
|
surface; this declaration is what it reads. Machine-readable copy:
|
||||||
|
[`../cadence.yaml`](../cadence.yaml).
|
||||||
|
|
||||||
|
## Form
|
||||||
|
|
||||||
|
**Heartbeat, plus reconciliation counts.** Not a rate.
|
||||||
|
|
||||||
|
### Heartbeat
|
||||||
|
|
||||||
|
A signed positive claim: *nothing to report*, together with per-class
|
||||||
|
transition counts since the previous heartbeat (or since process start on
|
||||||
|
the first). The claim can itself go missing, which is the point — silence
|
||||||
|
becomes a missing positive rather than a quiet month.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Class | `heartbeat` |
|
||||||
|
| Interval | 24 hours (SHOULD also be emittable on demand) |
|
||||||
|
| Assertion | `nothing-to-report` |
|
||||||
|
| Counts | `{issuance, use, supersession, revocation}` of committed outbox rows |
|
||||||
|
| Missing heartbeat | **finding** |
|
||||||
|
|
||||||
|
### Reconciliation
|
||||||
|
|
||||||
|
Compare this engine's committed outbox counts per class to `audit-core`'s
|
||||||
|
accepted event counts for `source=approval-engine` and the corresponding
|
||||||
|
`action`. Divergence is a **finding**, not a log line.
|
||||||
|
|
||||||
|
| This engine | `audit-core` `action` |
|
||||||
|
| --- | --- |
|
||||||
|
| `issuance` | `approval.issuance` |
|
||||||
|
| `use` | `approval.use` |
|
||||||
|
| `supersession` | `approval.supersession` |
|
||||||
|
| `revocation` | `approval.revocation` |
|
||||||
|
| `heartbeat` | `approval.heartbeat` |
|
||||||
|
|
||||||
|
Undrained local rows are this engine's lag, not yet a divergence. A row
|
||||||
|
with `drained_at` set that `audit-core` does not hold is the omission
|
||||||
|
case §9.6 names.
|
||||||
|
|
||||||
|
## What is a finding
|
||||||
|
|
||||||
|
- No heartbeat arrives for more than one interval.
|
||||||
|
- `audit-core` count for a class is less than this engine's drained count
|
||||||
|
for that class.
|
||||||
|
- Outbox depth or age exceeds the lag bound in `cadence.yaml` (drain stuck).
|
||||||
|
|
||||||
|
None of these are rate drops. None of these are informational logs.
|
||||||
|
|
||||||
|
## Residual
|
||||||
|
|
||||||
|
Atomicity prevents accidental omission (crash between mutation and emit).
|
||||||
|
Cadence and reconciliation **detect** adversarial omission after the fact.
|
||||||
|
Nothing in the model prevents a compromised source from suppressing. That
|
||||||
|
residual is stated, not closed.
|
||||||
26
docs/flex-auth-handoff.md
Normal file
26
docs/flex-auth-handoff.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Handoff for `access-engine` / `FLEX-WP-0017`
|
||||||
|
|
||||||
|
`FLEX-WP-0017` T03 waited on a durable object that was not the State Hub
|
||||||
|
`/decisions/{uuid}` shape. That object is this engine.
|
||||||
|
|
||||||
|
## What exists
|
||||||
|
|
||||||
|
- Claim contract: [`approval-claim.md`](approval-claim.md)
|
||||||
|
- Introspection: `GET /v1/approvals/{id}/claim`
|
||||||
|
- Object: `POST /v1/approvals`, entries, revoke, supersede
|
||||||
|
- Native `binding.digest` plus optional `binding.pdp_digest` for
|
||||||
|
`NewDecisionBinding.request_digest`
|
||||||
|
|
||||||
|
T03 is unblocked on the **object**, not on a hub substitute. Validate the
|
||||||
|
claim before privileged production actions. Fail closed if this engine is
|
||||||
|
unreachable.
|
||||||
|
|
||||||
|
## What does not exist yet
|
||||||
|
|
||||||
|
Consumption ordering (`GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`). There is
|
||||||
|
no public `consume`. `FLEX-WP-0017` T05 stays blocked **only** on that
|
||||||
|
contract, not on a missing object or a missing digest.
|
||||||
|
|
||||||
|
Canon T-06 against this implementation: `tests/test_t06_replay.py` (wrong
|
||||||
|
target, wrong action, later time, revoked, superseded). Consume-side replay
|
||||||
|
is out of scope until T05.
|
||||||
93
docs/outbox-contract.md
Normal file
93
docs/outbox-contract.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Local transactional outbox contract
|
||||||
|
|
||||||
|
Coordinates with `GH-WP-0002-T02`. Boundary and locality are in `INTENT.md`
|
||||||
|
and statute §9.4; this is the wire.
|
||||||
|
|
||||||
|
An implementer **cannot** satisfy this contract by emitting synchronously to
|
||||||
|
`audit-core` inside the state-change transaction. That path is atomic and is
|
||||||
|
forbidden: an audit outage would become an inability to revoke.
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
Every issuance, use, supersession, and revocation **inserts an outbox row in
|
||||||
|
the same transaction** that mutates the approval object. The durable queue
|
||||||
|
lives in this engine's own store. Drain is asynchronous, at-least-once.
|
||||||
|
`audit-core` dedupes on `event_id`; a replay does not fork the chain.
|
||||||
|
|
||||||
|
If the outbox insert cannot be committed, the mutation does not commit.
|
||||||
|
Emit-after-commit is a defect.
|
||||||
|
|
||||||
|
Heartbeat rows (see [`emission-cadence.md`](emission-cadence.md)) use the
|
||||||
|
same table and the same at-least-once drain. They are not coupled to an
|
||||||
|
object mutation.
|
||||||
|
|
||||||
|
## Event classes
|
||||||
|
|
||||||
|
| Class | When | `audit-core` `action` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `issuance` | object becomes `approved` (threshold met) | `approval.issuance` |
|
||||||
|
| `use` | object becomes `consumed` (internal CAS; not a public API until `GH-WP-0002-T06`) | `approval.use` |
|
||||||
|
| `supersession` | object becomes `superseded` | `approval.supersession` |
|
||||||
|
| `revocation` | object becomes `revoked` | `approval.revocation` |
|
||||||
|
| `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` |
|
||||||
|
|
||||||
|
Expiry is a clock crossing, persisted on observation, and is **not** an
|
||||||
|
emitted class. The validity window is already on the object.
|
||||||
|
|
||||||
|
## Outbox row
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `event_id` | UUID | Stable. Drain retries reuse it. |
|
||||||
|
| `class` | enum above | |
|
||||||
|
| `approval_id` | UUID or null | Null only for heartbeat. |
|
||||||
|
| `payload` | object | The `audit-core.event.v1alpha1` record, ready to POST. |
|
||||||
|
| `created_at` | RFC 3339 UTC | |
|
||||||
|
| `drained_at` | RFC 3339 UTC or null | Set after a successful audit-core ack. |
|
||||||
|
|
||||||
|
## Payload (audit-core v1alpha1)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "audit-core.event.v1alpha1",
|
||||||
|
"event_id": "<same as outbox.event_id>",
|
||||||
|
"observed_at": "<created_at>",
|
||||||
|
"tenant": "platform",
|
||||||
|
"scope": "netkingdom-approvals",
|
||||||
|
"source": "approval-engine",
|
||||||
|
"actor": "<actor or null for heartbeat>",
|
||||||
|
"action": "approval.revocation",
|
||||||
|
"resource": "approval:<approval_id>",
|
||||||
|
"outcome": "success",
|
||||||
|
"reason": null,
|
||||||
|
"details": {
|
||||||
|
"class": "revocation",
|
||||||
|
"approval_id": "<uuid>",
|
||||||
|
"binding_digest": "sha256:…",
|
||||||
|
"superseded_by": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No secret values. `details` may add non-secret identifiers; it must not add a
|
||||||
|
validity verdict for consumers to branch on. `audit-core` MUST NOT expose an
|
||||||
|
approval-validity query; this payload does not invite one.
|
||||||
|
|
||||||
|
## Drain
|
||||||
|
|
||||||
|
1. Select undrained rows, oldest first.
|
||||||
|
2. POST each payload to `audit-core`.
|
||||||
|
3. On success, set `drained_at`.
|
||||||
|
4. On failure, leave the row; retry later. **Do not** roll back the object
|
||||||
|
mutation — it already committed with the row.
|
||||||
|
|
||||||
|
An `audit-core` outage therefore cannot block a revocation. This engine's
|
||||||
|
own store being unavailable can, and should: the change could not have been
|
||||||
|
recorded anyway.
|
||||||
|
|
||||||
|
## Forbidden shapes
|
||||||
|
|
||||||
|
- `audit-core` HTTP (or any client) inside `BEGIN` … `COMMIT` of a mutation.
|
||||||
|
- Best-effort publish after commit with no row.
|
||||||
|
- A second, non-local queue as the durability mechanism.
|
||||||
|
- Deduping in this engine instead of relying on `event_id` at `audit-core`.
|
||||||
28
examples/claim.revoked.json
Normal file
28
examples/claim.revoked.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"schema_version": "0.1",
|
||||||
|
"kind": "approval-claim",
|
||||||
|
"yields_to": "net-kingdom taxonomy request-claim schema (statute §17; unassigned)",
|
||||||
|
"issuer": "approval-engine",
|
||||||
|
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||||
|
"state": "revoked",
|
||||||
|
"valid_now": false,
|
||||||
|
"consumed": false,
|
||||||
|
"binding": {
|
||||||
|
"action": "secrets.kv.destroy",
|
||||||
|
"target": {"id": "lane-openbao-root", "stage": "prod"},
|
||||||
|
"actor": "agt-secrets-engine",
|
||||||
|
"principal": "bernd",
|
||||||
|
"purpose": "rotate-exposed-key",
|
||||||
|
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"observed_at": "2026-08-29T12:05:00+00:00",
|
||||||
|
"ttl_seconds": 30,
|
||||||
|
"not_after": "2026-08-29T12:05:30+00:00"
|
||||||
|
},
|
||||||
|
"validity": {
|
||||||
|
"not_before": "2026-08-29T11:00:00+00:00",
|
||||||
|
"expires_at": "2026-08-29T15:00:00+00:00"
|
||||||
|
},
|
||||||
|
"reason_code": "revoked"
|
||||||
|
}
|
||||||
28
examples/claim.valid.json
Normal file
28
examples/claim.valid.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"schema_version": "0.1",
|
||||||
|
"kind": "approval-claim",
|
||||||
|
"yields_to": "net-kingdom taxonomy request-claim schema (statute §17; unassigned)",
|
||||||
|
"issuer": "approval-engine",
|
||||||
|
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||||
|
"state": "valid",
|
||||||
|
"valid_now": true,
|
||||||
|
"consumed": false,
|
||||||
|
"binding": {
|
||||||
|
"action": "secrets.kv.destroy",
|
||||||
|
"target": {"id": "lane-openbao-root", "stage": "prod"},
|
||||||
|
"actor": "agt-secrets-engine",
|
||||||
|
"principal": "bernd",
|
||||||
|
"purpose": "rotate-exposed-key",
|
||||||
|
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"observed_at": "2026-08-29T12:00:00+00:00",
|
||||||
|
"ttl_seconds": 30,
|
||||||
|
"not_after": "2026-08-29T12:00:30+00:00"
|
||||||
|
},
|
||||||
|
"validity": {
|
||||||
|
"not_before": "2026-08-29T11:00:00+00:00",
|
||||||
|
"expires_at": "2026-08-29T15:00:00+00:00"
|
||||||
|
},
|
||||||
|
"reason_code": "ok"
|
||||||
|
}
|
||||||
12
layer.yaml
12
layer.yaml
|
|
@ -39,10 +39,9 @@ approval_validity_query: owned # current-state introspection, not a verdi
|
||||||
# audit-core's PostgreSQL custody).
|
# audit-core's PostgreSQL custody).
|
||||||
tooling_contacts: []
|
tooling_contacts: []
|
||||||
|
|
||||||
# §11: record non-Tooling clients so the check is total. None exist: there
|
# §11: record non-Tooling clients so the check is total. The SQLite store is
|
||||||
# is no runtime. Listed targets are the intended Engine APIs and the
|
# this engine's own operational store, not a §4 Tooling row. Drain is a
|
||||||
# uncatalogued hub write, to be filled in as code appears rather than
|
# callback; no audit-core client is compiled in.
|
||||||
# discovered later as silence.
|
|
||||||
non_tooling_clients: []
|
non_tooling_clients: []
|
||||||
|
|
||||||
intended_non_tooling_clients:
|
intended_non_tooling_clients:
|
||||||
|
|
@ -64,9 +63,10 @@ intended_non_tooling_clients:
|
||||||
# low-volume load-bearing classes is reconciliation or a heartbeat.
|
# low-volume load-bearing classes is reconciliation or a heartbeat.
|
||||||
evidence:
|
evidence:
|
||||||
kind: load-bearing
|
kind: load-bearing
|
||||||
atomicity: local-outbox # required; not yet implemented
|
atomicity: local-outbox
|
||||||
cadence_form: heartbeat-or-reconciliation
|
cadence_form: heartbeat-or-reconciliation
|
||||||
cadence_status: undeclared # APPROVAL-WP-0001-T04
|
cadence_status: declared
|
||||||
|
cadence: cadence.yaml
|
||||||
residual: adversarial-omission-at-compromised-source
|
residual: adversarial-omission-at-compromised-source
|
||||||
custody: same-bound-as-every-other-source # §16 decided: no stronger archive
|
custody: same-bound-as-every-other-source # §16 decided: no stronger archive
|
||||||
|
|
||||||
|
|
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[project]
|
||||||
|
name = "approval-engine"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "PIP for the approval object: durable, authenticated, consumable, atomically supersedable."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
approval-engine = "approval_engine.cli:main"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
115
schemas/approval_claim.schema.json
Normal file
115
schemas/approval_claim.schema.json
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://approval-engine.netkingdom/schemas/approval_claim.schema.json",
|
||||||
|
"title": "ApprovalClaim",
|
||||||
|
"description": "Input claim that access-engine consumes. This is a fact about an approval object, not a decision. Yields to the Taxonomy request-claim schema (statute §17) when that artifact exists and is assented; do not treat this local shape as permanent.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"kind",
|
||||||
|
"issuer",
|
||||||
|
"approval_id",
|
||||||
|
"state",
|
||||||
|
"valid_now",
|
||||||
|
"consumed",
|
||||||
|
"binding",
|
||||||
|
"freshness",
|
||||||
|
"validity",
|
||||||
|
"reason_code"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": { "const": "0.1" },
|
||||||
|
"kind": { "const": "approval-claim" },
|
||||||
|
"yields_to": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Taxonomy artifact this contract yields to. Informational; consumers must not branch on it."
|
||||||
|
},
|
||||||
|
"issuer": { "const": "approval-engine" },
|
||||||
|
"approval_id": { "type": "string", "format": "uuid" },
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"requested",
|
||||||
|
"approved",
|
||||||
|
"valid",
|
||||||
|
"consumed",
|
||||||
|
"superseded",
|
||||||
|
"revoked",
|
||||||
|
"expired"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"valid_now": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True only when the object is approved, inside its validity window, and not consumed, superseded, revoked, or expired. Not a permission."
|
||||||
|
},
|
||||||
|
"consumed": { "type": "boolean" },
|
||||||
|
"binding": { "$ref": "#/$defs/binding" },
|
||||||
|
"freshness": { "$ref": "#/$defs/freshness" },
|
||||||
|
"validity": { "$ref": "#/$defs/validity" },
|
||||||
|
"reason_code": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ok",
|
||||||
|
"requested",
|
||||||
|
"not_yet_valid",
|
||||||
|
"insufficient_approvers",
|
||||||
|
"expired",
|
||||||
|
"revoked",
|
||||||
|
"superseded",
|
||||||
|
"consumed"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"not": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "required": ["effect"] },
|
||||||
|
{ "required": ["decision"] },
|
||||||
|
{ "required": ["allow"] },
|
||||||
|
{ "required": ["deny"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"binding": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["action", "target", "actor", "principal", "purpose", "digest"],
|
||||||
|
"properties": {
|
||||||
|
"action": { "type": "string", "minLength": 1 },
|
||||||
|
"target": { "type": "object" },
|
||||||
|
"actor": { "type": "string", "minLength": 1 },
|
||||||
|
"principal": { "type": "string", "minLength": 1 },
|
||||||
|
"purpose": { "type": "string", "minLength": 1 },
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[0-9a-f]{64}$",
|
||||||
|
"description": "SHA-256 over the canonical JSON of action, actor, principal, purpose, target (sorted keys, RFC 8259). Distinguishes approved from approved-for-this-exact-request."
|
||||||
|
},
|
||||||
|
"pdp_digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[0-9a-f]{64}$",
|
||||||
|
"description": "Optional. The flex-auth NewDecisionBinding request_digest recorded at issue time. When present, access-engine MUST compare this to the digest it already computes, not re-derive our native digest as a substitute."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["observed_at", "ttl_seconds", "not_after"],
|
||||||
|
"properties": {
|
||||||
|
"observed_at": { "type": "string", "format": "date-time" },
|
||||||
|
"ttl_seconds": { "type": "integer", "minimum": 1 },
|
||||||
|
"not_after": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validity": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["not_before", "expires_at"],
|
||||||
|
"properties": {
|
||||||
|
"not_before": { "type": "string", "format": "date-time" },
|
||||||
|
"expires_at": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
tests/conftest.py
Normal file
56
tests/conftest.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from approval_engine.api import App
|
||||||
|
from approval_engine.store import Engine
|
||||||
|
|
||||||
|
FROZEN = datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def now():
|
||||||
|
return FROZEN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine(now):
|
||||||
|
eng = Engine(":memory:", clock=lambda: now)
|
||||||
|
yield eng
|
||||||
|
eng.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(engine):
|
||||||
|
return App(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def binding(**overrides):
|
||||||
|
base = {
|
||||||
|
"action": "secrets.kv.destroy",
|
||||||
|
"target": {"id": "lane-openbao-root", "stage": "prod"},
|
||||||
|
"actor": "agt-secrets-engine",
|
||||||
|
"principal": "bernd",
|
||||||
|
"purpose": "rotate-exposed-key",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def validity():
|
||||||
|
return {
|
||||||
|
"not_before": "2026-08-29T11:00:00+00:00",
|
||||||
|
"expires_at": "2026-08-29T15:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def approve(engine, required=1, extra_binding=None, pdp_digest=None):
|
||||||
|
obj = engine.create(
|
||||||
|
extra_binding or binding(),
|
||||||
|
validity(),
|
||||||
|
required_count=required,
|
||||||
|
pdp_digest=pdp_digest,
|
||||||
|
)
|
||||||
|
for i in range(required):
|
||||||
|
obj = engine.add_entry(obj.id, f"user:approver-{i}")
|
||||||
|
return obj
|
||||||
69
tests/test_api.py
Normal file
69
tests/test_api.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
from approval_engine.api import call
|
||||||
|
from tests.conftest import binding, validity
|
||||||
|
|
||||||
|
|
||||||
|
def test_readyz(app):
|
||||||
|
status, body = call(app, "GET", "/v1/readyz")
|
||||||
|
assert status == 200
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_entry_claim_roundtrip(app):
|
||||||
|
status, created = call(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/v1/approvals",
|
||||||
|
{"binding": binding(), "validity": validity(), "required_count": 1},
|
||||||
|
)
|
||||||
|
assert status == 201
|
||||||
|
aid = created["id"]
|
||||||
|
status, _ = call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||||
|
assert status == 200
|
||||||
|
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
|
||||||
|
assert status == 200
|
||||||
|
assert claim["kind"] == "approval-claim"
|
||||||
|
assert claim["valid_now"] is True
|
||||||
|
assert "effect" not in claim
|
||||||
|
assert "decision" not in claim
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_check_or_authorize_or_consume(app):
|
||||||
|
for path in (
|
||||||
|
"/v1/check",
|
||||||
|
"/authorize",
|
||||||
|
"/v1/approvals/00000000-0000-0000-0000-000000000001/consume",
|
||||||
|
"/v1/approvals/abc/consume",
|
||||||
|
):
|
||||||
|
status, body = call(app, "POST", path, {})
|
||||||
|
assert status == 404
|
||||||
|
assert "consume is not implemented" in body.get("message", "") or body["error"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_after_revoke(app):
|
||||||
|
_, created = call(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/v1/approvals",
|
||||||
|
{"binding": binding(), "validity": validity()},
|
||||||
|
)
|
||||||
|
aid = created["id"]
|
||||||
|
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||||
|
call(app, "POST", f"/v1/approvals/{aid}/revoke", {})
|
||||||
|
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
|
||||||
|
assert status == 200
|
||||||
|
assert claim["valid_now"] is False
|
||||||
|
assert claim["reason_code"] == "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_unavailable_is_503():
|
||||||
|
from approval_engine.api import App
|
||||||
|
from approval_engine.errors import StoreUnavailable
|
||||||
|
from approval_engine.store import Engine
|
||||||
|
|
||||||
|
class Dead(Engine):
|
||||||
|
def outbox_stats(self):
|
||||||
|
raise StoreUnavailable("down")
|
||||||
|
|
||||||
|
status, body = call(App(Dead.__new__(Dead)), "GET", "/v1/readyz")
|
||||||
|
assert status == 503
|
||||||
|
assert body["error"] == "store_unavailable"
|
||||||
22
tests/test_cadence.py
Normal file
22
tests/test_cadence.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_yaml_cadence_declared():
|
||||||
|
text = (ROOT / "layer.yaml").read_text()
|
||||||
|
assert "cadence_status: declared" in text
|
||||||
|
assert "cadence_form: heartbeat-or-reconciliation" in text
|
||||||
|
assert "cadence: cadence.yaml" in text
|
||||||
|
assert "cadence_status: undeclared" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_cadence_yaml_forbids_rate_monitoring():
|
||||||
|
text = (ROOT / "cadence.yaml").read_text()
|
||||||
|
assert "kind: load-bearing" in text
|
||||||
|
assert "form: heartbeat-or-reconciliation" in text
|
||||||
|
assert "rate_monitoring: forbidden" in text
|
||||||
|
assert "missing: finding" in text
|
||||||
|
assert "divergence: finding" in text
|
||||||
|
for cls in ("issuance", "use", "supersession", "revocation", "heartbeat"):
|
||||||
|
assert f"{cls}:" in text
|
||||||
70
tests/test_cas.py
Normal file
70
tests/test_cas.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from approval_engine.errors import Conflict
|
||||||
|
from approval_engine.store import Engine
|
||||||
|
from tests.conftest import approve
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_supersession_loses(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
first = engine.supersede(obj.id)
|
||||||
|
assert engine.get(obj.id).status == "superseded"
|
||||||
|
assert first["successor_id"]
|
||||||
|
try:
|
||||||
|
engine.supersede(obj.id)
|
||||||
|
raise AssertionError("second supersession must conflict")
|
||||||
|
except Conflict:
|
||||||
|
pass
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert claim["valid_now"] is False
|
||||||
|
assert claim["reason_code"] == "superseded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_supersessions_one_winner():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "a.sqlite"
|
||||||
|
setup = Engine(path)
|
||||||
|
obj = approve(setup)
|
||||||
|
setup.close()
|
||||||
|
|
||||||
|
winners: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
|
def race():
|
||||||
|
eng = Engine(path)
|
||||||
|
barrier.wait()
|
||||||
|
try:
|
||||||
|
result = eng.supersede(obj.id)
|
||||||
|
winners.append(result["successor_id"])
|
||||||
|
except Conflict as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
finally:
|
||||||
|
eng.close()
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=race) for _ in range(2)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
assert len(winners) == 1
|
||||||
|
assert len(errors) == 1
|
||||||
|
check = Engine(path)
|
||||||
|
assert check.get(obj.id).status == "superseded"
|
||||||
|
check.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_consume_cas_once(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine._cas_consume(obj.id)
|
||||||
|
try:
|
||||||
|
engine._cas_consume(obj.id)
|
||||||
|
raise AssertionError("double consume must conflict")
|
||||||
|
except Conflict:
|
||||||
|
pass
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert claim["consumed"] is True
|
||||||
|
assert claim["valid_now"] is False
|
||||||
|
assert claim["reason_code"] == "consumed"
|
||||||
58
tests/test_claim_contract.py
Normal file
58
tests/test_claim_contract.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from approval_engine.binding import binding_digest
|
||||||
|
from tests.conftest import approve, binding
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_forbids_decision_keys():
|
||||||
|
schema = json.loads((ROOT / "schemas/approval_claim.schema.json").read_text())
|
||||||
|
assert "not" in schema
|
||||||
|
kinds = [item["required"][0] for item in schema["not"]["anyOf"]]
|
||||||
|
assert set(kinds) >= {"effect", "decision", "allow", "deny"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_has_issuer_digest_freshness(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert claim["kind"] == "approval-claim"
|
||||||
|
assert claim["issuer"] == "approval-engine"
|
||||||
|
assert claim["approval_id"] == obj.id
|
||||||
|
assert claim["valid_now"] is True
|
||||||
|
assert claim["reason_code"] == "ok"
|
||||||
|
assert claim["binding"]["digest"] == binding_digest(binding())
|
||||||
|
assert claim["freshness"]["ttl_seconds"] == 30
|
||||||
|
assert claim["freshness"]["not_after"] == "2026-08-29T12:00:30+00:00"
|
||||||
|
assert "effect" not in claim
|
||||||
|
assert "decision" not in claim
|
||||||
|
assert "yields_to" in claim
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_target_changes_digest():
|
||||||
|
a = binding()
|
||||||
|
b = binding(target={"id": "other-lane", "stage": "prod"})
|
||||||
|
assert binding_digest(a) != binding_digest(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_action_changes_digest():
|
||||||
|
assert binding_digest(binding()) != binding_digest(binding(action="secrets.kv.read"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdp_digest_is_recorded_not_recomputed(engine):
|
||||||
|
pdp = "sha256:" + "ab" * 32
|
||||||
|
obj = approve(engine, pdp_digest=pdp)
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert claim["binding"]["pdp_digest"] == pdp
|
||||||
|
assert claim["binding"]["digest"] == binding_digest(binding())
|
||||||
|
assert claim["binding"]["digest"] != pdp
|
||||||
|
|
||||||
|
|
||||||
|
def test_examples_are_claim_shaped():
|
||||||
|
for name in ("claim.valid.json", "claim.revoked.json"):
|
||||||
|
data = json.loads((ROOT / "examples" / name).read_text())
|
||||||
|
assert data["kind"] == "approval-claim"
|
||||||
|
assert data["issuer"] == "approval-engine"
|
||||||
|
for forbidden in ("effect", "decision", "allow", "deny"):
|
||||||
|
assert forbidden not in data
|
||||||
80
tests/test_machine.py
Normal file
80
tests/test_machine.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from approval_engine.errors import Conflict, DuplicateApprover
|
||||||
|
from approval_engine.store import Engine
|
||||||
|
from tests.conftest import approve, binding, validity
|
||||||
|
|
||||||
|
|
||||||
|
def test_requested_until_threshold(engine):
|
||||||
|
obj = engine.create(binding(), validity(), required_count=2)
|
||||||
|
assert obj.status == "requested"
|
||||||
|
obj = engine.add_entry(obj.id, "user:alice")
|
||||||
|
assert obj.status == "requested"
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert claim["valid_now"] is False
|
||||||
|
assert claim["reason_code"] == "requested"
|
||||||
|
|
||||||
|
|
||||||
|
def test_threshold_approves_and_valid_now(engine):
|
||||||
|
obj = approve(engine, required=2)
|
||||||
|
assert obj.status == "approved"
|
||||||
|
assert len(obj.entries) == 2
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert claim["state"] == "valid"
|
||||||
|
assert claim["valid_now"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_approver_fails_closed(engine):
|
||||||
|
obj = engine.create(binding(), validity(), required_count=2)
|
||||||
|
engine.add_entry(obj.id, "user:alice")
|
||||||
|
try:
|
||||||
|
engine.add_entry(obj.id, "user:alice")
|
||||||
|
raise AssertionError("duplicate must fail")
|
||||||
|
except DuplicateApprover:
|
||||||
|
pass
|
||||||
|
obj = engine.get(obj.id)
|
||||||
|
assert len(obj.entries) == 1
|
||||||
|
assert obj.status == "requested"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_without_holder_and_next_claim(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.revoke(obj.id)
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert claim["valid_now"] is False
|
||||||
|
assert claim["state"] == "revoked"
|
||||||
|
assert claim["reason_code"] == "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expiry_on_observation():
|
||||||
|
jumping = {"t": datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)}
|
||||||
|
eng = Engine(":memory:", clock=lambda: jumping["t"])
|
||||||
|
obj = eng.create(binding(), validity(), required_count=1)
|
||||||
|
obj = eng.add_entry(obj.id, "user:alice")
|
||||||
|
assert eng.claim(obj.id)["valid_now"] is True
|
||||||
|
jumping["t"] = datetime(2026, 8, 29, 16, 0, tzinfo=timezone.utc)
|
||||||
|
claim = eng.claim(obj.id)
|
||||||
|
assert claim["state"] == "expired"
|
||||||
|
assert claim["valid_now"] is False
|
||||||
|
eng.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_yet_valid():
|
||||||
|
future = datetime(2026, 8, 29, 10, 0, tzinfo=timezone.utc)
|
||||||
|
eng = Engine(":memory:", clock=lambda: future)
|
||||||
|
obj = eng.create(binding(), validity(), required_count=1)
|
||||||
|
obj = eng.add_entry(obj.id, "user:alice")
|
||||||
|
claim = eng.claim(obj.id)
|
||||||
|
assert claim["valid_now"] is False
|
||||||
|
assert claim["reason_code"] == "not_yet_valid"
|
||||||
|
eng.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_revoke_twice(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.revoke(obj.id)
|
||||||
|
try:
|
||||||
|
engine.revoke(obj.id)
|
||||||
|
raise AssertionError("second revoke must conflict")
|
||||||
|
except Conflict:
|
||||||
|
pass
|
||||||
72
tests/test_outbox.py
Normal file
72
tests/test_outbox.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from approval_engine.errors import StoreUnavailable
|
||||||
|
from approval_engine.store import Engine
|
||||||
|
from tests.conftest import approve, binding, validity
|
||||||
|
|
||||||
|
|
||||||
|
def test_issuance_queued_in_same_commit(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
pending = engine.undrained()
|
||||||
|
classes = [p["class"] for p in pending]
|
||||||
|
assert classes == ["issuance"]
|
||||||
|
assert pending[0]["approval_id"] == obj.id
|
||||||
|
payload = pending[0]["payload"]
|
||||||
|
assert payload["source"] == "approval-engine"
|
||||||
|
assert payload["action"] == "approval.issuance"
|
||||||
|
assert payload["event_id"] == pending[0]["event_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_outbox_rolls_back_mutation():
|
||||||
|
eng = Engine(":memory:", fail_outbox=True)
|
||||||
|
obj = eng.create(binding(), validity(), required_count=1)
|
||||||
|
try:
|
||||||
|
eng.add_entry(obj.id, "user:alice")
|
||||||
|
raise AssertionError("must fail")
|
||||||
|
except StoreUnavailable:
|
||||||
|
pass
|
||||||
|
obj = eng.get(obj.id)
|
||||||
|
assert obj.status == "requested"
|
||||||
|
assert obj.entries == []
|
||||||
|
assert eng.undrained() == []
|
||||||
|
eng.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_succeeds_when_drain_sink_is_down(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.revoke(obj.id)
|
||||||
|
assert engine.get(obj.id).status == "revoked"
|
||||||
|
|
||||||
|
def down(_payload):
|
||||||
|
raise ConnectionError("audit-core unreachable")
|
||||||
|
|
||||||
|
result = engine.drain(down)
|
||||||
|
assert result["failed"] >= 1
|
||||||
|
assert engine.get(obj.id).status == "revoked"
|
||||||
|
assert any(p["class"] == "revocation" for p in engine.undrained())
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_marks_delivered(engine):
|
||||||
|
approve(engine)
|
||||||
|
sink: list[dict] = []
|
||||||
|
result = engine.drain(sink.append)
|
||||||
|
assert result["delivered"] == 1
|
||||||
|
assert result["failed"] == 0
|
||||||
|
assert engine.undrained() == []
|
||||||
|
assert sink[0]["action"] == "approval.issuance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_is_positive_claim(engine):
|
||||||
|
approve(engine)
|
||||||
|
beat = engine.emit_heartbeat()
|
||||||
|
assert beat["assertion"] == "nothing-to-report"
|
||||||
|
assert beat["counts"]["issuance"] == 1
|
||||||
|
assert beat["counts"]["heartbeat"] == 1
|
||||||
|
pending = [p for p in engine.undrained() if p["class"] == "heartbeat"]
|
||||||
|
assert len(pending) == 1
|
||||||
|
assert pending[0]["payload"]["details"]["assertion"] == "nothing-to-report"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revocation_event_class(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.revoke(obj.id)
|
||||||
|
classes = [p["class"] for p in engine.undrained()]
|
||||||
|
assert "revocation" in classes
|
||||||
67
tests/test_t06_replay.py
Normal file
67
tests/test_t06_replay.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Canon T-06 — Approval Replay.
|
||||||
|
|
||||||
|
Reuse a previously valid approval artifact for a different target, parameter
|
||||||
|
set, or later time. Pass: parameter binding, expiry, or replay protection
|
||||||
|
rejects the request.
|
||||||
|
|
||||||
|
Consume-side replay (use twice) waits on GH-WP-0002-T06. This suite covers
|
||||||
|
the object and claim side: wrong binding, expiry, revocation, supersession.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from approval_engine.binding import binding_digest
|
||||||
|
from approval_engine.store import Engine
|
||||||
|
from tests.conftest import approve, binding, validity
|
||||||
|
|
||||||
|
|
||||||
|
def _consumer_accepts(claim, proposed_binding) -> bool:
|
||||||
|
if claim["issuer"] != "approval-engine":
|
||||||
|
return False
|
||||||
|
if not claim["valid_now"]:
|
||||||
|
return False
|
||||||
|
if claim["consumed"]:
|
||||||
|
return False
|
||||||
|
if claim["reason_code"] != "ok":
|
||||||
|
return False
|
||||||
|
return claim["binding"]["digest"] == binding_digest(proposed_binding)
|
||||||
|
|
||||||
|
|
||||||
|
def test_t06_wrong_target_rejected(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
assert _consumer_accepts(claim, binding()) is True
|
||||||
|
other = binding(target={"id": "some-other-lane", "stage": "prod"})
|
||||||
|
assert _consumer_accepts(claim, other) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_t06_wrong_action_rejected(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
claim = engine.claim(obj.id)
|
||||||
|
other = binding(action="secrets.kv.read")
|
||||||
|
assert _consumer_accepts(claim, other) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_t06_later_time_expired():
|
||||||
|
jumping = {"t": datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)}
|
||||||
|
eng = Engine(":memory:", clock=lambda: jumping["t"])
|
||||||
|
obj = approve(eng)
|
||||||
|
claim = eng.claim(obj.id)
|
||||||
|
assert _consumer_accepts(claim, binding()) is True
|
||||||
|
jumping["t"] = datetime(2026, 8, 29, 16, 0, tzinfo=timezone.utc)
|
||||||
|
claim = eng.claim(obj.id)
|
||||||
|
assert claim["state"] == "expired"
|
||||||
|
assert _consumer_accepts(claim, binding()) is False
|
||||||
|
eng.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_t06_revoked_rejected(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.revoke(obj.id)
|
||||||
|
assert _consumer_accepts(engine.claim(obj.id), binding()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_t06_superseded_rejected(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.supersede(obj.id)
|
||||||
|
assert _consumer_accepts(engine.claim(obj.id), binding()) is False
|
||||||
|
|
@ -56,11 +56,16 @@ its three tasks are already discharged by this file, `AGENTS.md`, and
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T02
|
id: APPROVAL-WP-0001-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ca2ec789-b760-525a-9960-95d15760191e"
|
state_hub_task_id: "ca2ec789-b760-525a-9960-95d15760191e"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-08-29: `schemas/approval_claim.schema.json`, `docs/approval-claim.md`,
|
||||||
|
examples. Native digest is sorted-key JSON of action/actor/principal/purpose/target;
|
||||||
|
optional `pdp_digest` records `NewDecisionBinding.request_digest` at issue.
|
||||||
|
Yields to Taxonomy §17.
|
||||||
|
|
||||||
Specify the input claim `access-engine` consumes: approval identifier,
|
Specify the input claim `access-engine` consumes: approval identifier,
|
||||||
canonical-binding digest over the same binding the PDP already computes,
|
canonical-binding digest over the same binding the PDP already computes,
|
||||||
issuer (this engine), and freshness. Schema and examples in-repo.
|
issuer (this engine), and freshness. Schema and examples in-repo.
|
||||||
|
|
@ -78,11 +83,17 @@ class.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T03
|
id: APPROVAL-WP-0001-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a2eb2e8f-9b49-598e-b4a1-fb93e65f1bf3"
|
state_hub_task_id: "a2eb2e8f-9b49-598e-b4a1-fb93e65f1bf3"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-08-29: `docs/outbox-contract.md`. Classes issuance/use/supersession/revocation/heartbeat.
|
||||||
|
Payload is `audit-core.event.v1alpha1`. Synchronous `audit-core` inside the
|
||||||
|
mutation transaction is named forbidden.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Event classes (issuance, use, supersession, revocation, and the heartbeat
|
Event classes (issuance, use, supersession, revocation, and the heartbeat
|
||||||
class T04 needs), same transaction as the object mutation, queue local to
|
class T04 needs), same transaction as the object mutation, queue local to
|
||||||
this engine, at-least-once into the outbox (`audit-core` dedupes on event
|
this engine, at-least-once into the outbox (`audit-core` dedupes on event
|
||||||
|
|
@ -98,11 +109,17 @@ cannot satisfy it by emitting synchronously to `audit-core`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T04
|
id: APPROVAL-WP-0001-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "fce0df25-0106-5c0a-8187-87b956ca6220"
|
state_hub_task_id: "fce0df25-0106-5c0a-8187-87b956ca6220"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-08-29: `cadence.yaml` + `docs/emission-cadence.md`. `layer.yaml`
|
||||||
|
`evidence.cadence_status: declared`. Missing heartbeat and count divergence
|
||||||
|
are findings. Rate monitoring forbidden.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Approval evidence is load-bearing and low-volume. Rate monitoring is the
|
Approval evidence is load-bearing and low-volume. Rate monitoring is the
|
||||||
wrong form (statute §9.6). Publish the source-side declaration: expected
|
wrong form (statute §9.6). Publish the source-side declaration: expected
|
||||||
classes, the heartbeat (*nothing to report*, signed, itself able to go
|
classes, the heartbeat (*nothing to report*, signed, itself able to go
|
||||||
|
|
@ -139,11 +156,17 @@ consumption from a decision record, no demo that "just consumes on allow".
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T06
|
id: APPROVAL-WP-0001-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "44c4997b-6833-5540-a554-0e24210809f2"
|
state_hub_task_id: "44c4997b-6833-5540-a554-0e24210809f2"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-08-29: SQLite object + closed machine in `approval_engine/store.py`.
|
||||||
|
CAS supersession (concurrent test), distinct-approver fail-closed, revocation
|
||||||
|
without holder cooperation. `_cas_consume` is unexported.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Depends on T02 and T03. Implement the object and the machine in SCOPE:
|
Depends on T02 and T03. Implement the object and the machine in SCOPE:
|
||||||
identifiers; bindings (action, target, actor, principal, purpose, validity
|
identifiers; bindings (action, target, actor, principal, purpose, validity
|
||||||
window, approvers); authenticated entries; distinct-approver counting;
|
window, approvers); authenticated entries; distinct-approver counting;
|
||||||
|
|
@ -161,11 +184,16 @@ closed on duplicates; revocation is effective at the next introspection.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T07
|
id: APPROVAL-WP-0001-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ea51499d-ced5-50fa-b852-396719a8c0f4"
|
state_hub_task_id: "ea51499d-ced5-50fa-b852-396719a8c0f4"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-08-29: `GET /v1/approvals/{id}/claim`. Tests forbid decision-shaped
|
||||||
|
keys and `/v1/check` / `/authorize` / `/consume`. Store unavailable → 503.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Depends on T02 and T06. An API that answers INTENT's question and returns
|
Depends on T02 and T06. An API that answers INTENT's question and returns
|
||||||
the T02 claim. No endpoint answers "may this actor do X". Fail-closed when
|
the T02 claim. No endpoint answers "may this actor do X". Fail-closed when
|
||||||
this engine's own store is unavailable.
|
this engine's own store is unavailable.
|
||||||
|
|
@ -178,11 +206,17 @@ forbid a decision-shaped response.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T08
|
id: APPROVAL-WP-0001-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b9267011-71b3-5138-a3f9-5256374a6b3b"
|
state_hub_task_id: "b9267011-71b3-5138-a3f9-5256374a6b3b"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-08-29: Outbox insert in the same IMMEDIATE transaction. Test fails the
|
||||||
|
transaction when emission fails (no leftover mutation). Revoke succeeds while
|
||||||
|
the drain sink is down; the revocation row stays undrained.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Depends on T03 and T06. Every issuance, use, supersession, and revocation
|
Depends on T03 and T06. Every issuance, use, supersession, and revocation
|
||||||
inserts the outbox row in the same transaction. Drain is asynchronous.
|
inserts the outbox row in the same transaction. Drain is asynchronous.
|
||||||
`audit-core` outage does not block a revocation.
|
`audit-core` outage does not block a revocation.
|
||||||
|
|
@ -196,11 +230,18 @@ does not.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T09
|
id: APPROVAL-WP-0001-T09
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "b4946568-5dda-5bd7-9941-83a5f0137700"
|
state_hub_task_id: "b4946568-5dda-5bd7-9941-83a5f0137700"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2026-08-29: `tests/test_t06_replay.py` encodes Canon T-06 for wrong target,
|
||||||
|
wrong action, later time, revoked, superseded. Consume-side replay stays out.
|
||||||
|
Handoff: `docs/flex-auth-handoff.md`. `FLEX-WP-0017` T03 is unblocked on this
|
||||||
|
object; T05 remains blocked only on consumption ordering.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Depends on T07, T08, and T05 (the last only for the consume-side replay
|
Depends on T07, T08, and T05 (the last only for the consume-side replay
|
||||||
cases). Point Canon `T-06 — Approval Replay` at a live implementation.
|
cases). Point Canon `T-06 — Approval Replay` at a live implementation.
|
||||||
Hand `access-engine` / `secrets-engine` a claim they can validate before
|
Hand `access-engine` / `secrets-engine` a claim they can validate before
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue