APPROVAL-WP-0007-T01 done: required_assurance floor; executor ingress; alignment answer

- Optional required_assurance {"level": aal1|aal2|aal3} at issue, schema v7.
  Enforced at add_entry (403), claim (assurance_unsatisfied) and consume (409)
  from the verified token level only; inherited on supersession; legacy rows
  NULL. Served only when declared so undeclared claims keep exact bytes.
- deploy/networkpolicies.yaml: pod-scoped ingress for sso +
  app.kubernetes.io/name=bounded-executor on 8080 (hub 39a3bffa). Not applied.
- docs/decision-services-alignment-answer.md: owner answer to the NetKingdom
  decision services review (hub 81cafbf3): roles accepted, T02 split proposed,
  presentation_ref at create declined with an entry-level counter-proposal.

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:
tegwick 2026-10-31 23:07:05 +01:00
parent e5f31477d4
commit e9e9f93317
14 changed files with 540 additions and 13 deletions

View file

@ -133,6 +133,7 @@ class App:
pdp_digest=data.get("pdp_digest"),
pdp_path=bool(data.get("pdp_path", False)),
human_control=data.get("human_control", False),
required_assurance=data.get("required_assurance"),
approval_id=data.get("id"),
)
return 201, obj.as_dict()

View file

@ -43,7 +43,47 @@ 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 = 6
LATEST_SCHEMA_VERSION = 7
# 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")
def assurance_rank(level: Any) -> int | None:
if isinstance(level, str) and level in ASSURANCE_LEVELS:
return ASSURANCE_LEVELS.index(level)
return None
def require_assurance_floor(value: Any) -> str | None:
"""Validate an issue-time `required_assurance` and return its level.
The shape mirrors the token's `assurance` claim: `{"level": "aal2"}`.
Absent or null means no floor (the pre-v7 behavior).
"""
if value is None:
return None
if not isinstance(value, dict) or set(value) != {"level"}:
raise Unprocessable('required_assurance must be {"level": "aal1|aal2|aal3"}')
if assurance_rank(value["level"]) is None:
raise Unprocessable("required_assurance.level must be one of aal1, aal2, aal3")
return value["level"]
def entry_meets_floor(assurance: str | None, floor: str | None) -> bool:
"""Whether a stored entry's verified assurance meets `floor`.
`assurance` is the JSON the HTTP boundary stored from the verified token.
A missing, unparsable or unknown level fails a declared floor.
"""
if floor is None:
return True
try:
level = json.loads(assurance).get("level") if assurance else None
except (TypeError, ValueError, AttributeError):
level = None
rank = assurance_rank(level)
return rank is not None and rank >= assurance_rank(floor)
SCHEMA = """
CREATE TABLE IF NOT EXISTS approvals (
@ -66,6 +106,7 @@ CREATE TABLE IF NOT EXISTS approvals (
consumed_at TEXT,
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')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -123,6 +164,7 @@ class Approval:
pdp_digest: str | None
pdp_path: bool
human_control: bool
required_assurance: str | None
actor: str
principal: str
action: str
@ -157,6 +199,10 @@ class Approval:
"created_at": self.created_at,
"updated_at": self.updated_at,
}
if self.required_assurance:
# 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.consumed_digest:
result["consumption"] = {
"request_digest": self.consumed_digest,
@ -251,6 +297,14 @@ class Engine:
"ALTER TABLE approvals ADD COLUMN human_control "
"INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1))"
)
if "required_assurance" not in approval_columns:
# v7, APPROVAL-WP-0007-T01. Legacy rows stay NULL: no floor was
# declared at their issue, and inferring one from the entries'
# recorded levels would manufacture a declaration nobody made.
conn.execute(
"ALTER TABLE approvals ADD COLUMN required_assurance TEXT CHECK "
"(required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3'))"
)
entry_columns = {
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
}
@ -372,6 +426,7 @@ class Engine:
pdp_digest: str | None = None,
pdp_path: bool = False,
human_control: bool = False,
required_assurance: dict[str, Any] | None = None,
approval_id: str | None = None,
) -> Approval:
if required_count < 1:
@ -380,6 +435,7 @@ class Engine:
raise Unprocessable("pdp_path must be a boolean")
if not isinstance(human_control, bool):
raise Unprocessable("human_control must be a boolean")
floor = require_assurance_floor(required_assurance)
canon = canonical_binding(binding)
digest = binding_digest(canon)
pdp = require_digest(pdp_digest)
@ -408,8 +464,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, human_control, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
pdp_path, human_control, required_assurance, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
aid,
"requested",
@ -427,6 +483,7 @@ class Engine:
None,
1 if pdp_path else 0,
1 if human_control else 0,
floor,
now,
now,
),
@ -485,6 +542,7 @@ class Engine:
pdp_digest=row["pdp_digest"],
pdp_path=bool(row["pdp_path"]),
human_control=bool(row["human_control"]),
required_assurance=row["required_assurance"],
actor=row["actor"],
principal=row["principal"],
action=row["action"],
@ -550,6 +608,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 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.
raise Forbidden(
f"required_assurance {row['required_assurance']} not met by the approver's verified assurance"
)
try:
conn.execute(
"INSERT INTO entries "
@ -654,10 +718,13 @@ class Engine:
conn.rollback()
raise Conflict("supersession lost the compare-and-swap")
existing = conn.execute(
"SELECT id, human_control FROM approvals WHERE id=?", (successor_id,)
"SELECT id, human_control, required_assurance 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")
created_successor = False
if existing is None:
conn.execute(
@ -665,8 +732,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, human_control, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
pdp_path, human_control, required_assurance, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
successor_id,
"requested",
@ -684,6 +751,7 @@ class Engine:
None,
row["pdp_path"],
row["human_control"],
row["required_assurance"],
now,
now,
),
@ -808,6 +876,14 @@ class Engine:
).fetchone()
if counts["total"] < row["required_count"] or counts["humans"] != counts["total"]:
raise Conflict("cannot consume without the required human evidence")
if row["required_assurance"]:
levels = conn.execute(
"SELECT assurance FROM entries WHERE approval_id=?", (approval_id,)
).fetchall()
if len(levels) < row["required_count"] or not all(
entry_meets_floor(e["assurance"], row["required_assurance"]) for e in levels
):
raise Conflict("cannot consume without entries meeting required_assurance")
cur = conn.execute(
"UPDATE approvals SET status='consumed', consumed_digest=?, "
"consumed_decision_id=?, consumed_at=?, updated_at=? "
@ -928,10 +1004,13 @@ class Engine:
if approval_id:
details["approval_id"] = approval_id
row = conn.execute(
"SELECT human_control FROM approvals WHERE id=?", (approval_id,)
"SELECT human_control, required_assurance 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"]}
payload = {
"schema_version": AUDIT_SCHEMA,
"event_id": event_id,
@ -977,6 +1056,11 @@ class Engine:
# happens to be present.
binding["pdp_path"] = obj.pdp_path
binding["human_control"] = obj.human_control
# APPROVAL-WP-0007-T01: stated only when a floor was declared. The
# access fact reader rejects unknown members, so undeclared claims keep
# their exact pre-v7 bytes; absence means no floor was declared.
if obj.required_assurance:
binding["required_assurance"] = {"level": obj.required_assurance}
return {
"schema_version": CLAIM_SCHEMA,
"kind": "approval-claim",
@ -1013,6 +1097,10 @@ class Engine:
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 obj.required_assurance and not all(
entry_meets_floor(e["assurance"], obj.required_assurance) for e in obj.entries
):
return "approved", False, False, "assurance_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"

View file

@ -27,6 +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.
The earlier September v5 release evidence remains historical at
[the image evidence](../docs/evidence/2026-09-10-human-control-image.json).

View file

@ -28,6 +28,35 @@ spec:
ports:
- {protocol: TCP, port: 8080}
---
# NetKingdom bounded executor (NK-WP-0056, APPROVAL-WP-0007; hub request
# 39a3bffa). One Job per admission in namespace sso, ServiceAccount
# nk-bind-executor. It GETs and consumes its own approval on 8080.
# Pod-scoped on purpose: namespaceSelector and podSelector sit in ONE `from`
# element, so both must match. Labelling all of sso as an approval-engine
# client would also admit KeyCape, Authelia and LLDAP. Network admission is
# not authority: the executor still needs its KeyCape token and scopes.
# Prepared in source; live apply is founder-run.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: approval-engine-bounded-executor
namespace: approval-engine
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: approval-engine
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sso
podSelector:
matchLabels:
app.kubernetes.io/name: bounded-executor
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:

View file

@ -397,3 +397,31 @@ native issuer/deployment proof remain necessary before the factory human path.
`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.
### Declared minimum approver assurance — `binding.required_assurance`
APPROVAL-WP-0007-T01, schema v7 store. `POST /v1/approvals` may declare
`"required_assurance": {"level": "aal1" | "aal2" | "aal3"}` (ordered
aal1 < aal2 < aal3). Any other shape is refused with 422. The level is stored
on the object, served in `GET` and the claim as `binding.required_assurance`,
carried on audit events in `details.required_assurance`, and inherited on
supersession (linking an existing successor with a different floor is a 409).
The member is **present only when declared**. Undeclared objects keep their
exact pre-v7 bytes, because strict readers (the access fact reader) reject
unknown members. Absence means no floor was declared; it is never evidence of
any level. A consumer that needs a floor must require the member with its
expected level and must not infer one from approver entries.
Enforcement uses only the verified token's `assurance.level` stored with the
entry, never a request body:
- `POST /entries` refuses (403) an entry whose level is weaker, missing or
unknown.
- The claim reports `valid_now: false` with `reason_code: assurance_unsatisfied`
if a persisted entry does not meet the floor.
- `consume` refuses (409) in the same case.
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.

View file

@ -100,7 +100,12 @@ supersession carries it forward. The level comes only from the verified token.
Whether KeyCape puts `aal2` into the `assurance` claim for a step-up login is
KeyCape's question (identity owner), not this engine's.
**Until T01 ships,** the executor's GET check (§3) must require that every
**Update (APPROVAL-WP-0007-T01, in source, not yet deployed):** the engine now
accepts `required_assurance: {"level": "aal2"}` at issue and enforces it at
entry, claim and consume (`docs/approval-claim.md`). The executor should issue
with it once the v7 image is live, and keep its own check until then.
**Until T01 is deployed,** the executor's GET check (§3) must require that every
entry has `principal_type: "human"` and `assurance.level == "aal2"`. Note that
this makes the executor the only enforcement point.

View file

@ -0,0 +1,115 @@
# Decision services alignment — Approval Engine owner answer
Status: owner answer to the NetKingdom review
`net-kingdom/docs/proposals/2026-10-31-decision-services-alignment-review.md`
(hub message `81cafbf3-8d54-44c6-9235-6f46e95aaf74`), 2026-10-31. Also answers
the pod-scoped ingress request (hub `39a3bffa-2c7c-4ca6-8a57-61e4ed94820f`).
This is an engine-owner technical position, not a founder decision. Nothing
here is deployed.
## Summary
| Item | Position |
| --- | --- |
| Roles (§4.1) | **Accepted.** No change of role is needed here |
| Present an existing approval (§4.2) | **Works on today's API.** Step 3 needs only `GET /v1/approvals/{id}` with `approval:read` |
| S6 / T01 aal2 floor | **Implemented in source** (schema v7). Not yet released or deployed |
| S6 / T02 `actor_consumes` | **Supported, still waiting.** Owner proposes splitting it from `principal_approves` |
| §6.3 `presentation_ref` at create | **Declined as proposed.** Counter-proposal: the link goes on the entry, from the presenter |
| Executor ingress | **Prepared in source:** `deploy/networkpolicies.yaml`, policy `approval-engine-bounded-executor`. Live apply is founder-run |
## 1. Roles
The proposal matches this engine's INTENT and SCOPE:
- The engine **keeps the object**: binding, entries, consume, supersede, revoke and outbox evidence.
- **Only the actor creates.** `POST /v1/approvals` already refuses unless `binding.actor` equals the token
subject. informed-decision stays a legitimate actor for approvals it uses itself.
- The engine **stays not-an-inbox**: get by id, no list and no notification. Discovery belongs to State Hub
(the index) and informed-decision (the surface).
For the §4.2 verify step, informed-decision reads everything it needs from one
`GET`: `status`, `entries`, `binding.human_control`, `binding.actor` and
`binding.digest`. It should also check `binding.required_assurance` (T01, once
deployed) when a C2 class requires aal2. With `human_control: true`, the engine
already refuses an entry posted under informed-decision's own service token.
The founder's entry must carry the founder's token, as it does today.
One addition to §4.2 step 3: compare `validity` as well, so a memo cannot
present an approval whose window has already passed or is far wider than the
packet says.
## 2. S6
**T01 (aal2 floor): done in source.**
- `POST /v1/approvals` accepts an optional `required_assurance: {"level": "aal1"|"aal2"|"aal3"}`.
- `POST /entries` refuses a weaker, missing or unknown level from the verified token.
- The claim reports `assurance_unsatisfied`, and `consume` refuses a persisted entry below the floor.
- The floor is inherited on supersession and is not part of the act digest.
- Legacy rows migrate to NULL, meaning no floor.
The member appears in GET and the claim **only when declared**. The access fact
reader rejects unknown members, so undeclared claims keep their exact bytes.
Contract: `docs/approval-claim.md`. It needs a v7 image release and a
founder-run rollout with a verified pre-v7 backup. Until then the executor keeps
its own aal2 check. Whether KeyCape issues `aal2` stays KeyCape's question.
**T02 (correspondence): supported, not implemented.** The owner proposes
splitting it:
- **`actor_consumes`** (consumer subject must equal `binding.actor`). It is
low-risk and closes the denial-of-run gap: today any tenant service holding
`approval:consume` can spend the object. It adds no new identity mapping,
because the actor is already pinned to the creator's token subject. The owner
is ready to implement it as an opt-in issue flag once Gate House (or the
founder) agrees that `actor` means "the only consumer" when the flag is set.
- **`principal_approves`** (entry subject must equal `binding.principal`). It
stays waiting. NetKingdom's `principal` labels (for example `admin`) must
first equal KeyCape subjects exactly, and C2 doctrine (S5) may name approvers
that are not the principal.
## 3. Open question 6.3 — `presentation_ref` at create
**Declined as proposed**, for three reasons:
1. **Ordering.** In §4.2 the actor creates the approval *before* it submits the
memo, so no memo id exists at create. Setting the field later would make the
object mutable outside its state machine. The binding is immutable by design.
2. **Wrong asserter.** A reference set by the actor is the actor's claim about
where it will be presented. The evidence that matters is what the *approver*
was shown, and only the presenting surface can state that.
3. **The join already exists in one direction.** The memo carries `approval_id`
and the binding digest (§4.2 step 2), and audit-core can join on
`(approval_id, subject, approved_at)`.
**Counter-proposal (needs informed-decision agreement; not implemented):** an
optional entry-level `presentation` record. The approving surface posts it with
the entry, for example `{"memo_id", "revision", "view_hash"}`. The engine
stores it on that entry, serves it in GET and emits it in the issuance and use
threshold evidence. It is labelled surface-asserted: the engine checks its
shape, not its truth. Identity, principal type and assurance still come only
from the token. This closes Finding 5 (`view_hash` not inside the entry)
without changing creation. If agreed, it becomes a new APPROVAL-WP-0007 task.
## 4. Executor ingress
`approval-engine-bounded-executor` admits TCP 8080 to the approval-engine pod
from pods labelled `app.kubernetes.io/name=bounded-executor` in namespace
`sso`. The rule needs both selectors to match: they sit in one `from` element.
It does not label `sso` and does not change `approval-engine-callers`.
`tests/test_networkpolicies.py` holds that shape, so a split into two peers,
which would admit all of `sso`, fails the test. Network admission grants no
authority: the executor still needs its KeyCape token and scopes. To apply
(founder-run):
```bash
kubectl apply -f deploy/networkpolicies.yaml
```
## Founder decisions needed
1. Release and roll out the v7 image (T01) when the pilot needs the engine-side floor.
2. Agree the T02 split, and that `actor_consumes` may be implemented now.
3. Accept or decline the entry-level `presentation` counter-proposal (with informed-decision).
4. Apply the executor ingress policy.

View file

@ -7,7 +7,7 @@ requires-python = ">=3.11"
dependencies = ["PyJWT[crypto]>=2.7,<3"]
[project.optional-dependencies]
dev = ["pytest", "jsonschema>=4,<5"]
dev = ["pytest", "jsonschema>=4,<5", "pyyaml>=6"]
serve = ["waitress>=3,<4"]
[project.scripts]

View file

@ -75,7 +75,8 @@
"revoked",
"superseded",
"consumed",
"human_control_unsatisfied"
"human_control_unsatisfied",
"assurance_unsatisfied"
]
}
},
@ -157,6 +158,23 @@
"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."
},
"required_assurance": {
"type": "object",
"additionalProperties": false,
"required": [
"level"
],
"properties": {
"level": {
"enum": [
"aal1",
"aal2",
"aal3"
]
}
},
"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."
}
}
},

View file

@ -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 == 6
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 7
upgraded.close()

View file

@ -0,0 +1,67 @@
"""Mechanical checks on deploy/networkpolicies.yaml.
The bounded-executor rule must stay pod-scoped. Splitting namespaceSelector
and podSelector into two `from` elements turns AND into OR and admits every
pod in sso (KeyCape, Authelia, LLDAP) or every bounded-executor pod in any
namespace. That is a one-character YAML change review can miss.
"""
from __future__ import annotations
from pathlib import Path
import pytest
yaml = pytest.importorskip("yaml")
POLICIES = Path(__file__).resolve().parents[1] / "deploy" / "networkpolicies.yaml"
def _policies() -> dict[str, dict]:
docs = [d for d in yaml.safe_load_all(POLICIES.read_text(encoding="utf-8")) if d]
return {d["metadata"]["name"]: d for d in docs}
def test_default_deny_remains():
spec = _policies()["approval-engine-default-deny"]["spec"]
assert spec["podSelector"] == {}
assert set(spec["policyTypes"]) == {"Ingress", "Egress"}
def test_bounded_executor_ingress_is_pod_scoped_to_sso_on_8080():
policy = _policies()["approval-engine-bounded-executor"]
assert policy["metadata"]["namespace"] == "approval-engine"
spec = policy["spec"]
assert spec["podSelector"] == {"matchLabels": {"app.kubernetes.io/name": "approval-engine"}}
assert spec["policyTypes"] == ["Ingress"]
assert len(spec["ingress"]) == 1
rule = spec["ingress"][0]
assert rule["from"] == [
{
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "sso"}},
"podSelector": {"matchLabels": {"app.kubernetes.io/name": "bounded-executor"}},
}
]
assert rule["ports"] == [{"protocol": "TCP", "port": 8080}]
def test_no_policy_admits_sso_namespace_wide():
for name, policy in _policies().items():
for rule in policy["spec"].get("ingress", []):
for peer in rule.get("from", []):
ns = peer.get("namespaceSelector", {}).get("matchLabels", {})
if ns.get("kubernetes.io/metadata.name") == "sso":
assert "podSelector" in peer, f"{name} admits all of sso"
def test_existing_callers_policy_is_unchanged():
rule = _policies()["approval-engine-callers"]["spec"]["ingress"]
assert rule == [
{
"from": [
{"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "secrets-engine"}}},
{"namespaceSelector": {"matchLabels": {"railiance.io/approval-engine-client": "true"}}},
],
"ports": [{"protocol": "TCP", "port": 8080}],
}
]

View file

@ -0,0 +1,163 @@
"""APPROVAL-WP-0007-T01: an optional minimum approver assurance declared at issue."""
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, Unprocessable
from approval_engine.store import Engine, LATEST_SCHEMA_VERSION
from tests.conftest import FROZEN, binding, validity
from tests.test_auth import _jwt, _JWKS
SCHEMA = json.loads(
(Path(__file__).parents[1] / "schemas" / "approval_claim.schema.json").read_text()
)
AAL2 = {"level": "aal2"}
def _a(level):
return json.dumps({"level": level, "amr": ["pwd", "otp"], "source": "key-cape"})
@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()),
))
return app, private
def _bearer(private, principal_type, subject, assurance):
return "Bearer " + _jwt(private, principal_type=principal_type, sub=subject,
tenant="tenant:platform",
scope="approval:create approval:approve approval:read approval:consume",
assurance=assurance)
def test_http_floor_refuses_aal1_and_accepts_aal2_from_the_verified_token(engine, signed_app):
app, private = signed_app
status, created = call(app, "POST", "/v1/approvals", {
"binding": binding(actor="service:nk-bind-executor"), "validity": validity(),
"human_control": True, "required_assurance": AAL2,
}, authorization=_bearer(private, "service", "service:nk-bind-executor",
{"level": "aal1", "amr": ["pwd"], "source": "key-cape"}))
assert status == 201, created
assert created["binding"]["required_assurance"] == AAL2
status, refused = call(app, "POST", f"/v1/approvals/{created['id']}/entries",
{"assurance": {"level": "aal3"}}, # body is ignored; only the token counts
authorization=_bearer(private, "human", "user:admin",
{"level": "aal1", "amr": ["pwd"], "source": "key-cape"}))
assert status == 403, refused
assert engine.get(created["id"]).entries == []
status, approved = call(app, "POST", f"/v1/approvals/{created['id']}/entries", {},
authorization=_bearer(private, "human", "user:admin",
{"level": "aal2", "amr": ["pwd", "otp"], "source": "key-cape"}))
assert status == 200 and approved["status"] == "approved", approved
claim = engine.claim(created["id"])
assert claim["valid_now"] and claim["reason_code"] == "ok"
assert claim["binding"]["required_assurance"] == AAL2
jsonschema.validate(claim, SCHEMA)
@pytest.mark.parametrize("assurance", [
None, _a("aal1"), json.dumps({"amr": ["pwd"]}), _a("AAL2"), _a("aal4"), "not-json", json.dumps("aal2"),
])
def test_weaker_missing_or_unknown_level_is_refused(engine, assurance):
obj = engine.create(binding(), validity(), required_assurance=AAL2)
with pytest.raises(Forbidden, match="required_assurance"):
engine.add_entry(obj.id, "user:admin", assurance=assurance, principal_type="human")
assert engine.get(obj.id).entries == []
assert engine.get(obj.id).status == "requested"
@pytest.mark.parametrize("level", ["aal2", "aal3"])
def test_equal_or_stronger_level_is_accepted(engine, level):
obj = engine.create(binding(), validity(), required_assurance=AAL2)
engine.add_entry(obj.id, "user:admin", assurance=_a(level), principal_type="human")
assert engine.claim(obj.id)["valid_now"] is True
result = engine.consume(obj.id, obj.binding_digest)
assert result["status"] == "consumed"
@pytest.mark.parametrize("value", [
"aal2", {"level": "aal4"}, {"level": None}, {"level": "aal2", "amr": []}, {}, [], True,
])
def test_floor_declaration_shape_is_strict(app, engine, value):
status, _ = call(app, "POST", "/v1/approvals", {
"binding": binding(), "validity": validity(), "required_assurance": value,
})
assert status == 422
with pytest.raises(Unprocessable, match="required_assurance"):
engine.create(binding(), validity(), required_assurance=value)
assert engine._conn().execute("SELECT COUNT(*) FROM approvals").fetchone()[0] == 0
def test_undeclared_floor_keeps_existing_behavior(engine):
obj = engine.create(binding(), validity())
engine.add_entry(obj.id, "svc", assurance=None, principal_type="service")
claim = engine.claim(obj.id)
assert claim["valid_now"] and "required_assurance" not in claim["binding"]
assert "required_assurance" not in engine.get(obj.id).as_dict()["binding"]
jsonschema.validate(claim, SCHEMA)
def test_persisted_weak_entry_is_reported_and_cannot_be_consumed(engine):
obj = engine.create(binding(), validity(), required_assurance=AAL2)
engine.add_entry(obj.id, "user:admin", assurance=_a("aal2"), principal_type="human")
# Simulate an inconsistent row from a faulty writer.
engine._conn().execute("UPDATE entries SET assurance=? WHERE approval_id=?", (_a("aal1"), obj.id))
engine._conn().commit()
claim = engine.claim(obj.id)
assert not claim["valid_now"] and claim["reason_code"] == "assurance_unsatisfied"
jsonschema.validate(claim, SCHEMA)
with pytest.raises(Conflict, match="required_assurance"):
engine.consume(obj.id, obj.binding_digest)
assert engine.get(obj.id).status == "approved"
def test_supersession_inherits_floor_and_refuses_existing_downgrade(engine):
parent = engine.create(binding(), validity(), required_assurance=AAL2)
weaker = engine.create(binding(), validity())
with pytest.raises(Conflict, match="required_assurance"):
engine.supersede(parent.id, weaker.id)
assert engine.get(parent.id).status == "requested"
result = engine.supersede(parent.id)
child = engine.get(result["successor_id"])
assert child.required_assurance == "aal2"
with pytest.raises(Forbidden):
engine.add_entry(child.id, "user:admin", assurance=_a("aal1"), principal_type="human")
def test_floor_is_emitted_on_audit_events_and_does_not_change_the_act_digest(engine):
plain = engine.create(binding(), validity())
floored = engine.create(binding(), validity(), required_assurance=AAL2)
assert plain.binding_digest == floored.binding_digest
engine.add_entry(floored.id, "user:admin", assurance=_a("aal2"), principal_type="human")
event = json.loads(engine._conn().execute(
"SELECT payload_json FROM outbox WHERE approval_id=?", (floored.id,)).fetchone()[0])
assert event["details"]["required_assurance"] == AAL2
def test_legacy_v6_rows_migrate_to_null_floor(tmp_path):
path = tmp_path / "v6.sqlite"
eng = Engine(path, clock=lambda: FROZEN)
obj = eng.create(binding(), validity())
eng.add_entry(obj.id, "user:admin", assurance=_a("aal1"), principal_type="human")
eng._conn().execute("ALTER TABLE approvals DROP COLUMN required_assurance")
eng._conn().execute("PRAGMA user_version=6")
eng._conn().commit()
eng.close()
upgraded = Engine(path, clock=lambda: FROZEN)
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
upgraded.close()

View file

@ -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 == 6
assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 7
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

View file

@ -26,7 +26,7 @@ their behavior. Native digest, CAS consumption and no-unconsume are unchanged.
```task
id: APPROVAL-WP-0007-T01
status: todo
status: done
priority: high
state_hub_task_id: "9ce0aca8-7694-53c6-a76a-f78d395a9861"
```
@ -41,6 +41,11 @@ accepted, missing level refused, supersede inheritance and the claim
reason_code. Serves BEA-C4. Whether KeyCape emits `aal2` is KeyCape's
question, outside this plan.
Done 2026-10-31 in source (schema v7, `tests/test_required_assurance.py`,
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
```task
@ -57,6 +62,10 @@ flags:
- `principal_approves`: every entry subject must equal `binding.principal`.
- `actor_consumes`: the consuming subject must equal `binding.actor`.
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.
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