Prepare reviewed service JWT boundary verification and retain execution adoption gates
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
443c2698c3
commit
4378ec1fc2
6 changed files with 551 additions and 7 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
111
docs/service-jwt-final-acceptance.md
Normal file
111
docs/service-jwt-final-acceptance.md
Normal file
|
|
@ -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.
|
||||
212
tests/test_service_jwt_boundary.py
Normal file
212
tests/test_service_jwt_boundary.py
Normal file
|
|
@ -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)
|
||||
194
tools/verify_service_jwt_boundary.py
Normal file
194
tools/verify_service_jwt_boundary.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue