diff --git a/docs/openbao-jwt-login.md b/docs/openbao-jwt-login.md index 7b841d4..e7369dd 100644 --- a/docs/openbao-jwt-login.md +++ b/docs/openbao-jwt-login.md @@ -18,6 +18,12 @@ through a temporary JSON file, never argv. The resulting OpenBao token is revoked at the end of the command (`token revoke -self`). Evidence records the provider name and an accessor fingerprint only. -Until railiance-platform publishes that file, `--auth auto` keeps the named -bootstrap-file and `BAO_TOKEN` providers. `--auth service-jwt` fail-closes. +railiance-platform published the exact consumer file on 2026-10-10 at +`docs/contracts/secrets-engine-openbao-jwt-login.yaml` (mount `keycape-services`, +role `secrets-engine-openbao`, issuer `https://kc.coulomb.social`). When configured, +`--auth auto` selects service-jwt; without a contract it keeps named bootstrap/env +providers. Explicit service-jwt fails closed when configuration is missing. +The current service token is login-self-only and cannot perform protected lane +operations. T06 also requires a reviewed task-scoped execution entitlement and +functional steady-state adoption; see [final acceptance](service-jwt-final-acceptance.md). A service-jwt selection never reads `--bootstrap-token-file` or `BAO_TOKEN`. diff --git a/docs/service-auth.md b/docs/service-auth.md index 599a1c8..b653fed 100644 --- a/docs/service-auth.md +++ b/docs/service-auth.md @@ -36,8 +36,10 @@ policy, TTL/use limits, and cryptographic verification. When `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` names that contract, the CLI selects `service-jwt` and logs in for one command, then self-revokes the OpenBao token. Failure of that path never falls back to `--bootstrap-token-file`, -`BAO_TOKEN`, or AppRole. Until the contract is published, `--auth auto` keeps -those named providers and `--auth service-jwt` fail-closes. See +`BAO_TOKEN`, or AppRole. The platform contract was published 2026-10-10. Configured `--auth auto` +selects service-jwt; without that configuration it keeps bootstrap/env. The +current login-self-only token cannot perform protected lane operations; +functional adoption and task-scoped execution entitlement remain T06. See [openbao-jwt-login.md](openbao-jwt-login.md). Canonical provider contract: diff --git a/docs/service-jwt-final-acceptance.md b/docs/service-jwt-final-acceptance.md new file mode 100644 index 0000000..2867c22 --- /dev/null +++ b/docs/service-jwt-final-acceptance.md @@ -0,0 +1,111 @@ +# Service-JWT final acceptance — SECRETS-WP-0008-T06 + +Prepared 2026-10-11; source preparation only. T06 remains wait. This packet +neither grants live authority nor provides owner acknowledgment. + +## Delivered producer and consumer work + +SECRETS-WP-0012 is finished: protected in-memory client-secret acquisition, +operator cleanup, KeyCape exchange, service JWT login and service cleanup passed +in `docs/evidence/2026-10-10-keycape-openbao-probe.json`. The platform applied +CCR-2026-0038 on October 8 and published +`railiance-platform/docs/contracts/secrets-engine-openbao-jwt-login.yaml` on +October 10. Mount `keycape-services`, role `secrets-engine-openbao`, issuer +`https://kc.coulomb.social` are published. An unpublished-mount or +unprovisioned-registration wait is obsolete. + +The consumer no-fallback receipt +`docs/evidence/2026-10-10-service-jwt-no-fallback.json` proves synthetic selection +and real Bao CLI header behavior against a loopback listener. It is not native +claim rejection evidence. KEY-WP-0039-T01 is source acceptance done; T02 owns the +attended actual negative-token session. RPF-WP-0035-T02 retains native claims, +effective policy/capability denials, and MASON-WP-0009-T05 return. + +WP0007 is six of seven complete with native per-lane adoption outstanding. +WP0009 credential apply/verify/delivery/revocation passed in the September 27 +attended receipts, but its task cannot finish: held EUR 10 reservation still +needs accounting reconciliation, replacement runtime admission and separate +retry authority. These are existing live tasks; this packet closes neither. + +## Executable missing identity-boundary check + +`tools/verify_service_jwt_boundary.py` defaults to backend-free preparation. +It checks exact applied CCR, published mount/role and issuer, client-secret-file +refusal and exact guarded paths. Preparation cannot produce a passing native +receipt. After explicit authorization for the attended metadata check, add +`--execute` to the same command: + +```bash +env -u BAO_TOKEN -u VAULT_TOKEN -u SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE \ + BAO_ADDR=http://127.0.0.1:18200 \ + SECRETS_ENGINE_KEYCAPE_TOKEN_URL=https://kc.coulomb.social/token \ + SECRETS_ENGINE_KEYCAPE_ISSUER=https://kc.coulomb.social \ + SECRETS_ENGINE_OPENBAO_JWT_LOGIN=/home/worsch/railiance-platform/docs/contracts/secrets-engine-openbao-jwt-login.yaml \ + uv run python tools/verify_service_jwt_boundary.py \ + --ccr /home/worsch/railiance-platform/credential-change-requests/CCR-2026-0038-secrets-engine-service-jwt-login.yaml +``` + +Live mode uses existing attended custody and performs two service-token metadata +requests: lookup-self and one batch capabilities-self. The batch checks both +client-secret guarded paths, `sys/policies/acl/*`, `auth/*/role/*` and +`auth/token/create`; it never reads their values or attempts writes. Cleanup +runs even on failed verification. Two metadata calls reserve room for cleanup +within the service token's eight-use budget. Passing requires exactly the +self policy, no identity-policy additions, deny on every requested path and +both operator and service revocation success. Output contains hashes, capability +booleans and session fingerprints. No backend response/error body is printed. +Transport fixes bind each request to the configured OpenBao origin, disable +environment proxies and refuse all redirects including relative redirects. +Only HTTP 200 application/json with a bounded 64 KiB body and object data is +accepted. Errors and response bodies are suppressed. Session evidence is +restricted to the contract whitelist with strict boolean types, the fixed +source name, and twelve-hex session handles. Native acceptance requires every +contract field (established, source, both fingerprints and attempted/succeeded +cleanup flags), a normally returned cleanup call, and no verification failure. +Stale true evidence cannot override a cleanup exception. HTTP JSON with an +errors member is refused even when a data member also exists. Synthetic/local-loopback tests +cover redirects, proxy environment, size/status/content/shape failures, absent +responses, unknown exceptions and unknown evidence fields with cleanup. +This does not prove expiry/use exhaustion, native claim negatives or adoption. +Platform retains those native test obligations and owner review of this tool. + +## Protected-operation adoption is a distinct missing contract + +INTENT requires task-bounded authority and a functional Lifecycle API. The +platform design explicitly says this login policy is insufficient for native +lanes; future execution entitlement requires a separately reviewed CCR type and +backend-enforced scope. No standing wildcard policy is accepted. + +Actual CLI flow (`cli._require_lane_approval`, `_open_backend`, `cmd_apply`) is +claim → validated PDP Check → approval CAS consume → select provider → OpenBao +operation. `_open_backend` supplies the login-only service token directly. +There is no per-action delegated grant or scoped backend exchange in that path. +Consequently a service-jwt default flip would consume a real approval then fail +the protected operation. Configured `auto` chooses service-jwt; unconfigured +`auto` still chooses bootstrap/env. Neither selection proves steady-state +adoption. Keep existing service policy and provider defaults until the execution +contract exists; failed JWT never switches provider. + +Founder/owner choice needed: retain login-self-only and implement a separately +owned task-scoped delegation contract (recommended), or explicitly redesign +backend execution mediation with platform enforcement. Both must preserve +claim/PDP/CAS binding, exact action/lane/target/expiry/principal scope, failure +closed, revocation and audit evidence; neither means granting broad standing +ACLs. Retaining attended bootstrap/env as a transition leaves T06 open. + +Concrete missing owner obligation: secrets-engine and railiance-platform must +agree the execution entitlement's owner and CCR schema, implementation endpoint, +backend policy enforcement, exact request binding, TTL/use budget and revocation, +then wire it after successful consume. KeyCape supplies identity, not authority. +Existing native AppRole delivery sessions are short-lived workload read sessions, +not an engine administrative entitlement. Existing native lane cutover requires +scoped attended platform authority but does not implement this exchange. + +T06 closes only after native KEY39/CCR negative/expiry/use/cleanup evidence and +MASON return, a reviewed implemented execution entitlement, and a selected real +protected action through service identity plus delegated authority: exact bound +PDP/CAS evidence, intended backend result, unauthorized target/action/expired or +replayed request refusal, cleanup on success and failure, no standing engine +credential, and steady-state invocation/default migration evidence. Do not use +`apply` or any other protected live command to discover the missing entitlement. +All of this remains in T06 and owner tasks; no residual is silently finished. diff --git a/tests/test_service_jwt_boundary.py b/tests/test_service_jwt_boundary.py new file mode 100644 index 0000000..5e9c35a --- /dev/null +++ b/tests/test_service_jwt_boundary.py @@ -0,0 +1,212 @@ +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from secrets_engine.errors import BackendError + +spec = importlib.util.spec_from_file_location("boundary", Path(__file__).parents[1] / "tools/verify_service_jwt_boundary.py") +boundary = importlib.util.module_from_spec(spec) +spec.loader.exec_module(boundary) + + +class Session: + client = SimpleNamespace() + closed = False + + def close(self): + self.closed = True + + def evidence(self): + return {"secret_source": "openbao-oidc-in-memory", "established": True, + "operator_session_handle": "0123456789ab", "session_handle": "abcdef012345", + "operator_revocation_attempted": True, "operator_revocation_succeeded": True, + "revocation_attempted": self.closed, "revocation_succeeded": self.closed} + + +@pytest.mark.parametrize("extra_identity", [False, True]) +def test_batch_denial_checks_identity_policy_and_always_cleans(extra_identity): + session = Session() + paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES) + calls = [] + + def request(client, path, payload=None): + calls.append((path, payload)) + if path == "auth/token/lookup-self": + return {"policies": ["secrets-engine-login-self"], "identity_policies": ["admin"] if extra_identity else []} + return {p: ["deny"] for p in paths} + + result = boundary.verify_session(session, {"expected_policy": "secrets-engine-login-self", "denied_paths": paths}, request) + assert result["native_verified"] is (not extra_identity) + assert session.closed + assert len(calls) == 2 + assert calls[1][1] == {"paths": paths} + assert not result["protected_operation_adoption_proved"] + + +@pytest.mark.parametrize("failure", ["request", "missing-deny", "revoke"]) +def test_failed_or_partial_acceptance_never_passes(failure): + session = Session() + paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES) + + def request(client, path, payload=None): + if failure == "request": + raise BackendError("sensitive backend error") + if path == "auth/token/lookup-self": + return {"policies": ["secrets-engine-login-self"]} + return {p: ["deny"] for p in paths[:-1] if failure == "missing-deny"} if failure == "missing-deny" else {p: ["deny"] for p in paths} + + if failure == "revoke": + def close(): + raise BackendError("sensitive backend error") + session.close = close + result = boundary.verify_session(session, {"expected_policy": "secrets-engine-login-self", "denied_paths": paths}, request) + assert not result["native_verified"] + assert "sensitive backend error" not in str(result) + if failure != "revoke": + assert session.closed + + +@pytest.mark.parametrize("fault", ["oversize-header", "oversize-body", "malformed", "wrong-shape", "wrong-status", "wrong-content", "no-response", "unknown-error"]) +def test_transport_failures_suppress_body_and_cleanup(monkeypatch, fault): + from email.message import Message + headers = Message() + headers["Content-Type"] = "text/plain" if fault == "wrong-content" else "application/json" + if fault == "oversize-header": + headers["Content-Length"] = str(boundary.MAX_RESPONSE_BYTES + 1) + response = SimpleNamespace(status=503 if fault == "wrong-status" else 200, headers=headers) + raw = b'{"data": {}}' + if fault == "oversize-body": + raw = b'x' * (boundary.MAX_RESPONSE_BYTES + 1) + if fault == "malformed": + raw = b'private-body' + if fault == "wrong-shape": + raw = b'{"data": []}' + + class Response: + status = response.status + headers = response.headers + def __enter__(self): return self + def __exit__(self, *args): pass + def read(self, count): + assert count == boundary.MAX_RESPONSE_BYTES + 1 + return raw + + def opening(req, timeout): + if fault in ("no-response", "unknown-error"): + raise RuntimeError("private-body") + return Response() + monkeypatch.setattr(boundary, "build_opener", lambda *args: SimpleNamespace(open=opening)) + session = Session() + session.client = SimpleNamespace(addr="http://127.0.0.1:18200", token="sentinel") + plan = {"expected_policy": "secrets-engine-login-self", "denied_paths": list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)} + result = boundary.verify_session(session, plan, lambda c,p,d=None: boundary.request_json(c,p,d,expected_addr=c.addr)) + assert not result["native_verified"] + assert session.closed + assert "private-body" not in str(result) + + +@pytest.mark.parametrize("location", ["http://127.0.0.1:9/private", "/relative"]) +def test_real_redirect_refused_and_proxy_environment_ignored(monkeypatch, location): + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + observed = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + observed.append(self.path) + self.send_response(302) + self.send_header("Location", location) + self.end_headers() + def log_message(self, *args): pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + # Invalid proxy would prevent the first request if environment proxies were used. + monkeypatch.setenv("http_proxy", "http://127.0.0.1:1") + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("no_proxy", "") + monkeypatch.setenv("NO_PROXY", "") + addr = f"http://127.0.0.1:{server.server_port}" + session = Session() + session.client = SimpleNamespace(addr=addr, token="synthetic-token-only") + try: + result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths": list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)}, + lambda c,p,d=None: boundary.request_json(c,p,d,expected_addr=addr)) + finally: + server.shutdown() + server.server_close() + worker.join() + assert observed == ["/v1/auth/token/lookup-self"] + assert session.closed + assert not result["native_verified"] + + +def test_origin_mismatch_refused_before_request(monkeypatch): + monkeypatch.setattr(boundary, "build_opener", lambda *args: pytest.fail("must not send token")) + client = SimpleNamespace(addr="http://evil.invalid", token="sentinel") + with pytest.raises(BackendError, match="response suppressed"): + boundary.request_json(client, "auth/token/lookup-self", expected_addr="http://127.0.0.1:18200") + + +def test_unknown_evidence_and_private_failure_not_published(): + session = Session() + session.evidence = lambda: {"private_token": "private-body", "session_handle": "private-body", "secret_source": "private-body", "operator_revocation_succeeded": True, "revocation_succeeded": True} + def fail(*args): raise RuntimeError("private-body") + result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths": list(boundary.EXPECTED_GUARDS)}, fail) + assert session.closed + assert not result["native_verified"] + assert "private-body" not in str(result) + assert "private_token" not in result + + +@pytest.mark.parametrize("missing", list(boundary.SAFE_EVIDENCE)) +def test_every_contract_evidence_field_required(missing): + session = Session() + original = session.evidence + def evidence(): + result = original() + del result[missing] + return result + session.evidence = evidence + paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES) + def request(client, path, payload=None): + return {"policies": ["secrets-engine-login-self"]} if path == "auth/token/lookup-self" else {p:["deny"] for p in paths} + result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths": paths}, request) + assert session.closed + assert not result["native_verified"] + + +def test_revoke_exception_cannot_be_overridden_by_stale_evidence(): + session = Session() + session.closed = True + def close(): raise RuntimeError("private failure") + session.close = close + paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES) + def request(client, path, payload=None): + return {"policies": ["secrets-engine-login-self"]} if path == "auth/token/lookup-self" else {p:["deny"] for p in paths} + result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths":paths}, request) + assert result["revocation_succeeded"] is True + assert not result["native_verified"] + assert "private failure" not in str(result) + + +@pytest.mark.parametrize("errors", [[], ["private backend failure"], None]) +def test_errors_and_data_response_never_accepted(monkeypatch, errors): + import json + from email.message import Message + headers = Message() + headers["Content-Type"] = "application/json" + class Response: + status = 200 + def __enter__(self): return self + def __exit__(self, *args): pass + def read(self, count): return json.dumps({"data": {}, "errors": errors}).encode() + response = Response() + response.headers = headers + monkeypatch.setattr(boundary, "build_opener", lambda *args: SimpleNamespace(open=lambda *a,**k: response)) + client = SimpleNamespace(addr="http://127.0.0.1:18200", token="synthetic-only") + with pytest.raises(BackendError, match="response suppressed"): + boundary.request_json(client, "auth/token/lookup-self", expected_addr=client.addr) diff --git a/tools/verify_service_jwt_boundary.py b/tools/verify_service_jwt_boundary.py new file mode 100644 index 0000000..5afbc63 --- /dev/null +++ b/tools/verify_service_jwt_boundary.py @@ -0,0 +1,194 @@ +"""Prepare or attend CCR-2026-0038 self-policy/capability acceptance. + +Default is backend-free. --execute invokes existing attended in-memory custody; +never print backend response bodies, secret values, tokens or full accessors. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener + +import yaml + +from secrets_engine.config import Config +from secrets_engine.engine_auth import keycape_config, load_jwt_login_contract, login_service_jwt +from secrets_engine.errors import BackendError + +ADMIN_DENIES = ("sys/policies/acl/*", "auth/*/role/*", "auth/token/create") +EXPECTED_GUARDS = ( + "platform/data/workloads/keycape/secrets-engine-openbao", + "platform/metadata/workloads/keycape/secrets-engine-openbao", +) + + +def prepare(cfg, ccr_path): + raw = ccr_path.read_bytes() + ccr = yaml.safe_load(raw) + if not isinstance(ccr, dict) or ccr.get("id") != "CCR-2026-0038" or ccr.get("status") != "applied": + raise BackendError("expected applied CCR-2026-0038") + bao = ccr.get("openbao", {}) + auth = bao.get("auth", {}) + contract = load_jwt_login_contract(cfg) + kcfg = keycape_config(cfg) + if (contract.mount, contract.role, contract.bound_issuer) != ( + auth.get("mount"), auth.get("role"), kcfg.issuer + ): + raise BackendError("login contract and issuer must match applied CCR") + if bao.get("guarded_paths") != list(EXPECTED_GUARDS) or bao.get("policy_name") != "secrets-engine-login-self": + raise BackendError("unexpected CCR guarded paths or self policy") + return { + "ccr_sha256": hashlib.sha256(raw).hexdigest(), + "contract_sha256": hashlib.sha256(contract.path.read_bytes()).hexdigest(), + "denied_paths": list(EXPECTED_GUARDS + ADMIN_DENIES), + "expected_policy": "secrets-engine-login-self", + } + + +MAX_RESPONSE_BYTES = 65536 +SAFE_EVIDENCE = { + "secret_source": str, + "operator_session_handle": str, + "session_handle": str, + "established": bool, + "operator_revocation_attempted": bool, + "operator_revocation_succeeded": bool, + "revocation_attempted": bool, + "revocation_succeeded": bool, +} + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise BackendError("redirect refused") + + +def origin(url): + parsed = urlsplit(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/"): + raise BackendError("invalid configured OpenBao origin") + return (parsed.scheme, parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) + + +def request_json(client, path, payload=None, *, expected_addr=None): + # expected_addr is captured from Config.load(), never inferred from a response. + try: + if expected_addr is None or origin(client.addr) != origin(expected_addr): + raise BackendError("unexpected OpenBao origin") + if path not in ("auth/token/lookup-self", "sys/capabilities-self"): + raise BackendError("unexpected metadata endpoint") + data = None if payload is None else json.dumps(payload).encode() + req = Request(expected_addr.rstrip("/") + "/v1/" + path, data=data, + headers={"X-Vault-Token": client.token, "Content-Type": "application/json"}) + # Disable environment proxies and all redirect following before supplying token. + opener = build_opener(ProxyHandler({}), NoRedirect()) + with opener.open(req, timeout=30) as response: + if response.status != 200 or response.headers.get_content_type() != "application/json": + raise BackendError("unexpected metadata response") + length = response.headers.get("Content-Length") + if length is not None and (not length.isdecimal() or int(length) > MAX_RESPONSE_BYTES): + raise BackendError("oversize metadata response") + raw = response.read(MAX_RESPONSE_BYTES + 1) + if len(raw) > MAX_RESPONSE_BYTES: + raise BackendError("oversize metadata response") + result = json.loads(raw) + if not isinstance(result, dict) or not isinstance(result.get("data"), dict) or "errors" in result: + raise BackendError("malformed metadata response") + return result["data"] + except Exception: + raise BackendError("boundary metadata request failed; response suppressed") from None + + +def safe_evidence(session): + result = {} + try: + evidence = session.evidence() + if not isinstance(evidence, dict): + return result + for key, kind in SAFE_EVIDENCE.items(): + value = evidence.get(key) + if type(value) is not kind: + continue + if kind is str: + if key == "secret_source" and value != "openbao-oidc-in-memory": + continue + if key.endswith("handle") and not re.fullmatch(r"[0-9a-f]{12}", value): + continue + result[key] = value + except Exception: + pass + return result + + +def verify_session(session, plan, request=request_json): + """Two metadata requests, then unconditional cleanup, within 8-use budget.""" + result = {**plan, "auth_provider": "service-jwt", "native_verified": False} + cleanup_returned = False + try: + lookup = request(session.client, "auth/token/lookup-self") + # Effective identity policies matter even when default policy is absent. + policies = lookup.get("policies") + identities = lookup.get("identity_policies", []) + result["exact_self_policy"] = policies == [plan["expected_policy"]] and identities == [] + caps = request(session.client, "sys/capabilities-self", {"paths": plan["denied_paths"]}) + result["capability_denials"] = {path: caps.get(path) == ["deny"] for path in plan["denied_paths"]} + except Exception: + result["failure"] = "metadata verification failed; response suppressed" + finally: + try: + session.close() + cleanup_returned = True + except Exception: + result["failure"] = "service revocation failed" + result.update(safe_evidence(session)) + evidence_complete = ( + result.get("secret_source") == "openbao-oidc-in-memory" + and result.get("established") is True + and result.get("operator_revocation_attempted") is True + and result.get("operator_revocation_succeeded") is True + and result.get("revocation_attempted") is True + and result.get("revocation_succeeded") is True + and all(isinstance(result.get(key), str) and re.fullmatch(r"[0-9a-f]{12}", result[key]) + for key in ("operator_session_handle", "session_handle")) + ) + result["native_verified"] = bool( + cleanup_returned and "failure" not in result and evidence_complete + and result.get("exact_self_policy") and all(result.get("capability_denials", {}).values()) + and len(result.get("capability_denials", {})) == len(plan["denied_paths"]) + and result.get("operator_revocation_succeeded") and result.get("revocation_succeeded") + ) + # These are deliberately separate from boundary acceptance. + result["claim_negatives_proved"] = False + result["protected_operation_adoption_proved"] = False + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ccr", type=Path, required=True) + parser.add_argument("--execute", action="store_true", help="attended live self metadata check; requires scoped authorization") + args = parser.parse_args() + try: + cfg = Config.load() + plan = prepare(cfg, args.ccr) + result = verify_session( + login_service_jwt(cfg), plan, + lambda client, path, payload=None: request_json(client, path, payload, expected_addr=cfg.bao_addr), + ) if args.execute else { + **plan, "mode": "backend-free-preparation", "native_verified": False, + "claim_negatives_proved": False, "protected_operation_adoption_proved": False, + } + except Exception: + print(json.dumps({"native_verified": False, "failure": "preflight/login failed; details suppressed"})) + return 1 + print(json.dumps(result, sort_keys=True)) + return 0 if not args.execute or result["native_verified"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md index 54cf493..b0d05c9 100644 --- a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md +++ b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md @@ -332,7 +332,7 @@ Acceptance: id: SECRETS-WP-0008-T06 status: wait depends_on: [RPF-WP-0035-T02] -blocking_reason: "Positive service-JWT login proof passed 2026-10-10 (SECRETS-WP-0012-T03, docs/evidence/2026-10-10-keycape-openbao-probe.json). Waits on railiance-platform closing RPF-WP-0035-T02 with the CCR-2026-0038 negative checks, then on routing live engine actions through service-jwt instead of bootstrap/env." +blocking_reason: "Published JWT contract and positive login/revocation are delivered. Native KEY-WP-0039-T02 claim negatives, CCR-2026-0038 effective-policy/capability/expiry/use checks and MASON-WP-0009-T05 remain. Login-self-only grants no protected-operation authority: agree and implement separately reviewed task-scoped backend execution entitlement, then prove functional steady-state service-identity adoption without bootstrap/env fallback." priority: medium state_hub_task_id: "d7bc8bdc-a0f8-5058-a640-374ef9859148" ``` @@ -352,8 +352,10 @@ argv), then self-revokes. A JWT selection refuses `--bootstrap-token-file` and does not read `BAO_TOKEN` on failure. `--bootstrap-token-file` is a named break-glass provider with `auth_break_glass` evidence. -The platform-owned OpenBao JWT mount/role is still unpublished, so auto keeps -bootstrap/env and this task remains `wait`. +Historical 2026-09-02 state, superseded 2026-10-10: the platform-owned +OpenBao JWT mount/role was then unpublished. The consumer contract is now +published; native negative/capability checks and functional task-scoped backend +execution adoption remain open, so T06 remains `wait`. Inbox triage 2026-09-21. railiance-platform `29cccf8a` (2026-09-09): status, not a contract. Their side is RPF-WP-0035-T02 (design @@ -549,3 +551,20 @@ T06 still depends on RPF-WP-0035-T02, whose current file remains wait for live platform service registration/custody and scoped JWT provisioning plus native login, denial and revocation evidence. Source tenant corrections are complete. The workplan is now blocked; no new task or workplan was created. + +### 2026-10-11 final gate preparation + +Completed preparation record: `docs/service-jwt-final-acceptance.md` and +`tools/verify_service_jwt_boundary.py`; synthetic acceptance tests cover added +identity policy, omitted deny, metadata failure, failed cleanup, redirects, +environment proxies, bounded transport, complete typed evidence, stale cleanup +claims and mutually exclusive backend error/data responses. The tool +default is backend-free and cannot claim native success. It prepares the exact +applied CCR capability batch and preserves the eight-use cleanup budget. + +The actual CLI consumes approval before supplying its selected backend token. +The current service token grants self endpoints only, and no delegated backend +entitlement is implemented. Configuring auto now would not prove functional +protected-action adoption. T06 stays wait; no provider default, policy, secret, +live registration or owner acknowledgment changed. Existing live owner tasks +retain all remaining acceptance and execution entitlement work.