diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 6d8614d..8fb8f01 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,9 +15,9 @@ | workplan | APPROVAL-WP-0002 | blocked | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md | | workplan | APPROVAL-WP-0003 | finished | — | workplans/APPROVAL-WP-0003-reissue-expired-approvals.md | | workplan | APPROVAL-WP-0004 | finished | — | workplans/APPROVAL-WP-0004-exact-consume-source-provenance.md | -| workplan | APPROVAL-WP-0005 | active | — | workplans/APPROVAL-WP-0005-access-claim-fact-contract.md | -| workplan | APPROVAL-WP-0006 | active | — | workplans/APPROVAL-WP-0006-fractional-consumption-time.md | -| workplan | APPROVAL-WP-0007 | active | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | +| workplan | APPROVAL-WP-0005 | blocked | — | workplans/APPROVAL-WP-0005-access-claim-fact-contract.md | +| workplan | APPROVAL-WP-0006 | blocked | — | workplans/APPROVAL-WP-0006-fractional-consumption-time.md | +| workplan | APPROVAL-WP-0007 | blocked | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | | task | APPROVAL-WP-ADHOC-2026-10-10-T01 | done | — | workplans/ADHOC-2026-10-10.md | | task | APPROVAL-WP-ADHOC-2026-10-11-T01 | done | — | workplans/ADHOC-2026-10-11.md | | task | APPROVAL-WP-ADHOC-2026-10-11-T02 | done | — | workplans/ADHOC-2026-10-11.md | @@ -47,8 +47,8 @@ | task | APPROVAL-WP-0006-T02 | progress | — | workplans/APPROVAL-WP-0006-fractional-consumption-time.md | | task | APPROVAL-WP-0007-T01 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | | task | APPROVAL-WP-0007-T02 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | -| task | APPROVAL-WP-0007-T03 | todo | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | -| task | APPROVAL-WP-0007-T04 | todo | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | +| task | APPROVAL-WP-0007-T03 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | +| task | APPROVAL-WP-0007-T04 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | | task | APPROVAL-WP-0007-T05 | wait | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | | task | APPROVAL-WP-0007-T06 | todo | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md | | intake | APPROVAL-IN-0001 | open | blue | intakes/intakes.md | diff --git a/approval_engine/api.py b/approval_engine/api.py index f025c67..f6113a1 100644 --- a/approval_engine/api.py +++ b/approval_engine/api.py @@ -161,8 +161,13 @@ class App: ) return 200, obj.as_dict() if rest == ["revoke"] and method == "POST": - self.identity(environ, "approval:revoke") - return 200, self.engine.revoke(approval_id).as_dict() + identity = self.identity(environ, "approval:revoke") + data = _read_json(environ) + if set(data) - {"reason"}: + raise Unprocessable("revoke accepts only an optional reason") + return 200, self.engine.revoke( + approval_id, revoker=identity, reason=data.get("reason") + ).as_dict() if rest == ["supersede"] and method == "POST": self.identity(environ, "approval:supersede") data = _read_json(environ) diff --git a/approval_engine/store.py b/approval_engine/store.py index 4b5f5ac..e20d8a5 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -43,7 +43,10 @@ EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat") # class and says nothing about the quiet one — here that is `revocation`, which # is the class whose silence actually matters. HEARTBEAT_CLASSES = ("issuance", "use", "supersession", "revocation") -LATEST_SCHEMA_VERSION = 8 +LATEST_SCHEMA_VERSION = 9 +# APPROVAL-WP-0007-T03. A revocation reason is optional evidence, one line, +# bounded so a caller cannot put an unbounded blob in the local outbox. +REVOCATION_REASON_MAX = 200 # APPROVAL-WP-0007-T01: authenticator assurance levels, weakest first. A level # outside this list is unknown, and an unknown level never meets a floor. ASSURANCE_LEVELS = ("aal1", "aal2", "aal3") @@ -108,6 +111,12 @@ CREATE TABLE IF NOT EXISTS approvals ( human_control INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1)), required_assurance TEXT CHECK (required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3')), actor_consumes INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1)), + revoked_by_subject TEXT, + revoked_by_principal_type TEXT CHECK ( + revoked_by_principal_type IS NULL OR revoked_by_principal_type IN ('human', 'service', 'agent') + ), + revoked_by_assurance TEXT, + revocation_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -167,6 +176,10 @@ class Approval: human_control: bool required_assurance: str | None actor_consumes: bool + revoked_by_subject: str | None + revoked_by_principal_type: str | None + revoked_by_assurance: str | None + revocation_reason: str | None actor: str principal: str action: str @@ -208,6 +221,17 @@ class Approval: if self.actor_consumes: # APPROVAL-WP-0007-T02, same rule: stated only when declared. result["binding"]["actor_consumes"] = True + if self.revoked_by_subject: + # APPROVAL-WP-0007-T03. Absent when no verified revoker was + # recorded, including legacy rows and internal revoke calls. + revoked_by = { + "subject": self.revoked_by_subject, + "principal_type": self.revoked_by_principal_type, + "assurance": json.loads(self.revoked_by_assurance), + } + if self.revocation_reason: + revoked_by["reason"] = self.revocation_reason + result["revoked_by"] = revoked_by if self.consumed_digest: result["consumption"] = { "request_digest": self.consumed_digest, @@ -317,6 +341,18 @@ class Engine: "ALTER TABLE approvals ADD COLUMN actor_consumes " "INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1))" ) + if "revoked_by_subject" not in approval_columns: + # v9, APPROVAL-WP-0007-T03. Legacy revocations stay NULL: the + # revoker was not verified into the row, and inventing one + # would manufacture evidence. + conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_subject TEXT") + conn.execute( + "ALTER TABLE approvals ADD COLUMN revoked_by_principal_type TEXT CHECK (" + "revoked_by_principal_type IS NULL OR " + "revoked_by_principal_type IN ('human', 'service', 'agent'))" + ) + conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_assurance TEXT") + conn.execute("ALTER TABLE approvals ADD COLUMN revocation_reason TEXT") entry_columns = { row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall() } @@ -561,6 +597,10 @@ class Engine: human_control=bool(row["human_control"]), required_assurance=row["required_assurance"], actor_consumes=bool(row["actor_consumes"]), + revoked_by_subject=row["revoked_by_subject"], + revoked_by_principal_type=row["revoked_by_principal_type"], + revoked_by_assurance=row["revoked_by_assurance"], + revocation_reason=row["revocation_reason"], actor=row["actor"], principal=row["principal"], action=row["action"], @@ -685,8 +725,58 @@ class Engine: raise return self.get(approval_id) - def revoke(self, approval_id: str) -> Approval: - return self._terminal(approval_id, "revoked", "revocation") + def revoke( + self, + approval_id: str, + *, + revoker: Identity | None = None, + reason: str | None = None, + ) -> Approval: + """Terminal revoke. CAS and the closed machine are unchanged. + + A verified revoker is recorded when the HTTP boundary supplies one. + The revoker is not required to be the bound principal: revocation + must stay possible without the holder. An internal call with no + revoker still revokes and leaves the evidence fields empty. + """ + return self._terminal( + approval_id, + "revoked", + "revocation", + revoked_by=self._revoker_evidence(revoker, reason), + ) + + def _revoker_evidence( + self, revoker: Identity | None, reason: str | None + ) -> dict[str, str] | None: + if reason is not None and revoker is None: + raise Unprocessable("revocation reason requires a verified revoker") + if reason is not None: + if not isinstance(reason, str) or reason != reason.strip() or not reason: + raise Unprocessable( + "revocation reason must be a non-empty string without surrounding space" + ) + if len(reason) > REVOCATION_REASON_MAX or any(ord(ch) < 32 for ch in reason): + raise Unprocessable( + f"revocation reason must be a single line of at most {REVOCATION_REASON_MAX} characters" + ) + if revoker is None: + return None + if revoker.principal_type not in {"human", "service", "agent"} or not revoker.subject: + raise Unprocessable("verified revoker subject and principal_type are required") + if not isinstance(revoker.assurance, dict) or not revoker.assurance: + raise Unprocessable("verified revoker assurance is required") + assurance = json.dumps(revoker.assurance, sort_keys=True) + if len(assurance) > 1024: + raise Unprocessable("verified revoker assurance is too large") + evidence = { + "subject": revoker.subject, + "principal_type": revoker.principal_type, + "assurance": assurance, + } + if reason is not None: + evidence["reason"] = reason + return evidence def supersede( self, @@ -809,7 +899,14 @@ class Engine: "successor_created": created_successor, } - def _terminal(self, approval_id: str, status: str, event_class: str) -> Approval: + def _terminal( + self, + approval_id: str, + status: str, + event_class: str, + *, + revoked_by: dict[str, str] | None = None, + ) -> Approval: conn = self._conn() now = iso(self.now()) try: @@ -818,10 +915,27 @@ class Engine: if row is None: conn.rollback() raise NotFound(approval_id) + subject = principal_type = assurance = reason = None + extra: dict[str, Any] = {"binding_digest": row["binding_digest"]} + if revoked_by is not None: + subject = revoked_by["subject"] + principal_type = revoked_by["principal_type"] + assurance = revoked_by["assurance"] + reason = revoked_by.get("reason") + recorded = { + "subject": subject, + "principal_type": principal_type, + "assurance": json.loads(assurance), + } + if reason is not None: + recorded["reason"] = reason + extra["revoked_by"] = recorded cur = conn.execute( - "UPDATE approvals SET status=?, updated_at=? " + "UPDATE approvals SET status=?, updated_at=?, " + "revoked_by_subject=?, revoked_by_principal_type=?, " + "revoked_by_assurance=?, revocation_reason=? " "WHERE id=? AND status IN ('requested','approved')", - (status, now, approval_id), + (status, now, subject, principal_type, assurance, reason, approval_id), ) if cur.rowcount != 1: conn.rollback() @@ -831,7 +945,7 @@ class Engine: event_class, approval_id, actor=row["actor"], - extra={"binding_digest": row["binding_digest"]}, + extra=extra, ) conn.commit() except sqlite3.Error as exc: diff --git a/deploy/README.md b/deploy/README.md index 85994f0..98a3a2b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,10 +27,11 @@ provenance. The prior v5 image was `sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49`. It refuses a v6 store: rollback requires the matching verified pre-v6 backup and the stopped-single-writer restore procedure in `docs/storage-operations.md`. -Source has since moved to schema v8 (v7 `approvals.required_assurance`, -APPROVAL-WP-0007-T01; v8 `approvals.actor_consumes`, APPROVAL-WP-0007-T02); the -pinned image predates schema v8 (and v7). Deploying needs a new release, a -verified pre-v7 backup and a founder-run rollout like the v6 one. +Source has since moved to schema v9 (v7 `approvals.required_assurance`, +APPROVAL-WP-0007-T01; v8 `approvals.actor_consumes`, APPROVAL-WP-0007-T02; +v9 `revoked_by_*`, APPROVAL-WP-0007-T03); the pinned image predates schema v9 +(and v7). Deploying needs a new release, a verified pre-v7 backup and a +founder-run rollout like the v6 one. The earlier September v5 release evidence remains historical at [the image evidence](../docs/evidence/2026-09-10-human-control-image.json). diff --git a/docs/approval-claim.md b/docs/approval-claim.md index d0afc3e..3d8bd0b 100644 --- a/docs/approval-claim.md +++ b/docs/approval-claim.md @@ -448,6 +448,22 @@ call with no authenticated consumer fails closed. The check does not change Migration leaves existing rows without the flag. The declaration is separate from the five-field act digest. +### Revoker evidence — `revoked_by` + +APPROVAL-WP-0007-T03, schema v9 store. `POST /v1/approvals/{id}/revoke` still +moves `requested` or `approved` to terminal `revoked` by the same +compare-and-swap. The body accepts only an optional `reason`: one line, at +most 200 characters. The verified token supplies `subject`, `principal_type` +and `assurance`. A body cannot name the revoker. The revoker is not required +to equal `binding.principal`. + +When a verified revoker is present, `GET` serves `revoked_by` and the +`approval.revocation` outbox event carries the same object in +`details.revoked_by`. The event `actor` stays the bound actor. The claim does +not gain `revoked_by`: it stays a validity fact, and undeclared claim bytes +stay exact. An internal revoke with no verified revoker still revokes and +omits the member, which is also how legacy rows read after migration. + ### Actor is never an approver under `human_control` GH-DEC-2026-026 Ruling 6. Under `human_control: true`, `POST /entries` refuses diff --git a/docs/bounded-execution-admission-carrier.md b/docs/bounded-execution-admission-carrier.md index f39c006..ea069f0 100644 --- a/docs/bounded-execution-admission-carrier.md +++ b/docs/bounded-execution-admission-carrier.md @@ -18,7 +18,7 @@ Inputs: NetKingdom `canon/standards/bounded-execution-admission_v0.1.md` | 1. Binding | **Supported today**, with one consequence: the executor (the `actor`) must create the request itself | | 2. aal2 | **Recorded today, not enforced.** Enforcement at issue/entry/consume needs a change (APPROVAL-WP-0007-T01) | | 3. Consume | **Supported today.** Scope `approval:consume`, service principal; proposed `request_digest` = the admission digest. The independent GET comparison is the intended reading | -| 4. Revoke | **Supported today** for unconsumed approvals. The revoker's identity is not yet recorded in evidence (APPROVAL-WP-0007-T03) | +| 4. Revoke | **Supported.** Unconsumed approvals revoke by CAS. Schema v9 records the verified revoker (APPROVAL-WP-0007-T03, in source, not deployed) | ## 1. Binding @@ -207,11 +207,13 @@ Limits: after the executor's first-step consume, stopping it mid-run is the executor's own kill switch (T07, NetKingdom runtime). This engine has no "stop after start". -- **The engine does not record who revoked.** The event carries the bound - `actor` and the binding digest, not the revoker's subject or assurance, and - the engine does not check that the revoker is the approver or principal. - APPROVAL-WP-0007-T03 records the verified revoker identity, assurance and an - optional reason in the revocation event. +- **The verified revoker is recorded** (APPROVAL-WP-0007-T03, schema v9, in + source, not yet deployed). `GET` serves `revoked_by` with the token's + subject, principal type and assurance, plus an optional one-line `reason` + of at most 200 characters. The same object is on the `approval.revocation` + event. The event `actor` stays the bound actor. The revoker is not required + to be the principal or the approver. The claim does not carry `revoked_by`. + Legacy rows and an internal revoke with no verified identity omit it. - Supersession (`approval:supersede`) also stops an unconsumed approval and links a successor. For the pilot, revoke is the right kill switch. diff --git a/docs/storage-operations.md b/docs/storage-operations.md index 6ca81ee..714f8f7 100644 --- a/docs/storage-operations.md +++ b/docs/storage-operations.md @@ -94,3 +94,16 @@ Publish/scan and pin the same v6 artifact for both containers, take and verify the pre-migration backup, and obtain per-change rollout approval. An old v5 server refuses a v6 store; rollback uses the verified pre-migration backup with the matching release and the stopped single-writer restore procedure above. + +## Schema v7–v9 — assurance floor, actor consumption, revoker evidence + +v7 adds nullable `approvals.required_assurance`. v8 adds `approvals.actor_consumes` +(0 or 1, default 0). v9 adds nullable `revoked_by_subject`, +`revoked_by_principal_type`, `revoked_by_assurance`, and `revocation_reason`. +Migration is additive. Legacy rows stay NULL, and `actor_consumes` stays 0. +Nothing is inferred for old approvals, entries, or revocations. + +A v6 server refuses a v9 store. Rollback uses the verified pre-v9 backup with +the matching v6 release and the stopped single-writer restore procedure above. +Take and verify that backup, and open an isolated copy with the new image, +before the sole pod is replaced. diff --git a/examples/bounded-execution-approval-request.json b/examples/bounded-execution-approval-request.json new file mode 100644 index 0000000..ccaa576 --- /dev/null +++ b/examples/bounded-execution-approval-request.json @@ -0,0 +1,21 @@ +{ + "binding": { + "action": "bounded-execution", + "actor": "service:nk-bind-executor", + "principal": "admin", + "purpose": "synthetic bounded-execution carrier", + "target": { + "admission_id": "BEA-SYNTH-0001", + "admission_digest": "sha256:7b91434ed31962216e60117b989335bd55b0a679d2dfc1609cd4013dee45a947" + } + }, + "validity": { + "not_before": "2026-08-29T11:00:00+00:00", + "expires_at": "2026-08-29T15:00:00+00:00" + }, + "required_count": 1, + "human_control": true, + "pdp_path": false, + "required_assurance": {"level": "aal2"}, + "actor_consumes": true +} diff --git a/tests/test_actor_consumes.py b/tests/test_actor_consumes.py index d20d225..2f9c23c 100644 --- a/tests/test_actor_consumes.py +++ b/tests/test_actor_consumes.py @@ -159,7 +159,7 @@ def test_legacy_v7_rows_migrate_without_the_flag(tmp_path): survivor = upgraded.get(obj.id) assert survivor.actor_consumes is False assert "actor_consumes" not in upgraded.claim(obj.id)["binding"] - assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9 upgraded.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other")) upgraded.close() diff --git a/tests/test_bounded_execution_carrier.py b/tests/test_bounded_execution_carrier.py new file mode 100644 index 0000000..982a1b4 --- /dev/null +++ b/tests/test_bounded_execution_carrier.py @@ -0,0 +1,200 @@ +"""APPROVAL-WP-0007-T04: synthetic bounded-execution carrier path. + +The request is the published example. Nothing here contacts a live service. +""" +import json +from datetime import datetime, timezone +from pathlib import Path + +from approval_engine.api import App, call +from approval_engine.auth import Identity, StaticTokenAuthenticator +from approval_engine.store import Engine +from tests.conftest import FROZEN + +EXAMPLE = json.loads( + (Path(__file__).parents[1] / "examples" / "bounded-execution-approval-request.json").read_text() +) +ADMISSION_DIGEST = EXAMPLE["binding"]["target"]["admission_digest"] +OTHER_DIGEST = "sha256:" + "ab" * 32 +APPROVER = "uid=admin,ou=people,dc=netkingdom,dc=local" +FOUNDER = "uid=founder,ou=people,dc=netkingdom,dc=local" + + +def _identity(subject, principal_type, level): + return Identity( + subject=subject, + issuer="https://keycape.example", + audiences=("approval-engine",), + principal_type=principal_type, + tenant="tenant:platform", + roles=frozenset(), + scopes=frozenset({ + "approval:create", "approval:read", "approval:approve", + "approval:revoke", "approval:consume", + }), + assurance={"level": level, "amr": ["pwd", "otp"], "source": "key-cape"}, + evidence_ref=f"test-identity:{subject}", + ) + + +def _app(engine): + return App(engine, StaticTokenAuthenticator({ + "executor": _identity(EXAMPLE["binding"]["actor"], "service", "aal1"), + "aal1": _identity(APPROVER, "human", "aal1"), + "human": _identity(APPROVER, "human", "aal2"), + "founder": _identity(FOUNDER, "human", "aal2"), + "other": _identity("service:someone-else", "service", "aal1"), + })) + + +def test_synthetic_bounded_execution_carrier(): + clock = [FROZEN] + engine = Engine(":memory:", clock=lambda: clock[0]) + app = _app(engine) + try: + request = json.loads(json.dumps(EXAMPLE)) + assert request["binding"]["action"] == "bounded-execution" + assert request["human_control"] is True and request["pdp_path"] is False + assert set(request["binding"]["target"]) == {"admission_id", "admission_digest"} + + status, created = call( + app, "POST", "/v1/approvals", request, authorization="Bearer executor", + ) + assert status == 201 + aid = created["id"] + assert created["status"] == "requested" + assert created["binding"]["actor"] == request["binding"]["actor"] + assert created["binding"]["target"] == request["binding"]["target"] + + status, refused = call( + app, "POST", f"/v1/approvals/{aid}/entries", {}, authorization="Bearer aal1", + ) + assert status == 403 and refused["error"] == "forbidden" + + status, approved = call( + app, "POST", f"/v1/approvals/{aid}/entries", {}, authorization="Bearer human", + ) + assert status == 200 and approved["status"] == "approved" + assert approved["entries"][0]["subject_id"] == APPROVER + assert approved["entries"][0]["principal_type"] == "human" + assert json.loads(approved["entries"][0]["assurance"])["level"] == "aal2" + assert APPROVER != request["binding"]["principal"] + + status, fetched = call( + app, "GET", f"/v1/approvals/{aid}", authorization="Bearer executor", + ) + assert status == 200 + for field in ("action", "actor", "principal", "purpose", "target"): + assert fetched["binding"][field] == request["binding"][field] + assert fetched["binding"]["digest"] == created["binding"]["digest"] + assert fetched["binding"]["human_control"] is True + assert fetched["binding"]["pdp_path"] is False + assert fetched["binding"]["required_assurance"] == {"level": "aal2"} + assert fetched["binding"]["actor_consumes"] is True + assert fetched["validity"] == request["validity"] + + status, other = call( + app, "POST", f"/v1/approvals/{aid}/consume", + {"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"}, + authorization="Bearer other", + ) + assert status == 403 and other["error"] == "forbidden" + + status, spent = call( + app, "POST", f"/v1/approvals/{aid}/consume", + {"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"}, + authorization="Bearer executor", + ) + assert status == 200 + assert spent["status"] == "consumed" + assert spent["request_digest"] == ADMISSION_DIGEST + assert spent["idempotent"] is False + + status, again = call( + app, "POST", f"/v1/approvals/{aid}/consume", + {"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"}, + authorization="Bearer executor", + ) + assert status == 200 and again["idempotent"] is True + assert again["request_digest"] == ADMISSION_DIGEST + assert again["consumed_at"] == spent["consumed_at"] + + status, conflict = call( + app, "POST", f"/v1/approvals/{aid}/consume", + {"request_digest": OTHER_DIGEST, "decision_id": "run-2"}, + authorization="Bearer executor", + ) + assert status == 409 and conflict["error"] == "conflict" + + # Revoke before consume, by someone who is not the bound principal. + status, second = call( + app, "POST", "/v1/approvals", request, authorization="Bearer executor", + ) + assert status == 201 + second_id = second["id"] + assert call( + app, "POST", f"/v1/approvals/{second_id}/entries", {}, + authorization="Bearer human", + )[0] == 200 + status, revoked = call( + app, "POST", f"/v1/approvals/{second_id}/revoke", + {"reason": "founder stopped the run before consume"}, + authorization="Bearer founder", + ) + assert status == 200 and revoked["status"] == "revoked" + assert revoked["revoked_by"]["subject"] == FOUNDER + assert revoked["revoked_by"]["subject"] != request["binding"]["principal"] + status, blocked = call( + app, "POST", f"/v1/approvals/{second_id}/consume", + {"request_digest": ADMISSION_DIGEST, "decision_id": "run-revoked"}, + authorization="Bearer executor", + ) + assert status == 409 and blocked["error"] == "conflict" + + # Consumption outside the window is refused. Issue and approve while + # the clock is inside, then move it onto the exclusive expiry. + short = json.loads(json.dumps(request)) + short["validity"] = { + "not_before": "2026-08-29T11:00:00+00:00", + "expires_at": "2026-08-29T12:30:00+00:00", + } + status, third = call( + app, "POST", "/v1/approvals", short, authorization="Bearer executor", + ) + assert status == 201 + third_id = third["id"] + assert call( + app, "POST", f"/v1/approvals/{third_id}/entries", {}, + authorization="Bearer human", + )[0] == 200 + clock[0] = datetime(2026, 8, 29, 12, 30, tzinfo=timezone.utc) + status, late = call( + app, "POST", f"/v1/approvals/{third_id}/consume", + {"request_digest": ADMISSION_DIGEST, "decision_id": "run-late"}, + authorization="Bearer executor", + ) + assert status == 409 and "window" in late["message"] + + early = json.loads(json.dumps(request)) + early["validity"] = { + "not_before": "2026-08-29T13:00:00+00:00", + "expires_at": "2026-08-29T15:00:00+00:00", + } + clock[0] = FROZEN + status, fourth = call( + app, "POST", "/v1/approvals", early, authorization="Bearer executor", + ) + assert status == 201 + fourth_id = fourth["id"] + assert call( + app, "POST", f"/v1/approvals/{fourth_id}/entries", {}, + authorization="Bearer human", + )[0] == 200 + status, soon = call( + app, "POST", f"/v1/approvals/{fourth_id}/consume", + {"request_digest": ADMISSION_DIGEST, "decision_id": "run-early"}, + authorization="Bearer executor", + ) + assert status == 409 and "window" in soon["message"] + finally: + engine.close() diff --git a/tests/test_human_control.py b/tests/test_human_control.py index f6faa87..14d0a01 100644 --- a/tests/test_human_control.py +++ b/tests/test_human_control.py @@ -144,7 +144,7 @@ def test_legacy_v4_rows_are_not_reclassified_from_human_entries(tmp_path): assert survivor.entries[0]["principal_type"] == "human" assert survivor.human_control is False assert upgraded.claim(obj.id)["binding"]["human_control"] is False - assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9 upgraded.close() diff --git a/tests/test_required_assurance.py b/tests/test_required_assurance.py index df8319d..c806ad1 100644 --- a/tests/test_required_assurance.py +++ b/tests/test_required_assurance.py @@ -159,5 +159,5 @@ def test_legacy_v6_rows_migrate_to_null_floor(tmp_path): survivor = upgraded.get(obj.id) assert survivor.status == "approved" and survivor.required_assurance is None assert upgraded.claim(obj.id)["valid_now"] is True - assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9 upgraded.close() diff --git a/tests/test_revoker.py b/tests/test_revoker.py new file mode 100644 index 0000000..7ed5a8f --- /dev/null +++ b/tests/test_revoker.py @@ -0,0 +1,160 @@ +"""APPROVAL-WP-0007-T03: verified revoker evidence on the object and the event. + +The revoker is whoever holds approval:revoke. They are not required to be +the bound principal. The body cannot name the revoker. +""" +import json +from pathlib import Path + +import pytest + +from approval_engine.api import App, call +from approval_engine.auth import Identity, StaticTokenAuthenticator +from approval_engine.errors import Conflict, Unprocessable +from approval_engine.store import Engine, LATEST_SCHEMA_VERSION +from tests.conftest import FROZEN, approve, binding, validity + +AAL2 = {"level": "aal2", "amr": ["pwd", "otp"], "source": "key-cape"} + + +def _identity(subject, principal_type="human", assurance=None): + return Identity( + subject=subject, + issuer="https://keycape.example", + audiences=("approval-engine",), + principal_type=principal_type, + tenant="tenant:platform", + roles=frozenset(), + scopes=frozenset({"approval:create", "approval:read", "approval:approve", + "approval:revoke", "approval:consume"}), + assurance=assurance or dict(AAL2), + evidence_ref=f"test-identity:{subject}", + ) + + +@pytest.fixture +def people(engine): + return App(engine, StaticTokenAuthenticator({ + "executor": _identity("service:nk-bind-executor", "service", {"level": "aal1", "source": "key-cape"}), + "founder": _identity("uid=founder,ou=people,dc=netkingdom,dc=local"), + "holder": _identity("admin"), + })) + + +def _issued(app): + status, created = call( + app, "POST", "/v1/approvals", + {"binding": binding(actor="service:nk-bind-executor", principal="admin"), + "validity": validity(), "human_control": True}, + authorization="Bearer executor", + ) + assert status == 201 + status, _ = call( + app, "POST", f"/v1/approvals/{created['id']}/entries", {}, + authorization="Bearer holder", + ) + assert status == 200 + return created["id"] + + +def _revocation(engine, approval_id): + payload = engine._conn().execute( + "SELECT payload_json FROM outbox WHERE approval_id=? AND class='revocation'", + (approval_id,), + ).fetchone()[0] + return json.loads(payload) + + +def test_http_records_the_token_revoker_not_the_body(people, engine): + aid = _issued(people) + status, body = call( + people, "POST", f"/v1/approvals/{aid}/revoke", + {"reason": "pre-start kill switch", "subject": "admin", "principal_type": "human"}, + authorization="Bearer founder", + ) + assert status == 422 + assert body["error"] == "unprocessable" + + status, revoked = call( + people, "POST", f"/v1/approvals/{aid}/revoke", + {"reason": "pre-start kill switch"}, + authorization="Bearer founder", + ) + assert status == 200 + assert revoked["status"] == "revoked" + assert revoked["revoked_by"] == { + "subject": "uid=founder,ou=people,dc=netkingdom,dc=local", + "principal_type": "human", + "assurance": AAL2, + "reason": "pre-start kill switch", + } + assert revoked["revoked_by"]["subject"] != revoked["binding"]["principal"] + event = _revocation(engine, aid) + assert event["action"] == "approval.revocation" + assert event["actor"] == "service:nk-bind-executor" + assert event["details"]["revoked_by"] == revoked["revoked_by"] + assert event["details"]["binding_digest"] == revoked["binding"]["digest"] + status, claim = call(people, "GET", f"/v1/approvals/{aid}/claim", authorization="Bearer founder") + assert status == 200 + assert claim["reason_code"] == "revoked" + assert "revoked_by" not in claim + + +def test_reason_is_optional_and_bounded(people): + aid = _issued(people) + status, revoked = call( + people, "POST", f"/v1/approvals/{aid}/revoke", {}, + authorization="Bearer founder", + ) + assert status == 200 + assert "reason" not in revoked["revoked_by"] + + other = _issued(people) + status, body = call( + people, "POST", f"/v1/approvals/{other}/revoke", + {"reason": "x" * 201}, + authorization="Bearer founder", + ) + assert status == 422 + status, body = call( + people, "POST", f"/v1/approvals/{other}/revoke", + {"reason": "two\nlines"}, + authorization="Bearer founder", + ) + assert status == 422 + status, still = call(people, "GET", f"/v1/approvals/{other}", authorization="Bearer founder") + assert still["status"] == "approved" + assert "revoked_by" not in still + + +def test_internal_revoke_omits_evidence_and_stays_terminal(engine): + obj = approve(engine) + revoked = engine.revoke(obj.id) + assert revoked.status == "revoked" + assert "revoked_by" not in revoked.as_dict() + event = _revocation(engine, obj.id) + assert "revoked_by" not in event["details"] + with pytest.raises(Conflict): + engine.revoke(obj.id, revoker=_identity("uid=founder,ou=people,dc=netkingdom,dc=local")) + with pytest.raises(Unprocessable, match="reason"): + engine.revoke(obj.id, reason="too late") + + +def test_legacy_rows_migrate_without_a_revoker(tmp_path): + path = tmp_path / "v8.sqlite" + eng = Engine(path, clock=lambda: FROZEN) + obj = approve(eng) + eng.revoke(obj.id) + eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_subject") + eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_principal_type") + eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_assurance") + eng._conn().execute("ALTER TABLE approvals DROP COLUMN revocation_reason") + eng._conn().execute("PRAGMA user_version=8") + eng._conn().commit() + eng.close() + upgraded = Engine(path, clock=lambda: FROZEN) + survivor = upgraded.get(obj.id) + assert survivor.status == "revoked" + assert "revoked_by" not in survivor.as_dict() + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9 + upgraded.close() diff --git a/tests/test_tenant_provenance.py b/tests/test_tenant_provenance.py index 96fd9b9..cd7dbf9 100644 --- a/tests/test_tenant_provenance.py +++ b/tests/test_tenant_provenance.py @@ -79,7 +79,7 @@ def test_v5_migration_preserves_unknown_provenance_and_existing_evidence(tmp_pat with pytest.raises(StoreUnavailable, match="migrate"): Engine(path, auto_migrate=False) engine = Engine(path, clock=lambda: FROZEN) - assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 + assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9 assert engine.get(obj.id).entries[0]["tenant_source"] is None assert engine.get(obj.id).binding_digest == digest assert engine.get(obj.id).human_control is True diff --git a/workplans/APPROVAL-WP-0005-access-claim-fact-contract.md b/workplans/APPROVAL-WP-0005-access-claim-fact-contract.md index 72347c1..2c3e45b 100644 --- a/workplans/APPROVAL-WP-0005-access-claim-fact-contract.md +++ b/workplans/APPROVAL-WP-0005-access-claim-fact-contract.md @@ -4,12 +4,12 @@ type: workplan title: "Approval fact contract and reusable Go data adapter" domain: infotech repo: approval-engine -status: active +status: blocked flavor: implementation owner: codex topic_slug: netkingdom created: "2026-10-16" -updated: "2026-10-23" +updated: "2026-11-07" depends_on: [] state_hub_workstream_id: "777b17de-2c1e-5ce5-aa82-c99e5137e956" --- @@ -21,6 +21,12 @@ T03's pure reusable data adapter, agreed with Access before implementation. No broad residual activation, credential transport runtime, trust admission, policy eligibility or live action is included. +2026-11-07: status is `blocked`. T01 and T03 are done in this repo. T02 stays +`wait` on access-engine. The producer Go adapter bytes match the copy in +access-engine `third_party/approvalfact`. Closing T02 needs Access +conformance evidence under FLEX-WP-0024-T06, which is still `progress`. No +further change in this repo closes it. + ## Review authoritative claim guarantees and publish thin consumer vectors ```task diff --git a/workplans/APPROVAL-WP-0006-fractional-consumption-time.md b/workplans/APPROVAL-WP-0006-fractional-consumption-time.md index 87ac840..05bada7 100644 --- a/workplans/APPROVAL-WP-0006-fractional-consumption-time.md +++ b/workplans/APPROVAL-WP-0006-fractional-consumption-time.md @@ -4,12 +4,12 @@ type: workplan title: "Preserve fractional authoritative approval and consumption time" domain: infotech repo: approval-engine -status: active +status: blocked flavor: implementation owner: codex topic_slug: netkingdom created: "2026-10-16" -updated: "2026-10-23" +updated: "2026-11-07" depends_on: [] state_hub_workstream_id: "9b859b2f-f98a-5104-b0d5-72bfdaf56da4" --- @@ -20,6 +20,11 @@ the actual signed decision. INTENT basis: valid right now, exact-bound atomic consumption and faithful source evidence. Fix source time, not Access backdating or a consumer waiver. This does not promote native issuer/deployment authority. +2026-11-07: status is `blocked`. This plan is not current work. T01 stays +`done`. T02 stays `progress`; the remaining receipt is the Access and +secrets-engine immediate strict CAS remeasurement, and no task status was +changed to park the plan. + ## Preserve clock precision and one post-lock consumption instant ```task diff --git a/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md index 29a755a..74e0ea6 100644 --- a/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md +++ b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md @@ -4,12 +4,12 @@ type: workplan title: "Bounded-execution admission carrier: assurance floor, correspondence and revoker evidence" domain: infotech repo: approval-engine -status: active +status: blocked flavor: implementation owner: approval-engine topic_slug: netkingdom created: "2026-10-31" -updated: "2026-11-01" +updated: "2026-11-07" depends_on: [] state_hub_workstream_id: "af365506-f451-50b5-a0a2-f0ba9a0a4d9c" --- @@ -22,6 +22,10 @@ revoke already work. This plan closes the engine-side gaps the answer found. Every change is opt-in at issue, so existing approvals and consumers keep their behavior. Native digest, CAS consumption and no-unconsume are unchanged. +2026-11-07: status is `blocked`. This plan is not current work. T01 through +T04 stay as recorded. T05 stays `wait` on KeyCape subject equality. T06 stays +`todo` for the later self-acceptance exception. + ## Declared minimum approver assurance ```task @@ -100,7 +104,7 @@ the entry subject itself (`docs/bounded-execution-admission-carrier.md` §3). ```task id: APPROVAL-WP-0007-T03 -status: todo +status: done priority: medium state_hub_task_id: "7ceb5ffd-3ba8-5e9d-96a2-5e89c38a5c43" ``` @@ -112,11 +116,18 @@ terminal semantics. This is the evidence for the founder's pre-start kill switch (NK-WP-0056-T07). Do not require the revoker to equal the principal: revocation must stay possible without the holder. +Done 2026-11-07 in source (schema v9, `tests/test_revoker.py`, contract in +`docs/approval-claim.md`). `POST /revoke` accepts only an optional one-line +reason of at most 200 characters. The token supplies the revoker. GET and +`details.revoked_by` serve it. The claim omits it. An internal revoke with no +verified identity still revokes and omits the member, as do migrated rows. +Not released or deployed: a v9 image and a founder-run rollout are still needed. + ## Synthetic bounded-execution carrier proof ```task id: APPROVAL-WP-0007-T04 -status: todo +status: done priority: medium state_hub_task_id: "673890fe-3d49-5fe3-8ad7-c8c73e4d9376" ``` @@ -130,6 +141,14 @@ different digest returns 409; revoke before consume gives 409 on consume; consumption outside the window is refused. Synthetic fixtures only, with no live service. +Done 2026-11-07. Example `examples/bounded-execution-approval-request.json`. +Test `tests/test_bounded_execution_carrier.py` runs that request in process: +aal1 entry refused by the declared floor, human aal2 approves, GET matches +the binding, the actor consumes the admission digest, the same digest retries, +a different digest is 409, a founder who is not the principal revokes before +consume and consume is 409, and consume before the window and at expiry is +409. No live service. + ## Founder decision 2026-10-31 Approved by the founder (`proposed` -> `ready`) after the NK-WP-0056-T02 owner answers (hub decision 19afd15e). @@ -140,6 +159,7 @@ Approved by the founder (`proposed` -> `ready`) after the NK-WP-0056-T02 owner a id: APPROVAL-WP-0007-T06 status: todo priority: low +state_hub_task_id: "db25c052-3c60-5328-9993-b51f87fc5ba6" ``` Founder direction, 2026-11-01 (gate-house GH-DEC-2026-026 addendum, relayed by the net-kingdom coordinator).