Enforce declared human controls at approval binding
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
a3c94fb241
commit
be1a388a84
17 changed files with 441 additions and 40 deletions
|
|
@ -124,6 +124,7 @@ class App:
|
|||
int(data.get("required_count") or 1),
|
||||
pdp_digest=data.get("pdp_digest"),
|
||||
pdp_path=bool(data.get("pdp_path", False)),
|
||||
human_control=data.get("human_control", False),
|
||||
approval_id=data.get("id"),
|
||||
)
|
||||
return 201, obj.as_dict()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from .binding import binding_digest, canonical_binding, require_digest
|
|||
from .errors import (
|
||||
Conflict,
|
||||
DuplicateApprover,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
StoreUnavailable,
|
||||
Unprocessable,
|
||||
|
|
@ -35,7 +36,7 @@ AUDIT_SCHEMA = "audit-core.event.v1alpha1"
|
|||
SOURCE = "approval-engine"
|
||||
SCOPE = "netkingdom-approvals"
|
||||
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
|
||||
LATEST_SCHEMA_VERSION = 4
|
||||
LATEST_SCHEMA_VERSION = 5
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS approvals (
|
||||
|
|
@ -57,6 +58,7 @@ CREATE TABLE IF NOT EXISTS approvals (
|
|||
consumed_decision_id TEXT,
|
||||
consumed_at TEXT,
|
||||
pdp_path INTEGER NOT NULL DEFAULT 0,
|
||||
human_control INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
|
@ -112,6 +114,7 @@ class Approval:
|
|||
binding_digest: str
|
||||
pdp_digest: str | None
|
||||
pdp_path: bool
|
||||
human_control: bool
|
||||
actor: str
|
||||
principal: str
|
||||
action: str
|
||||
|
|
@ -137,6 +140,7 @@ class Approval:
|
|||
"digest": self.binding_digest,
|
||||
"pdp_digest": self.pdp_digest,
|
||||
"pdp_path": self.pdp_path,
|
||||
"human_control": self.human_control,
|
||||
},
|
||||
"validity": {"not_before": self.not_before, "expires_at": self.expires_at},
|
||||
"required_count": self.required_count,
|
||||
|
|
@ -231,6 +235,14 @@ class Engine:
|
|||
conn.execute(
|
||||
"ALTER TABLE approvals ADD COLUMN pdp_path INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
if "human_control" not in approval_columns:
|
||||
# v5, GH-DEC-2026-016. Existing human entries are evidence of
|
||||
# their authors, not a declaration that the object discharged
|
||||
# a human control. Never infer that declaration on migration.
|
||||
conn.execute(
|
||||
"ALTER TABLE approvals ADD COLUMN human_control "
|
||||
"INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1))"
|
||||
)
|
||||
entry_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
|
||||
}
|
||||
|
|
@ -348,12 +360,15 @@ class Engine:
|
|||
*,
|
||||
pdp_digest: str | None = None,
|
||||
pdp_path: bool = False,
|
||||
human_control: bool = False,
|
||||
approval_id: str | None = None,
|
||||
) -> Approval:
|
||||
if required_count < 1:
|
||||
raise Unprocessable("required_count must be >= 1")
|
||||
if not isinstance(pdp_path, bool):
|
||||
raise Unprocessable("pdp_path must be a boolean")
|
||||
if not isinstance(human_control, bool):
|
||||
raise Unprocessable("human_control must be a boolean")
|
||||
canon = canonical_binding(binding)
|
||||
digest = binding_digest(canon)
|
||||
pdp = require_digest(pdp_digest)
|
||||
|
|
@ -382,8 +397,8 @@ 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, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
pdp_path, human_control, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
aid,
|
||||
"requested",
|
||||
|
|
@ -400,6 +415,7 @@ class Engine:
|
|||
required_count,
|
||||
None,
|
||||
1 if pdp_path else 0,
|
||||
1 if human_control else 0,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
|
|
@ -456,6 +472,7 @@ class Engine:
|
|||
binding_digest=row["binding_digest"],
|
||||
pdp_digest=row["pdp_digest"],
|
||||
pdp_path=bool(row["pdp_path"]),
|
||||
human_control=bool(row["human_control"]),
|
||||
actor=row["actor"],
|
||||
principal=row["principal"],
|
||||
action=row["action"],
|
||||
|
|
@ -511,6 +528,10 @@ class Engine:
|
|||
if row["status"] not in MUTABLE:
|
||||
conn.rollback()
|
||||
raise Conflict(f"cannot add entries in status {row['status']}")
|
||||
if row["human_control"] and principal_type != "human":
|
||||
# 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")
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO entries "
|
||||
|
|
@ -582,7 +603,11 @@ class Engine:
|
|||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
raise Conflict("supersession lost the compare-and-swap")
|
||||
existing = conn.execute("SELECT id FROM approvals WHERE id=?", (successor_id,)).fetchone()
|
||||
existing = conn.execute(
|
||||
"SELECT id, human_control 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")
|
||||
created_successor = False
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
|
|
@ -590,8 +615,8 @@ 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, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
pdp_path, human_control, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
successor_id,
|
||||
"requested",
|
||||
|
|
@ -608,6 +633,7 @@ class Engine:
|
|||
row["required_count"],
|
||||
None,
|
||||
row["pdp_path"],
|
||||
row["human_control"],
|
||||
now,
|
||||
now,
|
||||
),
|
||||
|
|
@ -720,6 +746,14 @@ class Engine:
|
|||
if self.now() < parse_iso(row["not_before"]) or self.now() >= parse_iso(row["expires_at"]):
|
||||
conn.rollback()
|
||||
raise Conflict("cannot consume outside validity window")
|
||||
if row["human_control"]:
|
||||
counts = conn.execute(
|
||||
"SELECT COUNT(*) AS total, "
|
||||
"SUM(CASE WHEN principal_type='human' THEN 1 ELSE 0 END) AS humans "
|
||||
"FROM entries WHERE approval_id=?", (approval_id,)
|
||||
).fetchone()
|
||||
if counts["total"] < row["required_count"] or counts["humans"] != counts["total"]:
|
||||
raise Conflict("cannot consume without the required human evidence")
|
||||
cur = conn.execute(
|
||||
"UPDATE approvals SET status='consumed', consumed_digest=?, "
|
||||
"consumed_decision_id=?, consumed_at=?, updated_at=? "
|
||||
|
|
@ -823,6 +857,11 @@ class Engine:
|
|||
details = {"class": event_class, **(extra or {})}
|
||||
if approval_id:
|
||||
details["approval_id"] = approval_id
|
||||
row = conn.execute(
|
||||
"SELECT human_control FROM approvals WHERE id=?", (approval_id,)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
details["human_control"] = bool(row["human_control"])
|
||||
payload = {
|
||||
"schema_version": AUDIT_SCHEMA,
|
||||
"event_id": event_id,
|
||||
|
|
@ -861,6 +900,7 @@ class Engine:
|
|||
# for the PDP path, so a consumer does not infer it from a digest that
|
||||
# happens to be present.
|
||||
binding["pdp_path"] = obj.pdp_path
|
||||
binding["human_control"] = obj.human_control
|
||||
return {
|
||||
"schema_version": CLAIM_SCHEMA,
|
||||
"kind": "approval-claim",
|
||||
|
|
@ -895,6 +935,8 @@ class Engine:
|
|||
if obj.status == "approved":
|
||||
if now < parse_iso(obj.not_before):
|
||||
return "approved", False, False, "not_yet_valid"
|
||||
if obj.human_control and any(e["principal_type"] != "human" for e in obj.entries):
|
||||
return "approved", False, False, "human_control_unsatisfied"
|
||||
if len({e["subject_id"] for e in obj.entries}) < obj.required_count:
|
||||
return "approved", False, False, "insufficient_approvers"
|
||||
return "valid", True, False, "ok"
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ pinned at `b51d174`. There is no placeholder left to replace. Both references
|
|||
MUST stay identical and MUST stay digests — a tag here would let the init
|
||||
container and the server run different code against one database.
|
||||
|
||||
**The pinned image predates schema v4.** The published artifact recorded in
|
||||
**The pinned image predates schema v5.** The published artifact recorded in
|
||||
[`docs/image-scan-2026-09-06.md`](../docs/image-scan-2026-09-06.md) carries
|
||||
`LATEST_SCHEMA_VERSION = 3`; this repository is now at 4
|
||||
(`entries.principal_type`, see [storage-operations.md](../docs/storage-operations.md)).
|
||||
`LATEST_SCHEMA_VERSION = 3`; this repository is now at 5
|
||||
(`entries.principal_type` plus `approvals.human_control`, see [storage-operations.md](../docs/storage-operations.md)).
|
||||
The pinned pair is self-consistent — that image migrates to 3 and serves 3 — so
|
||||
nothing is broken by leaving it pinned, but a rollout that must carry approver
|
||||
nothing is broken by leaving it pinned, but a rollout that must enforce declared human controls or carry approver
|
||||
principal-type evidence requires cutting a new image at step 3 below. The
|
||||
`migrate` init container then performs the additive upgrade on the existing
|
||||
volume; `tests/test_deploy_manifest.py` holds this acknowledgement so the drift
|
||||
|
|
|
|||
|
|
@ -347,3 +347,26 @@ false, and the example set is what would have taught them — the failure
|
|||
|
||||
`tests/test_examples.py` asserts the decorrelation, not merely that both values
|
||||
appear somewhere.
|
||||
|
||||
|
||||
### Declared human judgment — `binding.human_control`
|
||||
|
||||
New producers always state this boolean. `true` records that the requester
|
||||
explicitly declared a human-in-the-loop or dual-control requirement at issue;
|
||||
`valid_now: true` then also requires the declared count of distinct verified
|
||||
human approvers. Non-human binds are refused before insertion. Undeclared objects
|
||||
remain useful for service approvals. Historical absence is undeclared, never
|
||||
proof of human judgment; a consumer needing the property must require exactly
|
||||
true, rather than infer it from an approver's name or from a valid generic claim.
|
||||
|
||||
The declaration stays separate from the five act fields and does not change the
|
||||
native binding digest. It is inherited on supersession, emitted on audit events,
|
||||
and cannot be downgraded by linking an existing successor. The additive claim
|
||||
property stays within schema 0.1; old consumers that do not require this property
|
||||
retain their existing behavior. Consumer adoption of the new requirement and
|
||||
native issuer/deployment proof remain necessary before the factory human path.
|
||||
|
||||
`claim.valid-human-control.json` shows a valid declared human control;
|
||||
`claim.valid.json` shows a valid ordinary approval. For an inconsistent persisted
|
||||
human-control object, `valid_now` is false with `human_control_unsatisfied` and
|
||||
consume refuses. Neither the declaration nor a claim is an authorization verdict.
|
||||
|
|
|
|||
|
|
@ -53,22 +53,21 @@ the deployed issuer's `/jwks` (RS256) and carry:
|
|||
The **access** token is what we validate. `id_token` appears nowhere in this
|
||||
codebase; the ID token belongs to the login client and is never evidence here.
|
||||
|
||||
**Correction to our 2026-09-09 message.** We said an agent token cannot hold
|
||||
`approval:approve` under the requested registrations. That is wrong twice: the
|
||||
`approval-engine-operator` service client holds `approval:approve`, and this
|
||||
engine does not restrict `/entries` by principal type at all — only `/consume`
|
||||
is restricted, to `service`/`agent`. So a non-human principal can supply
|
||||
approver evidence today.
|
||||
**GH-DEC-2026-016 is now implemented in the source candidate.** An approval
|
||||
explicitly declared with `human_control: true` refuses service/agent binds at
|
||||
`/entries`, irrespective of what the caller submits in the body. The verified
|
||||
human identity supplies the entry. The UI retains its own humans-bind-agents-draft
|
||||
rule and must require the declared human-control object for that workflow; an
|
||||
undeclared historical approval must not be relabelled from its human entries.
|
||||
Request a new declared object when needed. The requester may be a service; it
|
||||
creates an unapproved request and cannot supply human judgment.
|
||||
|
||||
Whether it *should* be able to is approval doctrine and belongs to
|
||||
`gate-house`, not to us and not to you. What we have done instead is make it
|
||||
visible: schema v4 records the verified `principal_type` on every entry
|
||||
(`tests/test_auth.py::test_entry_records_the_verified_principal_type`,
|
||||
`::test_non_human_approver_is_recorded_as_such`). Your design principle 10
|
||||
("humans bind, agents draft") is therefore enforceable in the evidence chain
|
||||
rather than assumed — read `entries[].principal_type` and do not infer the
|
||||
answer from the shape of `subject_id`. Entries written before v4 are `null` and
|
||||
must not be read as `human`.
|
||||
The engine still admits service-to-service approvals when no human control was
|
||||
declared. The flag neither grants a scope nor identifies which acts require a
|
||||
human; those remain owner doctrine. See [caller-authentication.md](caller-authentication.md)
|
||||
for the issuer's human-type provenance and remaining native acceptance. The
|
||||
source candidate requires schema v5 and a new image; the current production
|
||||
manifest is not evidence that this enforcement is deployed.
|
||||
|
||||
## 3. Open question A — the human client cannot read the approval it renders
|
||||
|
||||
|
|
|
|||
|
|
@ -47,15 +47,37 @@ Create additionally requires `binding.actor == sub`. Approval-entry subject,
|
|||
assurance, evidence reference, and **principal type** are derived from the
|
||||
verified JWT, never the request body.
|
||||
|
||||
`principal_type` is recorded on the entry (schema v4) because `subject_id`
|
||||
alone cannot answer what kind of principal bound the approval — `user:alice` is
|
||||
a naming convention, not a verified claim. This engine does not restrict
|
||||
`/entries` to human principals: whether a service or agent may supply approver
|
||||
evidence is approval doctrine and belongs to `gate-house`, and the
|
||||
`approval-engine-operator` registration holds `approval:approve` today. What
|
||||
this engine owes is that the evidence chain says which it was. Entries written
|
||||
before v4 stay `null` rather than being back-filled into a claim nobody made. KeyCape owns client registration and scope grants; approval-engine
|
||||
only verifies and enforces them. Requested registrations are:
|
||||
`principal_type` is recorded from verified identity (since schema v4). Schema v5
|
||||
implements GH-DEC-2026-016: when an approval declares `human_control: true`,
|
||||
`/entries` refuses a service, agent or unknown principal with 403 before inserting
|
||||
an entry or emitting issuance. The request body cannot supply the approver type
|
||||
or downgrade the declaration. Undeclared approvals retain service-to-service use;
|
||||
a human entry does not retroactively declare a human control.
|
||||
|
||||
**Requesting and binding are separate operations.** `POST /v1/approvals` creates
|
||||
an unapproved request and records the strict boolean declaration. A service or
|
||||
agent with `approval:create` may draft that request. The principal contributing
|
||||
judgment is known only at `POST /entries`; this is the bind/issue boundary where
|
||||
GH-DEC-2026-016's non-human refusal applies. A draft is never a valid approval.
|
||||
No statement about the approver is inferred from `binding.principal` or the
|
||||
requesting actor's type. Dual control also needs its declared `required_count`;
|
||||
human control alone does not imply two approvers or decide which acts need it.
|
||||
|
||||
**Identity provenance:** reviewed KeyCape source
|
||||
`f9812ab3b2bfe8f0817185f44071e612264ec3ee:src/internal/server/oidc/token.go`
|
||||
sets `principal_type=human` only after consuming a client/redirect-bound PKCE
|
||||
session and looking up the current user. Its separate client-credentials path
|
||||
sets `service`; no registration field supplies a human principal type. This is
|
||||
different from registration-supplied tenant routing. Production acceptance must
|
||||
pin and prove that issuer behavior with a real human flow; signed fixtures here
|
||||
prove engine enforcement, not native identity admission. If an issuer introduces
|
||||
a registration-supplied route to `human`, GH-DEC-2026-016 §5 requires independent
|
||||
provenance and refusal of that route before admitting it. A consumer must not
|
||||
relax the identity contract simply because the JWT verifies.
|
||||
|
||||
Entries written before v4 stay null; pre-v5 objects have `human_control: false`.
|
||||
KeyCape owns client registration and scope grants; approval-engine verifies and
|
||||
enforces them. Requested registrations are:
|
||||
|
||||
- audience/resource server `approval-engine` with the scopes above;
|
||||
- the secrets-engine PEP service client with `approval:read` and
|
||||
|
|
|
|||
47
docs/evidence/2026-09-10-human-control.json
Normal file
47
docs/evidence/2026-09-10-human-control.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"status": "source-tests-passed",
|
||||
"ruling": "GH-DEC-2026-016",
|
||||
"schema_version": 5,
|
||||
"new_cases_failed_before": 22,
|
||||
"new_cases_passed_after": 22,
|
||||
"full_suite_passed": 152,
|
||||
"tests": "tests/test_human_control.py",
|
||||
"semantics": {
|
||||
"create": "Records declared intent on an unapproved request; service/agent requesters remain permitted under approval:create",
|
||||
"bind": "Verified human required before insertion/issuance for declared human controls",
|
||||
"legacy": "False, no inference from existing human entries",
|
||||
"supersession": "Declaration inherited; mismatched existing successor conflicts atomically",
|
||||
"identity": "RS256/JWKS verifies principal_type; entry body cannot spoof it",
|
||||
"issuer_contract_source": "f9812ab3b2bfe8f0817185f44071e612264ec3ee:src/internal/server/oidc/token.go",
|
||||
"persistence_proof": "Temporary on-disk v4 database migrated to v5; preserved records/digests",
|
||||
"outbox": "Human bind and issuance roll back together",
|
||||
"act_digest": "Unchanged five-field digest"
|
||||
},
|
||||
"consumer_compatibility": {
|
||||
"status": "passed",
|
||||
"cases": [
|
||||
{
|
||||
"human_control": false,
|
||||
"existing_consumer_accepts_additive_field": true
|
||||
},
|
||||
{
|
||||
"human_control": true,
|
||||
"existing_consumer_accepts_additive_field": true
|
||||
}
|
||||
],
|
||||
"native_calls": 0,
|
||||
"human_control_required_by_existing_consumer": false,
|
||||
"limit": "This proves wire compatibility only. The pilot requester/PEP must explicitly declare and require human_control; current consumer admission is not changed."
|
||||
},
|
||||
"native_issuer_proof": false,
|
||||
"production_migration": false,
|
||||
"factory_attempts": 0,
|
||||
"residual_records": [
|
||||
"APPROVAL-WP-0002-T01",
|
||||
"APPROVAL-WP-0002-T03",
|
||||
"APPROVAL-WP-0002-T05",
|
||||
"SECRETS-WP-0009-T03",
|
||||
"INFD-WP-0001-T08",
|
||||
"HFACT-WP-0001-T01/T03/T04/T05"
|
||||
]
|
||||
}
|
||||
|
|
@ -60,3 +60,21 @@ Downgrade is not supported: an older server refuses the store on the version
|
|||
check rather than reading the column, which is the intended direction. Restore
|
||||
from a v4 backup onto a v3 release requires re-pinning forward, not editing
|
||||
`user_version`.
|
||||
|
||||
|
||||
## Schema v5 — explicit human-control declaration
|
||||
|
||||
GH-DEC-2026-016 adds `approvals.human_control`, constrained to 0 or 1, with default
|
||||
0. Existing objects remain undeclared regardless of recorded approver types;
|
||||
there is no retrospective declaration or change to their five-field binding
|
||||
digest. A successor inherits the declaration. Linking an existing successor with
|
||||
a different declaration conflicts and rolls back the parent transition.
|
||||
|
||||
New human-control entries require verified type human at the engine boundary.
|
||||
The declaration is retained on object/claim and audit events. Claim and consume
|
||||
also refuse inconsistent persisted human evidence. Tests cover a persistent v4
|
||||
upgrade and preservation, signed API refusal, quorum and outbox rollback. Take
|
||||
and verify the existing backup before migration. The old v3 image must not serve
|
||||
a v5 database; rollback requires the matching pre-migration backup and the
|
||||
existing single-writer recovery procedure. No live database is migrated by the
|
||||
source test run.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
"purpose": "rotate-exposed-key",
|
||||
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"pdp_digest": null,
|
||||
"pdp_path": false
|
||||
"pdp_path": false,
|
||||
"human_control": false
|
||||
},
|
||||
"freshness": {
|
||||
"observed_at": "2026-08-29T12:05:00+00:00",
|
||||
|
|
|
|||
34
examples/claim.valid-human-control.json
Normal file
34
examples/claim.valid-human-control.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"yields_to": "net-kingdom taxonomy request-claim schema (statute \u00a717; unassigned)",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "f7e5aefe-3136-4cd7-9445-5118a3c70b1c",
|
||||
"state": "valid",
|
||||
"valid_now": true,
|
||||
"consumed": false,
|
||||
"binding": {
|
||||
"action": "secrets.kv.destroy",
|
||||
"target": {
|
||||
"id": "lane-openbao-root",
|
||||
"stage": "prod"
|
||||
},
|
||||
"actor": "agt-secrets-engine",
|
||||
"principal": "bernd",
|
||||
"purpose": "rotate-exposed-key",
|
||||
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
|
||||
"pdp_path": true,
|
||||
"human_control": true
|
||||
},
|
||||
"freshness": {
|
||||
"observed_at": "2026-08-29T12:00:00+00:00",
|
||||
"ttl_seconds": 30,
|
||||
"not_after": "2026-08-29T12:00:30+00:00"
|
||||
},
|
||||
"validity": {
|
||||
"not_before": "2026-08-29T11:00:00+00:00",
|
||||
"expires_at": "2026-08-29T15:00:00+00:00"
|
||||
},
|
||||
"reason_code": "ok"
|
||||
}
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
"purpose": "rotate-exposed-key",
|
||||
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
|
||||
"pdp_path": true
|
||||
"pdp_path": true,
|
||||
"human_control": false
|
||||
},
|
||||
"freshness": {
|
||||
"observed_at": "2026-08-29T12:00:00+00:00",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"yields_to": "net-kingdom taxonomy request-claim schema (statute §17; unassigned)",
|
||||
"yields_to": "net-kingdom taxonomy request-claim schema (statute \u00a717; unassigned)",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "7c4e2b91-08da-4f63-b5c7-2a9e6d1f04b3",
|
||||
"state": "valid",
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
"purpose": "cut-0-1-0-release",
|
||||
"digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pdp_digest": null,
|
||||
"pdp_path": false
|
||||
"pdp_path": false,
|
||||
"human_control": false
|
||||
},
|
||||
"freshness": {
|
||||
"observed_at": "2026-08-29T12:00:00+00:00",
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@
|
|||
"expired",
|
||||
"revoked",
|
||||
"superseded",
|
||||
"consumed"
|
||||
"consumed",
|
||||
"human_control_unsatisfied"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -152,6 +153,10 @@
|
|||
"pdp_path": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this approval was declared at issue for the GH-DEC-2026-003 PDP consumption path. GH-DEC-2026-008 requires pdp_digest on that path, so this engine refuses to create a pdp_path approval without one; a true value therefore guarantees pdp_digest is non-null. A consumer on that path MUST require pdp_path true and MUST NOT infer path intent from a pdp_digest that merely happens to be present."
|
||||
},
|
||||
"human_control": {
|
||||
"type": "boolean",
|
||||
"description": "Declared at issue under GH-DEC-2026-016: this object discharges a human-in-the-loop or dual-control requirement. New producers always state it; absence in historical claims is undeclared, never proof of human control. True with valid_now means the required distinct approvers are verified human principals. It is separate from the five-field act digest. Consumers requiring human control must require exactly true; they must not infer it from approver identity or count."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -216,7 +216,8 @@ def test_v3_entries_migrate_to_v4_without_inventing_a_principal_type():
|
|||
eng.close()
|
||||
|
||||
upgraded = Engine(path, clock=lambda: FROZEN)
|
||||
assert int(upgraded._conn().execute("PRAGMA user_version").fetchone()[0]) == 4
|
||||
from approval_engine.store import LATEST_SCHEMA_VERSION
|
||||
assert int(upgraded._conn().execute("PRAGMA user_version").fetchone()[0]) == LATEST_SCHEMA_VERSION
|
||||
survivor = upgraded.get(obj.id)
|
||||
assert survivor.status == "approved"
|
||||
assert survivor.entries[0]["subject_id"] == "user:alice"
|
||||
|
|
|
|||
|
|
@ -96,3 +96,12 @@ def test_pdp_path_examples_always_carry_a_digest(path):
|
|||
binding = json.loads(path.read_text())["binding"]
|
||||
if binding["pdp_path"]:
|
||||
assert binding["pdp_digest"] is not None
|
||||
|
||||
|
||||
def test_valid_examples_distinguish_declared_human_control_from_ordinary_approval():
|
||||
controls = {
|
||||
c["binding"]["human_control"]
|
||||
for c in (json.loads(p.read_text()) for p in EXAMPLES)
|
||||
if c["valid_now"]
|
||||
}
|
||||
assert controls == {True, False}
|
||||
|
|
|
|||
168
tests/test_human_control.py
Normal file
168
tests/test_human_control.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""GH-DEC-2026-016: declared human judgment cannot be supplied by a service."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from approval_engine.api import App, call
|
||||
from approval_engine.auth import JWTAuthenticator
|
||||
from approval_engine.errors import Conflict, Forbidden, StoreUnavailable, Unprocessable
|
||||
from approval_engine.store import Engine, LATEST_SCHEMA_VERSION
|
||||
from tests.conftest import FROZEN, binding, validity
|
||||
from tests.test_auth import _jwt, _JWKS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signed_app(engine):
|
||||
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
app = App(engine, JWTAuthenticator(
|
||||
issuer="https://keycape.example", audience="approval-engine",
|
||||
jwks_url="https://keycape.example/jwks", jwks_client=_JWKS(private.public_key()),
|
||||
))
|
||||
def bearer(principal_type, subject="service:secrets-engine"):
|
||||
return "Bearer " + _jwt(private, principal_type=principal_type, sub=subject,
|
||||
tenant="tenant:platform", scope="approval:create approval:approve approval:read")
|
||||
return app, bearer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("principal_type", ["service", "agent"])
|
||||
def test_signed_nonhuman_cannot_bind_declared_human_control(engine, signed_app, principal_type):
|
||||
app, bearer = signed_app
|
||||
status, created = call(app, "POST", "/v1/approvals", {
|
||||
"binding": binding(actor="service:secrets-engine"), "validity": validity(),
|
||||
"human_control": True,
|
||||
}, authorization=bearer("service"))
|
||||
assert status == 201 # A machine may request a human's judgment, not supply it.
|
||||
status, refused = call(app, "POST", f"/v1/approvals/{created['id']}/entries", {
|
||||
"principal_type": "human", "subject_id": "forged-human", "human_control": False,
|
||||
}, authorization=bearer(principal_type))
|
||||
assert status == 403, refused
|
||||
obj = engine.get(created["id"])
|
||||
assert obj.status == "requested" and obj.entries == []
|
||||
assert engine._conn().execute("SELECT COUNT(*) FROM outbox").fetchone()[0] == 0
|
||||
|
||||
status, approved = call(app, "POST", f"/v1/approvals/{created['id']}/entries", {},
|
||||
authorization=bearer("human", "user:reviewer"))
|
||||
assert status == 200 and approved["status"] == "approved"
|
||||
assert approved["binding"]["human_control"] is True
|
||||
assert approved["entries"][0]["subject_id"] == "user:reviewer"
|
||||
assert approved["entries"][0]["principal_type"] == "human"
|
||||
claim = engine.claim(created["id"])
|
||||
assert claim["valid_now"] and claim["binding"]["human_control"] is True
|
||||
schema = json.loads((Path(__file__).parents[1]/"schemas/approval_claim.schema.json").read_text())
|
||||
jsonschema.validate(claim, schema)
|
||||
event = json.loads(engine._conn().execute("SELECT payload_json FROM outbox").fetchone()[0])
|
||||
assert event["details"]["human_control"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", "true", 0, 1, None, [], {}])
|
||||
def test_declaration_is_a_boolean_not_truthiness(app, engine, value):
|
||||
status, _ = call(app, "POST", "/v1/approvals", {
|
||||
"binding": binding(), "validity": validity(), "human_control": value,
|
||||
})
|
||||
assert status == 422
|
||||
assert engine._conn().execute("SELECT COUNT(*) FROM approvals").fetchone()[0] == 0
|
||||
with pytest.raises(Unprocessable, match="human_control"):
|
||||
engine.create(binding(), validity(), human_control=value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("principal_type", [None, "service", "agent", "human ", "HUMAN"])
|
||||
def test_store_itself_refuses_nonhuman_binding(engine, principal_type):
|
||||
obj = engine.create(binding(), validity(), human_control=True)
|
||||
with pytest.raises(Forbidden, match="human"):
|
||||
engine.add_entry(obj.id, "caller", principal_type=principal_type)
|
||||
assert engine.get(obj.id).entries == []
|
||||
|
||||
|
||||
def test_undeclared_approvals_remain_usable_for_services_and_do_not_infer_human_control(engine):
|
||||
for principal_type in ("service", "human", None):
|
||||
obj = engine.create(binding(), validity())
|
||||
engine.add_entry(obj.id, "caller", principal_type=principal_type)
|
||||
assert engine.claim(obj.id)["valid_now"] is True
|
||||
assert engine.claim(obj.id)["binding"]["human_control"] is False
|
||||
|
||||
|
||||
def test_two_humans_are_required_for_a_declared_dual_control(engine):
|
||||
obj = engine.create(binding(), validity(), required_count=2, human_control=True)
|
||||
engine.add_entry(obj.id, "first", principal_type="human")
|
||||
assert not engine.claim(obj.id)["valid_now"]
|
||||
with pytest.raises(Forbidden):
|
||||
engine.add_entry(obj.id, "service", principal_type="service")
|
||||
engine.add_entry(obj.id, "second", principal_type="human")
|
||||
assert engine.claim(obj.id)["valid_now"]
|
||||
|
||||
|
||||
def test_supersession_inherits_declaration_and_refuses_existing_downgrade_atomically(engine):
|
||||
parent = engine.create(binding(), validity(), human_control=True)
|
||||
weaker = engine.create(binding(), validity())
|
||||
with pytest.raises(Conflict, match="human_control"):
|
||||
engine.supersede(parent.id, weaker.id)
|
||||
assert engine.get(parent.id).status == "requested"
|
||||
assert engine.get(parent.id).superseded_by is None
|
||||
assert engine._conn().execute("SELECT COUNT(*) FROM outbox").fetchone()[0] == 0
|
||||
result = engine.supersede(parent.id)
|
||||
child = engine.get(result["successor_id"])
|
||||
assert child.human_control is True and child.entries == []
|
||||
with pytest.raises(Forbidden):
|
||||
engine.add_entry(child.id, "service", principal_type="service")
|
||||
|
||||
|
||||
def test_existing_successor_must_retain_the_same_declaration(engine):
|
||||
parent = engine.create(binding(), validity(), human_control=True)
|
||||
matching = engine.create(binding(), validity(), human_control=True)
|
||||
result = engine.supersede(parent.id, matching.id)
|
||||
assert result["successor_created"] is False
|
||||
assert engine.get(matching.id).human_control is True
|
||||
undeclared = engine.create(binding(), validity())
|
||||
with pytest.raises(Conflict, match="human_control"):
|
||||
engine.supersede(undeclared.id, matching.id)
|
||||
|
||||
|
||||
def test_human_bind_and_outbox_still_roll_back_together(engine):
|
||||
obj = engine.create(binding(), validity(), human_control=True)
|
||||
engine.fail_outbox = True
|
||||
with pytest.raises(StoreUnavailable):
|
||||
engine.add_entry(obj.id, "human", principal_type="human")
|
||||
engine.fail_outbox = False
|
||||
assert engine.get(obj.id).entries == [] and engine.get(obj.id).status == "requested"
|
||||
|
||||
|
||||
def test_legacy_v4_rows_are_not_reclassified_from_human_entries(tmp_path):
|
||||
path = tmp_path/"v4.sqlite"
|
||||
eng = Engine(path, clock=lambda: FROZEN)
|
||||
obj = eng.create(binding(), validity())
|
||||
eng.add_entry(obj.id, "human", principal_type="human")
|
||||
digest = obj.binding_digest
|
||||
eng._conn().execute("ALTER TABLE approvals DROP COLUMN human_control")
|
||||
eng._conn().execute("PRAGMA user_version=4")
|
||||
eng._conn().commit(); eng.close()
|
||||
upgraded = Engine(path, clock=lambda: FROZEN)
|
||||
survivor = upgraded.get(obj.id)
|
||||
assert survivor.status == "approved" and survivor.binding_digest == digest
|
||||
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 == 5
|
||||
upgraded.close()
|
||||
|
||||
|
||||
def test_invalid_persisted_human_evidence_cannot_be_claimed_or_consumed(engine):
|
||||
obj = engine.create(binding(), validity(), human_control=True)
|
||||
engine.add_entry(obj.id, "human", principal_type="human")
|
||||
# Simulate an inconsistent persisted row from a faulty writer. This is not
|
||||
# a defence against a compromised database owner, which remains trusted.
|
||||
engine._conn().execute("UPDATE entries SET principal_type='service' WHERE approval_id=?", (obj.id,))
|
||||
engine._conn().commit()
|
||||
claim = engine.claim(obj.id)
|
||||
assert not claim["valid_now"] and claim["reason_code"] == "human_control_unsatisfied"
|
||||
with pytest.raises(Conflict, match="human"):
|
||||
engine.consume(obj.id, obj.binding_digest)
|
||||
assert engine.get(obj.id).status == "approved"
|
||||
|
||||
|
||||
def test_control_declaration_does_not_change_the_five_field_act_digest(engine):
|
||||
ordinary = engine.create(binding(), validity())
|
||||
controlled = engine.create(binding(), validity(), human_control=True)
|
||||
assert ordinary.binding_digest == controlled.binding_digest
|
||||
|
|
@ -246,6 +246,35 @@ return changes neither existing rows nor production admission. The new rule is
|
|||
not satisfied merely by the already-recorded principal_type field. The separate
|
||||
R3 digest-exclusion condition is completed in T06 below.
|
||||
|
||||
### Declared human-control implementation — 2026-09-10
|
||||
|
||||
GH-DEC-2026-016 source implementation now records strict `human_control` intent
|
||||
at request creation and refuses non-human binding at `add_entry` / `/entries`.
|
||||
The creator may still be a service drafting a request; approval judgment comes
|
||||
from the verified entry identity. This maps the ruling's bind/issue boundary to
|
||||
this engine's two-step API and does not confer approval on an unapproved draft.
|
||||
Schema v5 defaults legacy rows to undeclared, preserves act digests and recorded
|
||||
entries, and inherits the declaration on supersession. An existing successor
|
||||
with a mismatched declaration conflicts atomically. Spoofed request-body identity
|
||||
cannot change the verified JWT type; invalid persisted human evidence also fails
|
||||
claim/consume. The outbox retains the declaration with its existing atomic write.
|
||||
|
||||
All 22 new cases fail on the previous implementation and pass after correction;
|
||||
the full suite passes 152 tests. Persistent v4 migration, RS256/JWKS HTTP calls,
|
||||
ordinary service compatibility, human quorum, rollback and successor cases are
|
||||
included. Actual Secrets Engine claim parsing accepts both new flag values;
|
||||
this is wire compatibility, not adoption of the new requirement. KeyCape's
|
||||
current source fixes human type on its PKCE/user path and service type on its
|
||||
client-credentials path; native issuer/human proof is still outstanding.
|
||||
|
||||
T01 remains progress for exact requester/human registration and native proof.
|
||||
T03 owns the matching schema-v5 image and admitted deployment. T05 with
|
||||
SECRETS-WP-0009-T03 and INFD-WP-0001-T08 must explicitly declare and require
|
||||
human_control for the factory human-approval workflow; old generic approvals
|
||||
must not be treated as satisfying it. Evidence:
|
||||
`docs/evidence/2026-09-10-human-control.json`. No production approval or database
|
||||
migration occurred during these tests.
|
||||
|
||||
## Harden durable storage and migrations
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue