Harden production authorization and service auth
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
tegwick 2026-08-23 14:15:42 +02:00
parent f579f3761c
commit 70371649af
20 changed files with 1268 additions and 54 deletions

View file

@ -0,0 +1,225 @@
import copy
from datetime import datetime, timezone
import pytest
from secrets_engine.authorization import (
build_action_request,
request_digest,
validate_action_authorization,
)
from secrets_engine.catalog import validate_entry
from secrets_engine.errors import DecisionError
from tests.test_catalog import VALID
NOW = datetime(2026, 8, 23, 10, 5, tzinfo=timezone.utc)
def _request():
entry = validate_entry(copy.deepcopy(VALID))
return build_action_request(
entry,
"deactivate",
subject_id="user:alice",
subject_type="Human",
purpose="contract-test",
fields=["api_token"],
policy_targets=[entry.policy_name],
auth_targets=[entry.role_name],
request_id="check:test-lane-deactivate",
)
def _envelope():
request = _request()
return {
"schema_version": "0.1",
"id": "8bfc20be-47a4-4fb0-97a2-bf0a920afad8",
"status": "approved",
"request": request,
"validity": {
"not_before": "2026-08-23T10:00:00Z",
"expires_at": "2026-08-23T10:15:00Z",
},
"approvals": {
"required_count": 2,
"entries": [
{
"subject_id": "user:alice",
"approved_at": "2026-08-23T10:01:00Z",
},
{
"subject_id": "user:bob",
"approved_at": "2026-08-23T10:02:00Z",
},
],
},
"decision": {
"id": "decision:test-lane-deactivate",
"request_id": request["id"],
"effect": "allow",
"resource": copy.deepcopy(request["resource"]),
"subject": copy.deepcopy(request["subject"]),
"binding": {
"subject": copy.deepcopy(request["subject"]),
"action": request["action"],
"resource": copy.deepcopy(request["resource"]),
"context": copy.deepcopy(request["context"]),
"request_digest": request_digest(request),
},
"provenance": {
"evaluator": "flex-auth/local",
"mode": "standalone",
"policy_package": "secrets-engine.lifecycle",
"policy_version": "v1",
},
},
"provenance": {"authority": "state-hub"},
}
def _validate(envelope, expected=None):
return validate_action_authorization(
envelope,
expected or _request(),
accepted_policy_packages={"secrets-engine.lifecycle"},
accepted_policy_versions={"v1"},
minimum_approval_count=2,
now=NOW,
)
def test_digest_matches_flex_auth_contract_example():
request = {
"id": "check:secrets-engine-destroy-example",
"subject": {"id": "user:alice", "type": "Human"},
"action": "destroy",
"resource": {
"id": "catalog:example-build-test-token",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "build",
"fields": ["token"],
"policy_targets": [],
"auth_targets": [],
},
},
"context": {"purpose": "contract-test"},
}
# Generated independently with flex-auth's Go api.CheckRequest and
# encoding/json. The action_authorization.json example carried a stale
# digest when this consumer contract was implemented.
assert request_digest(request) == (
"sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3"
)
def test_valid_exact_action_authorization_passes():
result = _validate(_envelope())
assert result.authorization_id == "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
assert result.action == "deactivate"
assert result.subject_id == "user:alice"
@pytest.mark.parametrize(
("mutation", "match"),
[
(lambda doc: doc.update(status="superseded"), "status is not approved"),
(
lambda doc: doc["request"]["resource"].update(id="catalog:wrong"),
"does not exactly match",
),
(
lambda doc: doc["request"].update(action="destroy"),
"does not exactly match",
),
(
lambda doc: doc["request"]["resource"]["attributes"].update(
fields=["other"]
),
"does not exactly match",
),
(
lambda doc: doc["request"]["context"].update(purpose="wrong"),
"does not exactly match",
),
(
lambda doc: doc["decision"].update(effect="deny"),
"effect is not allow",
),
(
lambda doc: doc["decision"]["binding"].update(
request_digest="sha256:" + "0" * 64
),
"digest does not match",
),
(
lambda doc: doc["approvals"]["entries"][1].update(
subject_id="user:alice"
),
"duplicate approver",
),
(
lambda doc: doc["approvals"].update(required_count=1),
"threshold is insufficient",
),
(
lambda doc: doc["decision"].update(request_id="check:wrong"),
"request id does not match",
),
(
lambda doc: doc["provenance"].update(authority="local-fixture"),
"authority is not State Hub",
),
],
)
def test_invalid_authorizations_fail_closed(mutation, match):
envelope = _envelope()
mutation(envelope)
with pytest.raises(DecisionError, match=match):
_validate(envelope)
def test_expired_authorization_fails_closed():
with pytest.raises(DecisionError, match="expired"):
validate_action_authorization(
_envelope(),
_request(),
accepted_policy_packages={"secrets-engine.lifecycle"},
accepted_policy_versions={"v1"},
now=datetime(2026, 8, 23, 10, 15, tzinfo=timezone.utc),
)
def test_noncanonical_authorization_uuid_is_rejected():
envelope = _envelope()
envelope["id"] = envelope["id"].replace("-", "")
with pytest.raises(DecisionError, match="canonical UUID"):
_validate(envelope)
def test_approval_timestamp_must_be_inside_current_authorization_window():
envelope = _envelope()
envelope["approvals"]["entries"][1]["approved_at"] = "2026-08-23T10:06:00Z"
with pytest.raises(DecisionError, match="approval time is outside window"):
_validate(envelope)
def test_unsorted_or_duplicate_target_sets_are_rejected():
envelope = _envelope()
envelope["request"]["resource"]["attributes"]["fields"] = [
"second",
"first",
"first",
]
with pytest.raises(DecisionError, match="sorted and unique"):
_validate(envelope, expected=envelope["request"])
def test_unaccepted_policy_revision_is_rejected():
envelope = _envelope()
envelope["decision"]["provenance"]["policy_version"] = "v2"
with pytest.raises(DecisionError, match="policy version is not accepted"):
_validate(envelope)

View file

@ -96,6 +96,24 @@ def test_lane_audit_empty_directory_is_a_valid_empty_summary(tmp_path):
assert summary.render().startswith("Lane audit summary for 'test-lane'")
def test_lane_audit_counts_edge_queued_delivery(tmp_path):
path = tmp_path / "evidence-2026-08-23.jsonl"
path.write_text(
json.dumps(
{
"catalog_id": "test-lane",
"action": "evidence-delivery",
"result": "queued",
"detail": {"outbox_id": "3f12014e-47c1-48a5-9c8f-774c1dac1853"},
}
)
+ "\n",
encoding="utf-8",
)
summary = summarize_lane_evidence(tmp_path, "test-lane")
assert summary.hub_delivery == {"queued": 1}
def test_cli_parser_exposes_audit_json_command():
args = build_parser().parse_args(["audit", "test-lane", "--json"])
assert args.catalog_id == "test-lane"

View file

@ -72,3 +72,37 @@ def test_privileged_lane_helper_accepts_local_approval(tmp_path, monkeypatch):
monkeypatch.setattr(cli, "repo_root", lambda: tmp_path)
decision = _require_lane_approval(SimpleNamespace(hub_url=""), _approved())
assert decision.id == "x"
def test_production_action_fails_closed_before_legacy_decision(monkeypatch):
import secrets_engine.cli as cli
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200")
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
with pytest.raises(DecisionError, match="production action 'apply'"):
_require_lane_approval(cfg, entry, "apply")
def test_production_demo_requires_all_three_safety_conditions(tmp_path, monkeypatch):
import secrets_engine.cli as cli
(tmp_path / ".decisions").mkdir()
(tmp_path / ".decisions" / "x.yaml").write_text(
"id: x\ntitle: approved\nstatus: resolved\nsuperseded_by: null\n"
)
entry = validate_entry(
dict(VALID, stage="prod", approval={"model": "decision", "decision_ref": "x"})
)
monkeypatch.setattr(cli, "repo_root", lambda: tmp_path)
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
allowed = SimpleNamespace(hub_url="", bao_addr="http://127.0.0.1:8200")
assert _require_lane_approval(allowed, entry, "apply").id == "x"
for cfg in (
SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200"),
SimpleNamespace(hub_url="", bao_addr="https://bao.example.test"),
):
with pytest.raises(DecisionError, match="live production remains disabled"):
_require_lane_approval(cfg, entry, "apply")

View file

@ -96,3 +96,28 @@ def test_provision_decision_rejection_is_recorded_before_backend(
]
assert records[-1]["detail"]["approval_status"] == "rejected"
assert records[-1]["detail"]["decision_ref"] == "CCR-2026-0001"
def test_production_handler_fails_closed_before_backend(tmp_path, monkeypatch):
data = copy.deepcopy(VALID)
data.update(
stage="prod",
approval={"model": "decision", "decision_ref": "CCR-2026-0001"},
)
entry = validate_entry(data)
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr(
cli.OpenBaoClient,
"resolve",
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
)
with pytest.raises(DecisionError, match="production action 'provision'"):
cli.cmd_provision(_config(tmp_path), _provision_args(entry))
records = _records(tmp_path)
assert [record["result"] for record in records] == [
"attempt",
"failed-DecisionError",
]

View file

@ -47,9 +47,15 @@ def test_evidence_record_has_no_value(tmp_path):
def test_evidence_records_append_only_hub_delivery_success(tmp_path, monkeypatch):
captured = {}
def delivered(request, **_kwargs):
captured["headers"] = dict(request.header_items())
return SimpleNamespace(status=200, read=lambda: b"{}")
monkeypatch.setattr(
"urllib.request.urlopen",
lambda *_args, **_kwargs: SimpleNamespace(read=lambda: b"{}"),
delivered,
)
writer = EvidenceWriter(
evidence_dir=tmp_path,
@ -68,6 +74,62 @@ def test_evidence_records_append_only_hub_delivery_success(tmp_path, monkeypatch
assert lines[1]["action"] == "evidence-delivery"
assert lines[1]["result"] == "delivered"
assert lines[1]["related_record_id"] == primary["record_id"]
assert captured["headers"]["Idempotency-key"] == (
f"secrets-engine:{primary['record_id']}"
)
assert captured["headers"]["X-statehub-source-agent"] == "secrets-engine"
assert captured["headers"]["X-statehub-repo-slug"] == "secrets-engine"
def test_evidence_records_edge_queued_receipt_as_queued(tmp_path, monkeypatch):
outbox_id = "3f12014e-47c1-48a5-9c8f-774c1dac1853"
monkeypatch.setattr(
"urllib.request.urlopen",
lambda *_args, **_kwargs: SimpleNamespace(
status=202,
read=lambda: json.dumps(
{"queued": True, "outbox_id": outbox_id}
).encode(),
),
)
writer = EvidenceWriter(
evidence_dir=tmp_path,
hub_url="http://edge.invalid",
topic_id="topic-id",
)
writer.record("verify", result="pass", catalog_id="lane")
lines = [
json.loads(line)
for line in next(tmp_path.glob("evidence-*.jsonl")).read_text().splitlines()
]
assert lines[-1]["action"] == "evidence-delivery"
assert lines[-1]["result"] == "queued"
assert lines[-1]["detail"] == {"outbox_id": outbox_id}
assert "upstream" not in json.dumps(lines[-1])
def test_evidence_rejects_malformed_queued_receipt(tmp_path, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda *_args, **_kwargs: SimpleNamespace(
status=202,
read=lambda: b'{"queued":true,"outbox_id":"not-a-uuid"}',
),
)
writer = EvidenceWriter(
evidence_dir=tmp_path,
hub_url="http://edge.invalid",
topic_id="topic-id",
)
writer.record("verify", result="pass", catalog_id="lane")
lines = [
json.loads(line)
for line in next(tmp_path.glob("evidence-*.jsonl")).read_text().splitlines()
]
assert lines[-1]["result"] == "failed"
assert lines[-1]["detail"] == {}
def test_evidence_records_hub_failure_without_raising(tmp_path, monkeypatch):

159
tests/test_service_auth.py Normal file
View file

@ -0,0 +1,159 @@
import base64
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
from secrets_engine.errors import BackendError
from secrets_engine.service_auth import (
KeyCapeServiceAuthConfig,
KeyCapeServiceAuthProvider,
preflight_service_jwt,
)
NOW = datetime(2026, 8, 23, 12, 0, tzinfo=timezone.utc)
def _segment(value):
return base64.urlsafe_b64encode(
json.dumps(value, separators=(",", ":")).encode()
).decode().rstrip("=")
def _jwt(**overrides):
now = int(NOW.timestamp())
claims = {
"iss": "https://keycape.example.test",
"sub": "service:secrets-engine",
"aud": "secrets-engine-openbao",
"iat": now,
"exp": now + 900,
"principal_type": "service",
"tenant": "tenant:coulomb",
"roles": ["secrets-engine"],
"groups": [],
"scope": "openbao:login",
"assurance": {
"aal": "AAL1",
"method": "client_secret",
"mfa": False,
"source": "key-cape",
},
}
claims.update(overrides)
return f"{_segment({'alg': 'RS256', 'typ': 'JWT'})}.{_segment(claims)}.signature"
def _config(secret_file=Path("/tmp/keycape-client-secret")):
return KeyCapeServiceAuthConfig(
token_url="https://keycape.example.test/token",
issuer="https://keycape.example.test",
client_secret_file=secret_file,
)
def test_preflight_accepts_exact_service_contract_and_renewal_window():
token = _jwt()
result = preflight_service_jwt(token, _config(), now=NOW)
assert result.expires_at - result.issued_at == 900
assert not result.needs_renewal(NOW)
assert result.needs_renewal(datetime.fromtimestamp(result.expires_at - 180, timezone.utc))
assert token not in repr(result)
@pytest.mark.parametrize(
("override", "match"),
[
({"iss": "https://attacker.test"}, "'iss'"),
({"sub": "service:operator"}, "'sub'"),
({"aud": "different"}, "'aud'"),
({"tenant": "tenant:other"}, "'tenant'"),
({"roles": ["admin"]}, "roles"),
({"scope": "openbao:login admin"}, "scope"),
({"exp": int(NOW.timestamp()) + 901}, "15-minute"),
],
)
def test_preflight_rejects_claim_or_lifetime_drift(override, match):
with pytest.raises(BackendError, match=match):
preflight_service_jwt(_jwt(**override), _config(), now=NOW)
class _Response:
status = 200
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self):
return json.dumps(self.payload).encode()
def test_exchange_uses_basic_auth_and_never_places_secret_in_body(tmp_path):
secret_file = tmp_path / "client.secret"
secret_file.write_text("client-secret-value")
secret_file.chmod(0o600)
captured = {}
def transport(request, *, timeout):
captured["authorization"] = request.get_header("Authorization")
captured["body"] = request.data.decode()
captured["timeout"] = timeout
return _Response(
{
"access_token": _jwt(),
"token_type": "Bearer",
"expires_in": 900,
}
)
provider = KeyCapeServiceAuthProvider(_config(secret_file), transport=transport)
result = provider.exchange(now=NOW)
assert result.expires_at == int(NOW.timestamp()) + 900
assert captured["authorization"].startswith("Basic ")
assert "client-secret-value" not in captured["body"]
assert captured["body"] == "grant_type=client_credentials&scope=openbao%3Alogin"
def test_exchange_rejects_refresh_or_id_tokens_without_exposing_secret(tmp_path):
secret = "do-not-leak-this-client-secret"
secret_file = tmp_path / "client.secret"
secret_file.write_text(secret)
secret_file.chmod(0o600)
def transport(_request, *, timeout):
return _Response(
{
"access_token": _jwt(),
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "also-secret",
}
)
provider = KeyCapeServiceAuthProvider(_config(secret_file), transport=transport)
with pytest.raises(BackendError, match="forbidden extra token") as error:
provider.exchange(now=NOW)
assert secret not in str(error.value)
def test_provider_config_is_exact_and_has_no_bootstrap_fallback():
with pytest.raises(BackendError, match="HTTPS"):
KeyCapeServiceAuthConfig(
token_url="http://keycape.example.test/token",
issuer="https://keycape.example.test",
client_secret_file=Path("/tmp/unused"),
)
with pytest.raises(BackendError, match="accepted contract"):
KeyCapeServiceAuthConfig(
token_url="https://keycape.example.test/token",
issuer="https://keycape.example.test",
client_secret_file=Path("/tmp/unused"),
client_id="operator",
)