diff --git a/approval_engine/api.py b/approval_engine/api.py index 440e4ac..3a0f1f1 100644 --- a/approval_engine/api.py +++ b/approval_engine/api.py @@ -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() diff --git a/approval_engine/store.py b/approval_engine/store.py index e9444d9..9a8225b 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -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" diff --git a/deploy/README.md b/deploy/README.md index 47042c6..9a06983 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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). diff --git a/deploy/networkpolicies.yaml b/deploy/networkpolicies.yaml index 6301345..765d14f 100644 --- a/deploy/networkpolicies.yaml +++ b/deploy/networkpolicies.yaml @@ -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: diff --git a/docs/approval-claim.md b/docs/approval-claim.md index 01199d4..3dfa79f 100644 --- a/docs/approval-claim.md +++ b/docs/approval-claim.md @@ -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. diff --git a/docs/bounded-execution-admission-carrier.md b/docs/bounded-execution-admission-carrier.md index 090c5db..e0ac17f 100644 --- a/docs/bounded-execution-admission-carrier.md +++ b/docs/bounded-execution-admission-carrier.md @@ -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. diff --git a/docs/decision-services-alignment-answer.md b/docs/decision-services-alignment-answer.md new file mode 100644 index 0000000..85a92e9 --- /dev/null +++ b/docs/decision-services-alignment-answer.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 32e4395..8bcf3f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/schemas/approval_claim.schema.json b/schemas/approval_claim.schema.json index 2b3249f..1776dfc 100644 --- a/schemas/approval_claim.schema.json +++ b/schemas/approval_claim.schema.json @@ -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." } } }, diff --git a/tests/test_human_control.py b/tests/test_human_control.py index 467c518..31a8b6c 100644 --- a/tests/test_human_control.py +++ b/tests/test_human_control.py @@ -144,7 +144,7 @@ def test_legacy_v4_rows_are_not_reclassified_from_human_entries(tmp_path): assert survivor.entries[0]["principal_type"] == "human" assert survivor.human_control is False assert upgraded.claim(obj.id)["binding"]["human_control"] is False - assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 6 + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 7 upgraded.close() diff --git a/tests/test_networkpolicies.py b/tests/test_networkpolicies.py new file mode 100644 index 0000000..7bc71a8 --- /dev/null +++ b/tests/test_networkpolicies.py @@ -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}], + } + ] diff --git a/tests/test_required_assurance.py b/tests/test_required_assurance.py new file mode 100644 index 0000000..7c67a88 --- /dev/null +++ b/tests/test_required_assurance.py @@ -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() diff --git a/tests/test_tenant_provenance.py b/tests/test_tenant_provenance.py index 9f3769b..0355fed 100644 --- a/tests/test_tenant_provenance.py +++ b/tests/test_tenant_provenance.py @@ -79,7 +79,7 @@ def test_v5_migration_preserves_unknown_provenance_and_existing_evidence(tmp_pat with pytest.raises(StoreUnavailable, match="migrate"): Engine(path, auto_migrate=False) engine = Engine(path, clock=lambda: FROZEN) - assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 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 diff --git a/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md index d79f8fd..228c288 100644 --- a/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md +++ b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md @@ -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