feat: exchange scoped approval service tokens per request
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
3a19069b4b
commit
7688445184
14 changed files with 859 additions and 35 deletions
293
tools/exercise_approval_identity.py
Normal file
293
tools/exercise_approval_identity.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Disposable KeyCape image -> actual Secrets Engine client -> Approval Engine.
|
||||
|
||||
Synthetic keys/credentials only. No OpenBao, cluster mutation, or model call.
|
||||
The PDP is a sequencing double; this is not production authorization evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import http.server
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from unittest.mock import patch
|
||||
from wsgiref.simple_server import WSGIRequestHandler, make_server
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
import yaml
|
||||
|
||||
KEYCAPE_IMAGE = "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(ROOT / "src"), str(ROOT)]
|
||||
|
||||
|
||||
def run(args):
|
||||
result = subprocess.run(args, capture_output=True, timeout=45)
|
||||
if result.returncode:
|
||||
raise RuntimeError("contained command failed")
|
||||
return result.stdout.decode().strip()
|
||||
|
||||
|
||||
def commit(path):
|
||||
return run(["git", "-C", str(path), "rev-parse", "HEAD"])
|
||||
|
||||
|
||||
def private_write(path, value):
|
||||
with path.open("x") as out:
|
||||
os.chmod(path, 0o600)
|
||||
out.write(value)
|
||||
|
||||
|
||||
def exercise(args):
|
||||
sys.path.insert(0, str(args.approval_engine_source))
|
||||
from approval_engine.api import App
|
||||
from approval_engine.auth import JWTAuthenticator
|
||||
from approval_engine.store import Engine
|
||||
from secrets_engine import cli
|
||||
from secrets_engine.approval_auth import approval_token, credential_urlopen
|
||||
from secrets_engine.approval_consume import ConsumeBinding, _expected_request, consume_approval
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.config import Config
|
||||
from secrets_engine.errors import BackendError, DecisionError
|
||||
from tests.authorization_stub import AuthorizationStub
|
||||
from tests.test_catalog import VALID
|
||||
|
||||
receipt = {
|
||||
"schema_version": 1, "target": "disposable local processes; synthetic credentials",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"keycape_image": KEYCAPE_IMAGE,
|
||||
"approval_engine_commit": commit(args.approval_engine_source),
|
||||
"keycape_contract_commit": commit(args.keycape_source),
|
||||
"consumer_source_sha256": {
|
||||
name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest()
|
||||
for name in ("approval_auth.py", "approval_consume.py", "config.py", "service_auth.py")
|
||||
},
|
||||
"limitations": ["PDP sequencing double", "local Approval Engine source, not deployed image",
|
||||
"no live custody or client-side read grant", "no OpenBao effect or model execution"],
|
||||
"checks": {},
|
||||
}
|
||||
with tempfile.TemporaryDirectory(prefix="approval-identity-private-") as temporary:
|
||||
root = Path(temporary)
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_write(root / "key.pem", key.private_bytes(serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode())
|
||||
now = datetime.now(timezone.utc)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "local approval identity exercise")])
|
||||
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(hours=1))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), False)
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), True)
|
||||
.sign(key, hashes.SHA256()))
|
||||
(root / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
keycape_port = sock.getsockname()[1]
|
||||
|
||||
class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
def proxy(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if self.command == "POST" else None
|
||||
req = Request(f"http://127.0.0.1:{keycape_port}" + self.path, data=body,
|
||||
headers={k: self.headers[k] for k in ("Content-Type", "Authorization") if k in self.headers})
|
||||
try:
|
||||
response = urlopen(req, timeout=10)
|
||||
except HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
self.send_response(response.code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(response.read())
|
||||
do_GET = proxy
|
||||
do_POST = proxy
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Proxy)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(root / "cert.pem", root / "key.pem")
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
issuer = f"https://127.0.0.1:{server.server_port}"
|
||||
proxy_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
proxy_thread.start()
|
||||
config = yaml.safe_load((args.keycape_source / "config/dev-config.yaml").read_text())
|
||||
clients = yaml.safe_load((args.keycape_source / "config/service-clients.example.yaml").read_text())["clients"]
|
||||
clients = [c for c in clients if c["clientId"] in ("secrets-engine-approval", "approval-engine-operator")]
|
||||
assert len(clients) == 2
|
||||
config.update(issuer=issuer, clients=clients)
|
||||
config["authelia"]["issuer"] = "https://synthetic-upstream.invalid"
|
||||
private_write(root / "config.yaml", yaml.safe_dump(config, sort_keys=False))
|
||||
values = {c["clientId"]: secrets.token_urlsafe(48) for c in clients}
|
||||
private_write(root / "env", "KEYCAPE_CONFIG=/etc/keycape/config.yaml\n" + "".join(
|
||||
c["secretRef"].removeprefix("env:") + "=" + values[c["clientId"]] + "\n" for c in clients))
|
||||
private_write(root / "approval.secret", values["secrets-engine-approval"])
|
||||
private_write(root / "pdp.token", "synthetic-pdp-token")
|
||||
container = "approval-identity-exercise-" + secrets.token_hex(5)
|
||||
created = False
|
||||
engine = None
|
||||
api_server = None
|
||||
pdp = None
|
||||
try:
|
||||
run(["docker", "run", "-d", "--rm", "--name", container, "--user", str(os.getuid()),
|
||||
"--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges",
|
||||
"--publish", f"127.0.0.1:{keycape_port}:8080", "--env-file", str(root / "env"),
|
||||
"--mount", "type=bind,source=" + temporary + ",target=/etc/keycape,readonly", KEYCAPE_IMAGE])
|
||||
created = True
|
||||
with patch.dict(os.environ, {"SSL_CERT_FILE": str(root / "cert.pem"), "SECRETS_ENGINE_UNSAFE_DEMO": ""}):
|
||||
for _ in range(40):
|
||||
try:
|
||||
with credential_urlopen(issuer + "/jwks", timeout=1) as response:
|
||||
if response.status == 200:
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
raise RuntimeError("disposable KeyCape did not start")
|
||||
engine = Engine(root / "approval.sqlite")
|
||||
app = App(engine, JWTAuthenticator(issuer=issuer, audience="approval-engine", jwks_url=issuer + "/jwks"))
|
||||
class Quiet(WSGIRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
api_server = make_server("127.0.0.1", 0, app, handler_class=Quiet)
|
||||
threading.Thread(target=api_server.serve_forever, daemon=True).start()
|
||||
api = f"http://127.0.0.1:{api_server.server_port}"
|
||||
|
||||
def operator_token(scope):
|
||||
credential = base64.b64encode(("approval-engine-operator:" + values["approval-engine-operator"]).encode()).decode()
|
||||
req = Request(issuer + "/token", data=urlencode({"grant_type": "client_credentials", "scope": scope}).encode(),
|
||||
headers={"Authorization": "Basic " + credential})
|
||||
with credential_urlopen(req, timeout=3) as response:
|
||||
return json.load(response)["access_token"]
|
||||
|
||||
def operator_request(path, payload, scope):
|
||||
req = Request(api + path, data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer " + operator_token(scope)})
|
||||
with credential_urlopen(req, timeout=3) as response:
|
||||
return json.load(response)
|
||||
|
||||
raw = copy.deepcopy(VALID)
|
||||
raw["stage"] = "prod"
|
||||
raw["approval"] = {"model": "bootstrap-only", "authorization_id": "synthetic-approval", "purpose": "disposable identity proof"}
|
||||
entry = validate_entry(raw)
|
||||
pdp = AuthorizationStub(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start()
|
||||
cfg = Config(catalog_dir=root, policy_dir=root, evidence_dir=root / "evidence", hub_url="", bao_addr="", topic_id="",
|
||||
approval_url=api, approval_client_secret_file=root / "approval.secret",
|
||||
keycape_issuer=issuer, keycape_token_url=issuer + "/token",
|
||||
authorization_subject_id="secrets-engine", authorization_subject_type="service",
|
||||
authorization_policy_package="secrets-engine.catalog-lane.lifecycle", authorization_policy_version="v2",
|
||||
pdp_url=pdp.url, pdp_token_file=root / "pdp.token")
|
||||
request = _expected_request(cfg, entry, "apply")
|
||||
digest = pdp.bind_request(request)
|
||||
obj = operator_request("/v1/approvals", {
|
||||
"id": "synthetic-approval",
|
||||
"binding": {"action": "secrets.apply", "target": {"id": entry.id, "stage": "prod"},
|
||||
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
|
||||
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
|
||||
"pdp_digest": digest, "pdp_path": True}, "approval:create")
|
||||
operator_request(f"/v1/approvals/{obj['id']}/entries", {}, "approval:approve")
|
||||
receipt["checks"]["operator_issued_and_approved_via_verified_jwt"] = True
|
||||
try:
|
||||
cli._require_lane_approval(cfg, entry, "destroy")
|
||||
except DecisionError:
|
||||
assert not engine.claim(obj["id"])["consumed"]
|
||||
else:
|
||||
raise AssertionError("wrong action accepted")
|
||||
receipt["checks"]["wrong_action_refused_before_consume"] = True
|
||||
cli._require_lane_approval(cfg, entry, "apply")
|
||||
assert engine.claim(obj["id"])["consumed"]
|
||||
assert pdp.calls == ["check"]
|
||||
receipt["checks"]["actual_consumer_claim_check_consume"] = True
|
||||
binding = ConsumeBinding(obj["id"], digest)
|
||||
retry = consume_approval(base_url=api, binding=binding, token_provider=lambda: approval_token(cfg, scope="approval:consume"))
|
||||
assert retry.idempotent
|
||||
receipt["checks"]["same_digest_retry_idempotent"] = True
|
||||
try:
|
||||
consume_approval(base_url=api, binding=ConsumeBinding(obj["id"], "sha256:" + "f" * 64),
|
||||
token_provider=lambda: approval_token(cfg, scope="approval:consume"))
|
||||
except DecisionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("different digest accepted")
|
||||
receipt["checks"]["different_digest_refused"] = True
|
||||
try:
|
||||
cli._require_lane_approval(cfg, entry, "apply")
|
||||
except DecisionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("spent claim accepted")
|
||||
receipt["checks"]["spent_claim_refused"] = True
|
||||
try:
|
||||
operator_token("approval:consume")
|
||||
except HTTPError as error:
|
||||
assert error.code in (400, 401)
|
||||
assert json.load(error)["error"] == "invalid_profile_usage"
|
||||
else:
|
||||
raise AssertionError("operator consume scope accepted")
|
||||
receipt["checks"]["operator_consume_scope_denied_by_issuer"] = True
|
||||
(root / "approval.secret").write_text("synthetic-wrong-secret")
|
||||
try:
|
||||
approval_token(cfg, scope="approval:read")
|
||||
except BackendError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("wrong secret accepted")
|
||||
receipt["checks"]["wrong_secret_refused"] = True
|
||||
assert not (root / "approval.token").exists()
|
||||
receipt["checks"]["no_access_token_file_created"] = True
|
||||
finally:
|
||||
if api_server:
|
||||
api_server.shutdown()
|
||||
api_server.server_close()
|
||||
if pdp:
|
||||
pdp.stop()
|
||||
if engine:
|
||||
engine.close()
|
||||
if created:
|
||||
run(["docker", "stop", "--time=5", container])
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
proxy_thread.join()
|
||||
receipt.update(status="passed", cleanup_complete=True, finished_at=datetime.now(timezone.utc).isoformat())
|
||||
private_write(args.receipt, json.dumps(receipt, indent=2) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--keycape-source", required=True, type=Path)
|
||||
parser.add_argument("--approval-engine-source", required=True, type=Path)
|
||||
parser.add_argument("--receipt", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.receipt.exists():
|
||||
raise SystemExit("receipt path must be new")
|
||||
try:
|
||||
exercise(args)
|
||||
except Exception as error:
|
||||
# Never dump a request, client environment or credential-bearing frame.
|
||||
print("Synthetic approval identity exercise failed: " + type(error).__name__, file=sys.stderr)
|
||||
return 1
|
||||
print("Synthetic approval identity exercise passed; temporary resources removed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue