NK-WP-0056-T18 (part): executor's own approval client and request phase
KeyCapeClientCredentials: client_secret_basic exchange, one scope per token, public Host for the in-cluster URL, no redirects, bounded response checks. ApprovalEngine takes a per-scope token provider (create/read/consume) and can create. cli.py --request files the approval for one admission as service:nk-bind-executor (principal = first approver's subject, target admission id+digest, validity = window, human_control, required_count = approvers) and verifies the created object. The client secret comes through its own OpenBao lane (oidc-service-client case), read once, login revoked at once, kept in memory only. Refusals before any call: other executor, unmapped approver, closed window, shared role. 56 executor tests. 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
5d5f15df55
commit
b71f509ce0
3 changed files with 437 additions and 13 deletions
|
|
@ -19,6 +19,19 @@ Exit codes: 0 completed, 3 reverted, 4 refused before any effect, 5 stopped / un
|
|||
|
||||
python3 bounded-executor/cli.py --journey-only --config FILE [--result FILE]
|
||||
|
||||
python3 bounded-executor/cli.py --request --admission FILE --config FILE [--result FILE]
|
||||
|
||||
Request (NK-WP-0056-T18): file the approval for one admission as the executor itself (approval-engine requires
|
||||
binding.actor == the token's sub). Binding: action bounded-execution, actor = executor_identity, principal = the
|
||||
first listed approver's subject, target {admission_id, admission_digest}, validity = the admission window,
|
||||
human_control on, required_count = number of listed approvers. Prints the approval id; consumes nothing.
|
||||
Exit codes: 0 requested, 4 refused.
|
||||
|
||||
approval_engine.client{token_url, client_id, k8s_role, kv, host?} selects the executor's own KeyCape client
|
||||
(SecurityCaseType oidc-service-client, case nk-bind-executor-approval): the client secret is read once through its
|
||||
own OpenBao lane (k8s_role, field CLIENT_SECRET), that login is revoked at once, and every approval call exchanges a
|
||||
fresh token for exactly its scope. approval_engine.token_file remains for fixtures.
|
||||
|
||||
Journey-only (NK-WP-0056-T10): read-only, no admission. Logs in with the journey role only (journey.k8s_role, the
|
||||
openbao-audience token), runs the synthetic journey once and prints its value-free receipt. No write role, no
|
||||
Kubernetes or directory client, no approval or audit call. Needs only openbao{addr, jwt_file, ca_file?} and
|
||||
|
|
@ -50,12 +63,39 @@ C = _load("clients")
|
|||
EXIT = {"completed": 0, "reverted": 3, "refused": 4}
|
||||
|
||||
|
||||
def approval_carrier(cfg: dict, logins: list):
|
||||
"""The approval-engine client: the executor's own KeyCape client when configured, else a token file."""
|
||||
ae, o = cfg["approval_engine"], cfg["openbao"]
|
||||
if "client" not in ae:
|
||||
return C.ApprovalEngine(ae["url"], ae["token_file"], ae.get("ca_file"))
|
||||
c = ae["client"]
|
||||
lane = OPS.OpenBao(o["addr"], k8s_role=c["k8s_role"], jwt_file=o["jwt_file"], ca_file=o.get("ca_file"))
|
||||
logins.append(lane)
|
||||
data, _ = lane.read(c["kv"])
|
||||
lane.revoke_self() # one read; the role's token is not kept for the run
|
||||
if set(data) != {"CLIENT_SECRET"} or not isinstance(data["CLIENT_SECRET"], str) or not data["CLIENT_SECRET"]:
|
||||
raise OPS.OperationError("approval client kv shape")
|
||||
secret = data["CLIENT_SECRET"]
|
||||
tokens = C.KeyCapeClientCredentials(c["token_url"], c["client_id"], lambda: secret, host=c.get("host"),
|
||||
ca_file=c.get("ca_file"))
|
||||
return C.ApprovalEngine(ae["url"], token_provider=tokens.token, ca_file=ae.get("ca_file"))
|
||||
|
||||
|
||||
def distinct_roles(cfg: dict) -> None:
|
||||
roles = [cfg["openbao"].get("k8s_role"), cfg.get("journey", {}).get("k8s_role"),
|
||||
cfg.get("approval_engine", {}).get("client", {}).get("k8s_role")]
|
||||
roles = [r for r in roles if r]
|
||||
if len(roles) != len(set(roles)):
|
||||
raise ValueError("every lane must use its own OpenBao role")
|
||||
|
||||
|
||||
def build(cfg: dict, logins: list):
|
||||
"""`logins` collects every OpenBao client as soon as it exists, so the caller revokes them all, even when a
|
||||
later step of setup fails."""
|
||||
o = cfg["openbao"]
|
||||
if cfg["journey"]["k8s_role"] == o["k8s_role"]:
|
||||
raise ValueError("the journey read and the bind write must use separate roles")
|
||||
distinct_roles(cfg)
|
||||
|
||||
OPS.wait_reachable(o["addr"], ca_file=o.get("ca_file")) # new pod: NetworkPolicy may not be applied yet
|
||||
|
||||
|
|
@ -79,8 +119,7 @@ def build(cfg: dict, logins: list):
|
|||
def alert(kind: str, info: dict) -> None:
|
||||
print(json.dumps({"alert": kind, **info}), file=sys.stderr, flush=True)
|
||||
|
||||
deps = X.Deps(carrier=C.ApprovalEngine(cfg["approval_engine"]["url"], cfg["approval_engine"]["token_file"],
|
||||
cfg["approval_engine"].get("ca_file")),
|
||||
deps = X.Deps(carrier=approval_carrier(cfg, logins),
|
||||
operations=operations, checks=checks, trust=lambda state: state in trusted,
|
||||
audit=C.AuditCore(cfg["audit_core"]["url"], cfg["audit_core"]["token_file"],
|
||||
cfg["audit_core"].get("ca_file")),
|
||||
|
|
@ -91,6 +130,53 @@ def build(cfg: dict, logins: list):
|
|||
return config, deps
|
||||
|
||||
|
||||
def request_binding(doc: dict, cfg: dict) -> tuple[dict, dict, int]:
|
||||
"""The approval binding for one admission. Refuses an invalid admission, another executor, an unmapped
|
||||
approver or a closed window, before anything is sent."""
|
||||
report = X.bea.evaluate(doc, json.loads(X.bea.SCHEMA.read_text()))
|
||||
if not report["valid"]:
|
||||
raise ValueError("admission invalid")
|
||||
spec = doc["spec"]
|
||||
if spec["executor"]["identity"] != cfg["executor_identity"]:
|
||||
raise ValueError("admission names another executor")
|
||||
approvers = spec["approval"]["approvers"]
|
||||
principal = cfg["approver_subjects"][approvers[0]]
|
||||
closes = X.dt.datetime.fromisoformat(spec["window"]["not_after"].replace("Z", "+00:00"))
|
||||
if X.dt.datetime.now(X.dt.UTC) >= closes:
|
||||
raise ValueError("admission window closed")
|
||||
binding = {"action": X.ACTION, "actor": cfg["executor_identity"], "principal": principal,
|
||||
"purpose": f"{spec['id']}: {spec['title']}"[:200],
|
||||
"target": {"admission_id": spec["id"], "admission_digest": X.bea.admission_digest(doc)}}
|
||||
validity = {"not_before": spec["window"]["not_before"], "expires_at": spec["window"]["not_after"]}
|
||||
return binding, validity, len(approvers)
|
||||
|
||||
|
||||
def run_request(admission_path: str, cfg: dict) -> tuple[dict, int]:
|
||||
logins: list = []
|
||||
try:
|
||||
doc = X.bea.load(pathlib.Path(admission_path))
|
||||
binding, validity, required = request_binding(doc, cfg)
|
||||
distinct_roles(cfg)
|
||||
OPS.wait_reachable(cfg["openbao"]["addr"], ca_file=cfg["openbao"].get("ca_file"))
|
||||
created = approval_carrier(cfg, logins).create(binding, validity, required)
|
||||
got = created.get("binding") or {}
|
||||
if (got.get("actor") != binding["actor"] or got.get("target") != binding["target"]
|
||||
or created.get("status") != "requested" or not created.get("id")):
|
||||
raise ValueError("created approval does not match the request")
|
||||
result = {"mode": "request", "outcome": "requested", "approval_id": created["id"],
|
||||
"binding_digest": got.get("digest"), "admission_id": binding["target"]["admission_id"],
|
||||
"admission_digest": binding["target"]["admission_digest"], "required_count": required,
|
||||
"effects": "approval requested"}
|
||||
code = 0
|
||||
except Exception as error: # nothing was consumed; at most an unapproved request exists
|
||||
result = {"mode": "request", "outcome": "refused", "reason": "request:" + type(error).__name__}
|
||||
code = 4
|
||||
finally:
|
||||
for client in logins:
|
||||
client.revoke_self()
|
||||
return result, code
|
||||
|
||||
|
||||
def journey_only(cfg: dict, logins: list) -> dict:
|
||||
o, j = cfg["openbao"], cfg["journey"]
|
||||
OPS.wait_reachable(o["addr"], ca_file=o.get("ca_file")) # new pod: NetworkPolicy may not be applied yet
|
||||
|
|
@ -125,11 +211,18 @@ def exit_code(result: dict) -> int:
|
|||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
parser.add_argument("--journey-only", action="store_true", help="read-only journey run, no admission (T10)")
|
||||
parser.add_argument("--request", action="store_true", help="file the approval for --admission (T18)")
|
||||
parser.add_argument("--admission")
|
||||
parser.add_argument("--approval-id")
|
||||
parser.add_argument("--config", required=True)
|
||||
parser.add_argument("--result", help="also write the result here")
|
||||
args = parser.parse_args(argv)
|
||||
if args.request:
|
||||
if args.journey_only or args.approval_id or not args.admission:
|
||||
parser.error("--request takes --admission and --config only")
|
||||
result, code = run_request(args.admission, json.loads(pathlib.Path(args.config).read_text()))
|
||||
_emit(result, args.result)
|
||||
return code
|
||||
if args.journey_only == bool(args.admission or args.approval_id):
|
||||
parser.error("give either --journey-only, or --admission with --approval-id")
|
||||
if not args.journey_only and not (args.admission and args.approval_id):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue