237 lines
8.6 KiB
Python
237 lines
8.6 KiB
Python
|
|
"""In-memory OpenBao source for the secrets-engine-openbao client secret.
|
||
|
|
|
||
|
|
Synthetic values only. Proves the operator token and the client secret never
|
||
|
|
reach argv, env, stdout/stderr or files, that revoke-self always runs, and that
|
||
|
|
the file source and ambient tokens are refused for this profile.
|
||
|
|
"""
|
||
|
|
import functools
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from urllib.error import HTTPError
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from secrets_engine import cli, engine_auth
|
||
|
|
from secrets_engine.errors import BackendError
|
||
|
|
from secrets_engine.openbao import OpenBaoClient, ScopedTokenSession
|
||
|
|
from secrets_engine.service_auth import (
|
||
|
|
KeyCapeServiceAuthProvider,
|
||
|
|
OpenBaoOidcClientSecretSource,
|
||
|
|
)
|
||
|
|
from tests.test_engine_auth import _cfg, _jwt_contract
|
||
|
|
from tests.test_service_auth import _jwt
|
||
|
|
|
||
|
|
SECRET = "synthetic-client-secret-0123456789"
|
||
|
|
OP_TOKEN = "synthetic-operator-token-abcdef"
|
||
|
|
OP_ACCESSOR = "synthetic-operator-accessor"
|
||
|
|
AMBIENT = "ambient-token-must-not-be-used"
|
||
|
|
ADDR = "https://bao.example.test"
|
||
|
|
|
||
|
|
|
||
|
|
class _Resp:
|
||
|
|
def __init__(self, body: bytes, status: int = 200):
|
||
|
|
self.status, self._body = status, body
|
||
|
|
|
||
|
|
def read(self, _n=-1):
|
||
|
|
return self._body
|
||
|
|
|
||
|
|
def __enter__(self):
|
||
|
|
return self
|
||
|
|
|
||
|
|
def __exit__(self, *_):
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
class FakeBao:
|
||
|
|
def __init__(self, kv_status=200, revoke_status=204):
|
||
|
|
self.kv_status, self.revoke_status = kv_status, revoke_status
|
||
|
|
self.argv, self.env, self.requests = [], [], []
|
||
|
|
|
||
|
|
def runner(self, argv, env):
|
||
|
|
self.argv.append(list(argv))
|
||
|
|
self.env.append(dict(env))
|
||
|
|
out = {"auth": {"client_token": OP_TOKEN, "accessor": OP_ACCESSOR}}
|
||
|
|
return 0, json.dumps(out).encode()
|
||
|
|
|
||
|
|
def transport(self, request, *, timeout):
|
||
|
|
self.requests.append(
|
||
|
|
(request.get_method(), request.full_url, dict(request.header_items()))
|
||
|
|
)
|
||
|
|
if request.full_url.endswith("/v1/platform/data/workloads/keycape/secrets-engine-openbao"):
|
||
|
|
if self.kv_status != 200:
|
||
|
|
raise HTTPError(request.full_url, self.kv_status, "denied", {}, None)
|
||
|
|
return _Resp(json.dumps({"data": {"data": {"CLIENT_SECRET": SECRET}}}).encode())
|
||
|
|
if request.full_url.endswith("/v1/auth/token/revoke-self"):
|
||
|
|
return _Resp(b"", self.revoke_status)
|
||
|
|
pytest.fail(f"unexpected request {request.full_url}")
|
||
|
|
|
||
|
|
|
||
|
|
def _source(fake, **kw):
|
||
|
|
return OpenBaoOidcClientSecretSource(
|
||
|
|
bao_addr=ADDR, runner=fake.runner, transport=fake.transport, **kw
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_no_leak(fake, *texts):
|
||
|
|
for value in (SECRET, OP_TOKEN, OP_ACCESSOR):
|
||
|
|
for argv in fake.argv:
|
||
|
|
assert value not in " ".join(argv)
|
||
|
|
for env in fake.env:
|
||
|
|
assert value not in " ".join(f"{k}={v}" for k, v in env.items())
|
||
|
|
assert value not in " ".join(f"{k}={v}" for k, v in os.environ.items())
|
||
|
|
for text in texts:
|
||
|
|
assert value not in text
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_no_files(root):
|
||
|
|
for dirpath, _dirs, files in os.walk(root):
|
||
|
|
for name in files:
|
||
|
|
data = open(os.path.join(dirpath, name), "rb").read()
|
||
|
|
assert SECRET.encode() not in data and OP_TOKEN.encode() not in data
|
||
|
|
|
||
|
|
|
||
|
|
def test_reads_in_memory_with_header_only_and_revokes(monkeypatch, tmp_path, capsys):
|
||
|
|
monkeypatch.setenv("BAO_TOKEN", AMBIENT)
|
||
|
|
monkeypatch.setenv("VAULT_TOKEN", AMBIENT)
|
||
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||
|
|
fake = FakeBao()
|
||
|
|
src = _source(fake)
|
||
|
|
assert src() == SECRET
|
||
|
|
assert fake.argv == [[
|
||
|
|
"bao", "login", "-method=oidc", "-path=netkingdom", "-no-store",
|
||
|
|
"-format=json", "role=keycape-secrets-engine-openbao-operator-workload-kv-read",
|
||
|
|
]]
|
||
|
|
env = fake.env[0]
|
||
|
|
assert "BAO_TOKEN" not in env and "VAULT_TOKEN" not in env
|
||
|
|
assert env["BAO_ADDR"] == ADDR
|
||
|
|
(get, kv_url, kv_headers), (post, rv_url, rv_headers) = fake.requests
|
||
|
|
assert (get, post) == ("GET", "POST")
|
||
|
|
assert kv_url == ADDR + "/v1/platform/data/workloads/keycape/secrets-engine-openbao"
|
||
|
|
assert rv_url == ADDR + "/v1/auth/token/revoke-self"
|
||
|
|
for headers in (kv_headers, rv_headers):
|
||
|
|
assert headers["X-vault-token"] == OP_TOKEN
|
||
|
|
assert AMBIENT not in json.dumps(headers)
|
||
|
|
ev = src.evidence()
|
||
|
|
assert ev == {
|
||
|
|
"secret_source": "openbao-oidc-in-memory",
|
||
|
|
"operator_session_handle": ev["operator_session_handle"],
|
||
|
|
"operator_revocation_attempted": True,
|
||
|
|
"operator_revocation_succeeded": True,
|
||
|
|
}
|
||
|
|
assert len(ev["operator_session_handle"]) == 12
|
||
|
|
out, err = capsys.readouterr()
|
||
|
|
_assert_no_leak(fake, out, err, repr(src), json.dumps(ev))
|
||
|
|
_assert_no_files(tmp_path)
|
||
|
|
|
||
|
|
|
||
|
|
def test_revoke_self_runs_when_kv_read_is_denied(capsys):
|
||
|
|
fake = FakeBao(kv_status=403)
|
||
|
|
src = _source(fake)
|
||
|
|
with pytest.raises(BackendError, match="HTTP 403") as error:
|
||
|
|
src()
|
||
|
|
assert [r[0] for r in fake.requests] == ["GET", "POST"]
|
||
|
|
assert src.revocation_attempted and src.revocation_succeeded
|
||
|
|
_assert_no_leak(fake, str(error.value), *capsys.readouterr())
|
||
|
|
|
||
|
|
|
||
|
|
def test_failed_login_makes_no_request():
|
||
|
|
fake = FakeBao()
|
||
|
|
src = _source(fake)
|
||
|
|
src.runner = lambda argv, env: (2, b"")
|
||
|
|
with pytest.raises(BackendError, match="exit 2"):
|
||
|
|
src()
|
||
|
|
assert fake.requests == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"override",
|
||
|
|
[
|
||
|
|
{"oidc_role": "platform-admin"},
|
||
|
|
{"oidc_mount": "oidc"},
|
||
|
|
{"kv_path": "workloads/keycape/other"},
|
||
|
|
{"kv_field": "OTHER"},
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_coordinates_are_pinned(override):
|
||
|
|
with pytest.raises(BackendError, match="reviewed plan"):
|
||
|
|
_source(FakeBao(), **override)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("addr", ["http://bao.example.test", "ftp://x", "", "https://u:p@bao"])
|
||
|
|
def test_address_must_be_https_or_loopback(addr):
|
||
|
|
with pytest.raises(BackendError):
|
||
|
|
OpenBaoOidcClientSecretSource(bao_addr=addr)
|
||
|
|
OpenBaoOidcClientSecretSource(bao_addr="http://127.0.0.1:8200")
|
||
|
|
|
||
|
|
|
||
|
|
def test_engine_refuses_consumer_file_for_openbao_profile(tmp_path):
|
||
|
|
secret = tmp_path / "client.secret"
|
||
|
|
secret.write_text(SECRET)
|
||
|
|
secret.chmod(0o600)
|
||
|
|
cfg = _cfg(
|
||
|
|
tmp_path,
|
||
|
|
openbao_jwt_login_file=_jwt_contract(tmp_path),
|
||
|
|
keycape_token_url="https://keycape.example.test/token",
|
||
|
|
keycape_issuer="https://keycape.example.test",
|
||
|
|
keycape_client_secret_file=secret,
|
||
|
|
)
|
||
|
|
with pytest.raises(BackendError, match="refused for the secrets-engine-openbao") as error:
|
||
|
|
engine_auth.login_service_jwt(cfg)
|
||
|
|
assert SECRET not in str(error.value)
|
||
|
|
|
||
|
|
|
||
|
|
def test_probe_end_to_end_is_value_free(tmp_path, monkeypatch, capsys):
|
||
|
|
monkeypatch.setenv("BAO_TOKEN", AMBIENT)
|
||
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||
|
|
fake = FakeBao()
|
||
|
|
keycape_auth = []
|
||
|
|
|
||
|
|
def keycape_transport(request, *, timeout):
|
||
|
|
keycape_auth.append(request.get_header("Authorization"))
|
||
|
|
return _Resp(json.dumps(
|
||
|
|
{"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900}
|
||
|
|
).encode())
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
engine_auth, "OpenBaoOidcClientSecretSource",
|
||
|
|
functools.partial(
|
||
|
|
OpenBaoOidcClientSecretSource, runner=fake.runner, transport=fake.transport
|
||
|
|
),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
engine_auth, "KeyCapeServiceAuthProvider",
|
||
|
|
functools.partial(KeyCapeServiceAuthProvider, transport=keycape_transport),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr("secrets_engine.service_auth.preflight_service_jwt",
|
||
|
|
lambda token, config, now=None: SimpleNamespace(token=token))
|
||
|
|
jwt_logins = []
|
||
|
|
|
||
|
|
def fake_login_jwt(self, mount, role, jwt):
|
||
|
|
jwt_logins.append((mount, role))
|
||
|
|
client = OpenBaoClient(addr=self.addr, token="engine-token", bao_bin="bao")
|
||
|
|
monkeypatch.setattr(client, "_run_ok", lambda args, **_k: "")
|
||
|
|
return ScopedTokenSession(client=client, accessor_fingerprint="0123456789ab")
|
||
|
|
|
||
|
|
monkeypatch.setattr(OpenBaoClient, "login_jwt", fake_login_jwt)
|
||
|
|
cfg = _cfg(
|
||
|
|
tmp_path,
|
||
|
|
bao_addr=ADDR,
|
||
|
|
openbao_jwt_login_file=_jwt_contract(tmp_path),
|
||
|
|
keycape_token_url="https://keycape.example.test/token",
|
||
|
|
keycape_issuer="https://keycape.example.test",
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(cli.Config, "load", classmethod(lambda _cls: cfg))
|
||
|
|
rc = cli.main(["service-auth", "probe-openbao", "--json"])
|
||
|
|
out, err = capsys.readouterr()
|
||
|
|
assert rc == 0, err
|
||
|
|
payload = json.loads(out)
|
||
|
|
assert payload["ok"] is True
|
||
|
|
assert payload["secret_source"] == "openbao-oidc-in-memory"
|
||
|
|
assert payload["operator_revocation_succeeded"] is True
|
||
|
|
assert payload["revocation_succeeded"] is True
|
||
|
|
assert len(jwt_logins) == 1
|
||
|
|
assert len(keycape_auth) == 1 and keycape_auth[0].startswith("Basic ")
|
||
|
|
_assert_no_leak(fake, out, err)
|
||
|
|
_assert_no_files(tmp_path)
|