APPROVAL-WP-0007: record the verified revoker and park the inactive plans
Schema v9 stores who revoked an approval and an optional bounded reason. The bounded-execution carrier example is proven in process. APPROVAL-WP-0005, 0006, and 0007 are blocked because they are not current work. Task statuses stay as they were, apart from T03 and T04, which are done in source. Assistant: grok Assistant-Session: 01a1b5fd-0c33-7ba1-b383-53937d525595
This commit is contained in:
parent
f41a9ca82c
commit
4aa0b75449
17 changed files with 599 additions and 36 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
21
examples/bounded-execution-approval-request.json
Normal file
21
examples/bounded-execution-approval-request.json
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
200
tests/test_bounded_execution_carrier.py
Normal file
200
tests/test_bounded_execution_carrier.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
160
tests/test_revoker.py
Normal file
160
tests/test_revoker.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue