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:
parent
e5f31477d4
commit
e9e9f93317
14 changed files with 540 additions and 13 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
115
docs/decision-services-alignment-answer.md
Normal file
115
docs/decision-services-alignment-answer.md
Normal 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.
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
67
tests/test_networkpolicies.py
Normal file
67
tests/test_networkpolicies.py
Normal 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}],
|
||||
}
|
||||
]
|
||||
163
tests/test_required_assurance.py
Normal file
163
tests/test_required_assurance.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue