APPROVAL-WP-0007-T02 done: actor_consumes; T05 principal_approves split out
Founder decision 2026-11-01, relayed by the net-kingdom coordinator: "Agree to split APPROVAL-WP-0007-T02: build actor_consumes now (opt-in at issue; the bounded executor will set it); principal_approves keeps waiting for the KeyCape subject mapping." - Optional actor_consumes (boolean) at issue, schema v8. Consume refuses with 403 unless the consuming subject equals binding.actor, also on idempotent retries; internal calls without a consumer fail closed. Inherited on supersession (existing-successor mismatch 409), in audit details, served in GET/claim only when declared so undeclared claims keep exact bytes. Legacy rows migrate without the flag. - GH-DEC-2026-026 Ruling 6: under human_control, add_entry refuses (403) the binding actor as approver. human_control already covered a service actor; this covers a human actor. - Workplan: T02 done (actor_consumes); new T05 principal_approves (wait). - Not released or deployed: needs a v8 image and founder-run rollout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
parent
e9e9f93317
commit
5c45d3c157
11 changed files with 344 additions and 36 deletions
|
|
@ -134,6 +134,7 @@ class App:
|
|||
pdp_path=bool(data.get("pdp_path", False)),
|
||||
human_control=data.get("human_control", False),
|
||||
required_assurance=data.get("required_assurance"),
|
||||
actor_consumes=data.get("actor_consumes", False),
|
||||
approval_id=data.get("id"),
|
||||
)
|
||||
return 201, obj.as_dict()
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ 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 = 7
|
||||
LATEST_SCHEMA_VERSION = 8
|
||||
# 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")
|
||||
|
|
@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS approvals (
|
|||
pdp_path INTEGER NOT NULL DEFAULT 0,
|
||||
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)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
|
@ -165,6 +166,7 @@ class Approval:
|
|||
pdp_path: bool
|
||||
human_control: bool
|
||||
required_assurance: str | None
|
||||
actor_consumes: bool
|
||||
actor: str
|
||||
principal: str
|
||||
action: str
|
||||
|
|
@ -203,6 +205,9 @@ class Approval:
|
|||
# APPROVAL-WP-0007-T01. Stated only when declared, so undeclared
|
||||
# objects keep their exact pre-v7 shape for strict readers.
|
||||
result["binding"]["required_assurance"] = {"level": self.required_assurance}
|
||||
if self.actor_consumes:
|
||||
# APPROVAL-WP-0007-T02, same rule: stated only when declared.
|
||||
result["binding"]["actor_consumes"] = True
|
||||
if self.consumed_digest:
|
||||
result["consumption"] = {
|
||||
"request_digest": self.consumed_digest,
|
||||
|
|
@ -305,6 +310,13 @@ class Engine:
|
|||
"ALTER TABLE approvals ADD COLUMN required_assurance TEXT CHECK "
|
||||
"(required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3'))"
|
||||
)
|
||||
if "actor_consumes" not in approval_columns:
|
||||
# v8, APPROVAL-WP-0007-T02. Legacy rows default to 0: nobody
|
||||
# declared at their issue that only the actor may spend them.
|
||||
conn.execute(
|
||||
"ALTER TABLE approvals ADD COLUMN actor_consumes "
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1))"
|
||||
)
|
||||
entry_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
|
||||
}
|
||||
|
|
@ -427,6 +439,7 @@ class Engine:
|
|||
pdp_path: bool = False,
|
||||
human_control: bool = False,
|
||||
required_assurance: dict[str, Any] | None = None,
|
||||
actor_consumes: bool = False,
|
||||
approval_id: str | None = None,
|
||||
) -> Approval:
|
||||
if required_count < 1:
|
||||
|
|
@ -435,6 +448,8 @@ class Engine:
|
|||
raise Unprocessable("pdp_path must be a boolean")
|
||||
if not isinstance(human_control, bool):
|
||||
raise Unprocessable("human_control must be a boolean")
|
||||
if not isinstance(actor_consumes, bool):
|
||||
raise Unprocessable("actor_consumes must be a boolean")
|
||||
floor = require_assurance_floor(required_assurance)
|
||||
canon = canonical_binding(binding)
|
||||
digest = binding_digest(canon)
|
||||
|
|
@ -464,8 +479,9 @@ class Engine:
|
|||
id, status, binding_json, binding_digest, pdp_digest,
|
||||
actor, principal, action, purpose, target_json,
|
||||
not_before, expires_at, required_count, superseded_by,
|
||||
pdp_path, human_control, required_assurance, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
pdp_path, human_control, required_assurance, actor_consumes,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
aid,
|
||||
"requested",
|
||||
|
|
@ -484,6 +500,7 @@ class Engine:
|
|||
1 if pdp_path else 0,
|
||||
1 if human_control else 0,
|
||||
floor,
|
||||
1 if actor_consumes else 0,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
|
|
@ -543,6 +560,7 @@ class Engine:
|
|||
pdp_path=bool(row["pdp_path"]),
|
||||
human_control=bool(row["human_control"]),
|
||||
required_assurance=row["required_assurance"],
|
||||
actor_consumes=bool(row["actor_consumes"]),
|
||||
actor=row["actor"],
|
||||
principal=row["principal"],
|
||||
action=row["action"],
|
||||
|
|
@ -608,6 +626,12 @@ class Engine:
|
|||
# This is the bind boundary. A service may draft a requested
|
||||
# approval, but cannot contribute evidence for human judgment.
|
||||
raise Forbidden("human_control requires a verified human approver")
|
||||
if row["human_control"] and subject_id == row["actor"]:
|
||||
# GH-DEC-2026-026 Ruling 6: the actor is never an approver of
|
||||
# its own approval. The principal_type check above covers a
|
||||
# service actor; this covers a human actor, who may create a
|
||||
# human_control request but must not also approve it.
|
||||
raise Forbidden("human_control forbids the binding actor approving its own approval")
|
||||
if not entry_meets_floor(assurance, row["required_assurance"]):
|
||||
# The level comes only from the verified token (api.py); a
|
||||
# weaker or missing level cannot count toward this object.
|
||||
|
|
@ -718,13 +742,16 @@ class Engine:
|
|||
conn.rollback()
|
||||
raise Conflict("supersession lost the compare-and-swap")
|
||||
existing = conn.execute(
|
||||
"SELECT id, human_control, required_assurance FROM approvals WHERE id=?",
|
||||
"SELECT id, human_control, required_assurance, actor_consumes "
|
||||
"FROM approvals WHERE id=?",
|
||||
(successor_id,),
|
||||
).fetchone()
|
||||
if existing is not None and existing["human_control"] != row["human_control"]:
|
||||
raise Conflict("successor must inherit the human_control declaration")
|
||||
if existing is not None and existing["required_assurance"] != row["required_assurance"]:
|
||||
raise Conflict("successor must inherit the required_assurance declaration")
|
||||
if existing is not None and existing["actor_consumes"] != row["actor_consumes"]:
|
||||
raise Conflict("successor must inherit the actor_consumes declaration")
|
||||
created_successor = False
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
|
|
@ -732,8 +759,9 @@ class Engine:
|
|||
id, status, binding_json, binding_digest, pdp_digest,
|
||||
actor, principal, action, purpose, target_json,
|
||||
not_before, expires_at, required_count, superseded_by,
|
||||
pdp_path, human_control, required_assurance, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
pdp_path, human_control, required_assurance, actor_consumes,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
successor_id,
|
||||
"requested",
|
||||
|
|
@ -752,6 +780,7 @@ class Engine:
|
|||
row["pdp_path"],
|
||||
row["human_control"],
|
||||
row["required_assurance"],
|
||||
row["actor_consumes"],
|
||||
now,
|
||||
now,
|
||||
),
|
||||
|
|
@ -843,6 +872,12 @@ class Engine:
|
|||
if row is None:
|
||||
conn.rollback()
|
||||
raise NotFound(approval_id)
|
||||
if row["actor_consumes"] and (consumer is None or consumer.subject != row["actor"]):
|
||||
# APPROVAL-WP-0007-T02. Checked before the idempotent branch so
|
||||
# another service cannot obtain a consumption receipt either.
|
||||
# No authenticated consumer (internal call) fails closed.
|
||||
conn.rollback()
|
||||
raise Forbidden("actor_consumes requires the consuming subject to equal binding.actor")
|
||||
if row["status"] == "consumed":
|
||||
if row["consumed_digest"] != digest:
|
||||
conn.rollback()
|
||||
|
|
@ -1004,13 +1039,15 @@ class Engine:
|
|||
if approval_id:
|
||||
details["approval_id"] = approval_id
|
||||
row = conn.execute(
|
||||
"SELECT human_control, required_assurance FROM approvals WHERE id=?",
|
||||
"SELECT human_control, required_assurance, actor_consumes FROM approvals WHERE id=?",
|
||||
(approval_id,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
details["human_control"] = bool(row["human_control"])
|
||||
if row["required_assurance"]:
|
||||
details["required_assurance"] = {"level": row["required_assurance"]}
|
||||
if row["actor_consumes"]:
|
||||
details["actor_consumes"] = True
|
||||
payload = {
|
||||
"schema_version": AUDIT_SCHEMA,
|
||||
"event_id": event_id,
|
||||
|
|
@ -1061,6 +1098,10 @@ class Engine:
|
|||
# their exact pre-v7 bytes; absence means no floor was declared.
|
||||
if obj.required_assurance:
|
||||
binding["required_assurance"] = {"level": obj.required_assurance}
|
||||
# APPROVAL-WP-0007-T02: same rule. Absence means any tenant consumer
|
||||
# holding approval:consume may spend the object.
|
||||
if obj.actor_consumes:
|
||||
binding["actor_consumes"] = True
|
||||
return {
|
||||
"schema_version": CLAIM_SCHEMA,
|
||||
"kind": "approval-claim",
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ 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 v7 (`approvals.required_assurance`,
|
||||
APPROVAL-WP-0007-T01); the pinned image predates schema v7. Deploying v7 needs a
|
||||
new release, a verified pre-v7 backup and a founder-run rollout like the v6 one.
|
||||
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.
|
||||
|
||||
The earlier September v5 release evidence remains historical at
|
||||
[the image evidence](../docs/evidence/2026-09-10-human-control-image.json).
|
||||
|
|
|
|||
|
|
@ -425,3 +425,34 @@ entry, never a request body:
|
|||
Migration leaves existing rows NULL (no floor). The declaration is separate from
|
||||
the five-field act digest. Whether KeyCape issues `aal2` for a step-up login is
|
||||
KeyCape's question.
|
||||
|
||||
### Declared consumer = actor — `binding.actor_consumes`
|
||||
|
||||
APPROVAL-WP-0007-T02 (actor half), schema v8 store. `POST /v1/approvals` may
|
||||
declare `"actor_consumes": true` (a JSON boolean; anything else is 422; `false`
|
||||
is the same as absent). The flag is stored on the object, served in `GET` and
|
||||
the claim as `binding.actor_consumes: true`, carried on audit events in
|
||||
`details.actor_consumes`, and inherited on supersession (linking an existing
|
||||
successor with a different declaration is a 409).
|
||||
|
||||
The member is **present only when declared**, so undeclared objects keep their
|
||||
exact pre-v8 bytes. Absence means any tenant service or agent holding
|
||||
`approval:consume` may consume, as before.
|
||||
|
||||
When declared, `POST /consume` refuses with **403** unless the authenticated
|
||||
consuming subject equals `binding.actor`. The check runs before the idempotent
|
||||
branch, so another service gets no receipt from a retry either. An internal
|
||||
call with no authenticated consumer fails closed. The check does not change
|
||||
`valid_now`: the claim describes the object, not who is reading it.
|
||||
|
||||
Migration leaves existing rows without the flag. The declaration is separate
|
||||
from the five-field act digest.
|
||||
|
||||
### Actor is never an approver under `human_control`
|
||||
|
||||
GH-DEC-2026-026 Ruling 6. Under `human_control: true`, `POST /entries` refuses
|
||||
(403) an entry whose verified subject equals `binding.actor`. For a service
|
||||
actor the existing human-only rule already made this impossible; the check
|
||||
covers a human actor, who may create the request but not approve it. Objects
|
||||
without `human_control` are unchanged. Entries persisted before this check
|
||||
are not re-evaluated by the claim or consume.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ unchanged:
|
|||
"validity": {"not_before": "<window start>", "expires_at": "<window end>"},
|
||||
"required_count": 1,
|
||||
"human_control": true,
|
||||
"required_assurance": {"level": "aal2"},
|
||||
"actor_consumes": true,
|
||||
"pdp_path": false
|
||||
}
|
||||
```
|
||||
|
|
@ -64,7 +66,16 @@ Things the requester must know:
|
|||
- **Declare `human_control: true`.** Then `add_entry` refuses any non-human
|
||||
approver and consume refuses without the required human entries
|
||||
(`Engine.add_entry`, `Engine.consume`). This keeps the service actor from
|
||||
approving its own request.
|
||||
approving its own request. GH-DEC-2026-026 Ruling 6 (the actor is never an
|
||||
approver of its own approval) is therefore already met for this service
|
||||
actor by `human_control` alone. For a human actor it is not, so `add_entry`
|
||||
also refuses (403), under `human_control`, an entry whose subject equals
|
||||
`binding.actor` (`docs/approval-claim.md`).
|
||||
- **Declare `actor_consumes: true`** (APPROVAL-WP-0007-T02, schema v8, in
|
||||
source, not yet deployed). Consume then refuses with 403 unless the
|
||||
consuming subject equals `binding.actor` (see §3). The `required_assurance`
|
||||
and `actor_consumes` members are accepted only by a v7/v8 image; until one is
|
||||
live, omit them and keep the executor's own checks.
|
||||
- **`pdp_path: false` for the pilot.** `pdp_path: true` requires a PDP request
|
||||
digest at issue (GH-DEC-2026-008). There is no access-engine decision for
|
||||
executor operations yet, so the pilot is off the PDP path. When access-engine
|
||||
|
|
@ -72,8 +83,10 @@ Things the requester must know:
|
|||
- **`principal` is a bound label, not an approver constraint.** The engine does
|
||||
not check that the approving human equals `binding.principal`. Any human
|
||||
holding `approval:approve` for the tenant can approve. Proposal
|
||||
APPROVAL-WP-0007-T02 lets an issue declare that correspondence; until then
|
||||
the executor checks the entry subject itself (see §3).
|
||||
APPROVAL-WP-0007-T05 (`principal_approves`, split from T02 by founder
|
||||
decision 2026-11-01) would let an issue declare that correspondence; it
|
||||
waits for the KeyCape subject mapping. Until then the executor checks the
|
||||
entry subject itself (see §3).
|
||||
|
||||
## 2. Assurance (BEA-C4)
|
||||
|
||||
|
|
@ -143,11 +156,14 @@ either one.
|
|||
`GET /v1/approvals/{id}` (scope `approval:read`) is the existing authoritative
|
||||
read surface for confirming consumption
|
||||
(`docs/task-execution-consumption-contract.md`, "Existing route and trust
|
||||
boundary"). The engine does not check whether a digest is equivalent and does
|
||||
not check that the consuming subject equals `binding.actor`. Any service in
|
||||
the tenant that holds `approval:consume` could spend the object. That cannot
|
||||
make anything run, but it can deny the run. Proposal APPROVAL-WP-0007-T02 adds
|
||||
an opt-in check that the consumer is the actor. The reading we intend for the
|
||||
boundary"). The engine does not check whether a digest is equivalent. Without a
|
||||
declaration it does not check that the consuming subject equals
|
||||
`binding.actor`: any service in the tenant that holds `approval:consume` could
|
||||
spend the object. That cannot make anything run, but it can deny the run.
|
||||
**Update (APPROVAL-WP-0007-T02, in source, not yet deployed):** issue with
|
||||
`"actor_consumes": true` and consume refuses (403) every other subject,
|
||||
including on idempotent retries; GET and the claim serve
|
||||
`binding.actor_consumes: true`. The reading we intend for the
|
||||
executor:
|
||||
|
||||
1. Before consuming, GET the object and require: `status == "approved"`; the
|
||||
|
|
|
|||
|
|
@ -175,6 +175,10 @@
|
|||
}
|
||||
},
|
||||
"description": "APPROVAL-WP-0007-T01: minimum authenticator assurance declared at issue (aal1 < aal2 < aal3). Present only when declared; absence means no floor was declared and is never proof of any level. With valid_now true, every entry's verified token assurance.level met it. Inherited on supersession. Separate from the five-field act digest. A consumer that needs a floor must require this member with the expected level; it must not infer one from approver entries."
|
||||
},
|
||||
"actor_consumes": {
|
||||
"const": true,
|
||||
"description": "APPROVAL-WP-0007-T02: declared at issue; only a consuming subject equal to binding.actor may consume (others get 403, including idempotent retries). Present only when declared; absence means any tenant service or agent holding approval:consume may consume. Inherited on supersession. Separate from the five-field act digest."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
190
tests/test_actor_consumes.py
Normal file
190
tests/test_actor_consumes.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""APPROVAL-WP-0007-T02: opt-in consumer = actor, declared at issue.
|
||||
|
||||
Also GH-DEC-2026-026 Ruling 6: under human_control the actor never approves
|
||||
its own approval.
|
||||
"""
|
||||
import dataclasses
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
from approval_engine.api import App, call
|
||||
from approval_engine.auth import Identity, StaticTokenAuthenticator
|
||||
from approval_engine.errors import Conflict, Forbidden, Unprocessable
|
||||
from approval_engine.store import Engine, LATEST_SCHEMA_VERSION
|
||||
from tests.conftest import FROZEN, binding, validity
|
||||
|
||||
SCHEMA = json.loads(
|
||||
(Path(__file__).parents[1] / "schemas" / "approval_claim.schema.json").read_text()
|
||||
)
|
||||
ACTOR = "agt-secrets-engine" # binding() default actor
|
||||
|
||||
|
||||
def _identity(subject, principal_type="service"):
|
||||
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:consume", "approval:supersede"}),
|
||||
assurance={"level": "aal2", "amr": ["pwd", "otp"], "source": "key-cape"},
|
||||
evidence_ref=f"test-identity:{subject}",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_app(engine):
|
||||
return App(engine, StaticTokenAuthenticator({
|
||||
"actor": _identity(ACTOR),
|
||||
"other": _identity("svc-other"),
|
||||
"human": _identity("user:admin", "human"),
|
||||
}))
|
||||
|
||||
|
||||
def _approved(engine, **kwargs):
|
||||
obj = engine.create(binding(), validity(), **kwargs)
|
||||
return engine.add_entry(obj.id, "user:admin", principal_type="human")
|
||||
|
||||
|
||||
def test_undeclared_keeps_any_consumer_and_exact_shape(engine):
|
||||
obj = _approved(engine)
|
||||
assert obj.actor_consumes is False
|
||||
assert "actor_consumes" not in obj.as_dict()["binding"]
|
||||
claim = engine.claim(obj.id)
|
||||
assert "actor_consumes" not in claim["binding"]
|
||||
jsonschema.validate(claim, SCHEMA)
|
||||
result = engine.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other"))
|
||||
assert result["status"] == "consumed"
|
||||
|
||||
|
||||
def test_declared_false_is_the_same_as_undeclared(engine):
|
||||
obj = _approved(engine, actor_consumes=False)
|
||||
assert "actor_consumes" not in engine.claim(obj.id)["binding"]
|
||||
engine.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["true", 1, None, [], {}])
|
||||
def test_declaration_must_be_a_boolean(multi_app, engine, value):
|
||||
status, _ = call(multi_app, "POST", "/v1/approvals", {
|
||||
"binding": binding(), "validity": validity(), "actor_consumes": value,
|
||||
}, authorization="Bearer actor")
|
||||
assert status == 422
|
||||
with pytest.raises(Unprocessable, match="actor_consumes"):
|
||||
engine.create(binding(), validity(), actor_consumes=value)
|
||||
|
||||
|
||||
def test_wrong_consumer_is_refused_and_actor_consumes_over_http(multi_app, engine):
|
||||
status, created = call(multi_app, "POST", "/v1/approvals", {
|
||||
"binding": binding(), "validity": validity(),
|
||||
"human_control": True, "actor_consumes": True,
|
||||
}, authorization="Bearer actor")
|
||||
assert status == 201, created
|
||||
assert created["binding"]["actor_consumes"] is True
|
||||
status, _ = call(multi_app, "POST", f"/v1/approvals/{created['id']}/entries", {},
|
||||
authorization="Bearer human")
|
||||
assert status == 200
|
||||
claim = engine.claim(created["id"])
|
||||
assert claim["binding"]["actor_consumes"] is True and claim["valid_now"]
|
||||
jsonschema.validate(claim, SCHEMA)
|
||||
|
||||
digest = created["binding"]["digest"]
|
||||
status, refused = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume",
|
||||
{"request_digest": digest}, authorization="Bearer other")
|
||||
assert status == 403, refused
|
||||
assert engine.get(created["id"]).status == "approved"
|
||||
|
||||
status, spent = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume",
|
||||
{"request_digest": digest}, authorization="Bearer actor")
|
||||
assert status == 200 and spent["status"] == "consumed" and spent["idempotent"] is False
|
||||
|
||||
# Another service gets no receipt from an idempotent retry either.
|
||||
status, _ = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume",
|
||||
{"request_digest": digest}, authorization="Bearer other")
|
||||
assert status == 403
|
||||
status, again = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume",
|
||||
{"request_digest": digest}, authorization="Bearer actor")
|
||||
assert status == 200 and again["idempotent"] is True
|
||||
|
||||
|
||||
def test_internal_call_without_consumer_fails_closed(engine):
|
||||
obj = _approved(engine, actor_consumes=True)
|
||||
with pytest.raises(Forbidden, match="actor_consumes"):
|
||||
engine.consume(obj.id, obj.binding_digest)
|
||||
assert engine.get(obj.id).status == "approved"
|
||||
assert engine.consume(obj.id, obj.binding_digest, consumer=_identity(ACTOR))["status"] == "consumed"
|
||||
|
||||
|
||||
def test_supersession_inherits_and_refuses_existing_mismatch(engine):
|
||||
parent = engine.create(binding(), validity(), actor_consumes=True)
|
||||
plain = engine.create(binding(), validity())
|
||||
with pytest.raises(Conflict, match="actor_consumes"):
|
||||
engine.supersede(parent.id, plain.id)
|
||||
assert engine.get(parent.id).status == "requested"
|
||||
child = engine.get(engine.supersede(parent.id)["successor_id"])
|
||||
assert child.actor_consumes is True
|
||||
assert engine.claim(child.id)["binding"]["actor_consumes"] is True
|
||||
engine.add_entry(child.id, "user:admin", principal_type="human")
|
||||
with pytest.raises(Forbidden):
|
||||
engine.consume(child.id, child.binding_digest, consumer=_identity("svc-other"))
|
||||
|
||||
|
||||
def test_declaration_is_on_audit_events_and_outside_the_act_digest(engine):
|
||||
plain = engine.create(binding(), validity())
|
||||
declared = engine.create(binding(), validity(), actor_consumes=True)
|
||||
assert plain.binding_digest == declared.binding_digest
|
||||
engine.add_entry(declared.id, "user:admin", principal_type="human")
|
||||
event = json.loads(engine._conn().execute(
|
||||
"SELECT payload_json FROM outbox WHERE approval_id=?", (declared.id,)).fetchone()[0])
|
||||
assert event["details"]["actor_consumes"] is True
|
||||
plain_event_rows = engine._conn().execute(
|
||||
"SELECT payload_json FROM outbox WHERE approval_id=?", (plain.id,)).fetchall()
|
||||
assert all("actor_consumes" not in json.loads(r[0])["details"] for r in plain_event_rows)
|
||||
|
||||
|
||||
def test_legacy_v7_rows_migrate_without_the_flag(tmp_path):
|
||||
path = tmp_path / "v7.sqlite"
|
||||
eng = Engine(path, clock=lambda: FROZEN)
|
||||
obj = eng.create(binding(), validity())
|
||||
eng.add_entry(obj.id, "user:admin", principal_type="human")
|
||||
eng._conn().execute("ALTER TABLE approvals DROP COLUMN actor_consumes")
|
||||
eng._conn().execute("PRAGMA user_version=7")
|
||||
eng._conn().commit()
|
||||
eng.close()
|
||||
upgraded = Engine(path, clock=lambda: FROZEN)
|
||||
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
|
||||
upgraded.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other"))
|
||||
upgraded.close()
|
||||
|
||||
|
||||
def test_human_control_refuses_the_actor_as_approver(engine):
|
||||
"""GH-DEC-2026-026 Ruling 6. A human actor may create a human_control
|
||||
request but may not approve it; another human may."""
|
||||
obj = engine.create(binding(actor="user:operator"), validity(), human_control=True)
|
||||
with pytest.raises(Forbidden, match="actor"):
|
||||
engine.add_entry(obj.id, "user:operator", principal_type="human")
|
||||
assert engine.get(obj.id).entries == []
|
||||
assert engine.add_entry(obj.id, "user:admin", principal_type="human").status == "approved"
|
||||
|
||||
|
||||
def test_actor_may_still_approve_without_human_control(engine):
|
||||
obj = engine.create(binding(actor="user:operator"), validity())
|
||||
assert engine.add_entry(obj.id, "user:operator", principal_type="human").status == "approved"
|
||||
|
||||
|
||||
def test_human_actor_self_approval_refused_over_http(engine):
|
||||
app = App(engine, StaticTokenAuthenticator({"op": _identity("user:operator", "human")}))
|
||||
status, created = call(app, "POST", "/v1/approvals", {
|
||||
"binding": binding(actor="user:operator"), "validity": validity(), "human_control": True,
|
||||
}, authorization="Bearer op")
|
||||
assert status == 201
|
||||
status, refused = call(app, "POST", f"/v1/approvals/{created['id']}/entries", {},
|
||||
authorization="Bearer op")
|
||||
assert status == 403, refused
|
||||
|
|
@ -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 == 7
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
|
||||
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 == 7
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
|
||||
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 == 7
|
||||
assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ flavor: implementation
|
|||
owner: approval-engine
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-31"
|
||||
updated: "2026-10-31"
|
||||
updated: "2026-11-01"
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "af365506-f451-50b5-a0a2-f0ba9a0a4d9c"
|
||||
---
|
||||
|
|
@ -46,30 +46,54 @@ contract in `docs/approval-claim.md`). The member is present only when
|
|||
declared, so undeclared claims keep their exact bytes for strict readers.
|
||||
Not released or deployed: a v7 image and a founder-run rollout are still needed.
|
||||
|
||||
## Opt-in approver and consumer correspondence
|
||||
## Opt-in consumer = actor (`actor_consumes`)
|
||||
|
||||
```task
|
||||
id: APPROVAL-WP-0007-T02
|
||||
status: wait
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "9657820d-23f9-54d9-920c-8f2a72110ad0"
|
||||
```
|
||||
|
||||
Proposal for owner and Gate House agreement before implementation, because
|
||||
it gives `principal` and `actor` an enforced meaning. Two optional issue
|
||||
flags:
|
||||
Optional issue flag `actor_consumes: true` on `POST /v1/approvals`: the
|
||||
consuming subject must equal `binding.actor`. Closes the denial-of-run gap
|
||||
(today any tenant service with `approval:consume` can spend the object). Adds
|
||||
no identity mapping, because the actor is already pinned to the creator's
|
||||
token subject. Serves GH-DEC-2026-026 build-mode relaxation B2.
|
||||
|
||||
- `principal_approves`: every entry subject must equal `binding.principal`.
|
||||
- `actor_consumes`: the consuming subject must equal `binding.actor`.
|
||||
Split from the original T02 by founder decision 2026-11-01, relayed by the
|
||||
net-kingdom coordinator: "Agree to split APPROVAL-WP-0007-T02: build
|
||||
actor_consumes now (opt-in at issue; the bounded executor will set it);
|
||||
principal_approves keeps waiting for the KeyCape subject mapping."
|
||||
`principal_approves` moved to T05.
|
||||
|
||||
Owner position 2026-10-31 (`docs/decision-services-alignment-answer.md`):
|
||||
split it. `actor_consumes` is ready to implement once agreed. `principal_approves`
|
||||
keeps waiting for the subject mapping and the S5 doctrine.
|
||||
Done 2026-11-01 in source (schema v8, `tests/test_actor_consumes.py`,
|
||||
contract in `docs/approval-claim.md`). Stored on the object, served in GET and
|
||||
the claim only when declared (undeclared claims keep their exact bytes),
|
||||
carried in audit `details`, inherited on supersession (existing-successor
|
||||
mismatch is 409). Consume refuses with 403 when the consuming subject differs,
|
||||
also on idempotent retries; an internal call without a consumer fails closed.
|
||||
Legacy rows migrate without the flag. Same commit: under `human_control`,
|
||||
`add_entry` refuses (403) the binding actor as approver (GH-DEC-2026-026
|
||||
Ruling 6); human_control alone already covered a service actor, this covers a
|
||||
human actor. Not released or deployed: a v8 image and a founder-run rollout
|
||||
are still needed.
|
||||
|
||||
Today any human with `approval:approve` can approve, and any tenant service
|
||||
with `approval:consume` can spend the object (a denial-of-run risk). Wait
|
||||
reason: semantic decision. NetKingdom's actor and principal strings must also
|
||||
match KeyCape subjects exactly.
|
||||
## Opt-in approver = principal (`principal_approves`)
|
||||
|
||||
```task
|
||||
id: APPROVAL-WP-0007-T05
|
||||
status: wait
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Optional issue flag `principal_approves: true`: every entry subject must equal
|
||||
`binding.principal`. It gives `principal` an enforced meaning, so it waits.
|
||||
Wait reasons: the KeyCape subject mapping (NetKingdom's principal strings must
|
||||
equal KeyCape subjects exactly) and the S5 doctrine. Split from T02 by the
|
||||
founder decision of 2026-11-01 quoted under T02. Until it lands, any human
|
||||
holding `approval:approve` for the tenant can approve, and the executor checks
|
||||
the entry subject itself (`docs/bounded-execution-admission-carrier.md` §3).
|
||||
|
||||
## Revoker identity in revocation evidence
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue