Harden production authorization and service auth
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
parent
f579f3761c
commit
70371649af
20 changed files with 1268 additions and 54 deletions
225
tests/test_action_authorization.py
Normal file
225
tests/test_action_authorization.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
159
tests/test_service_auth.py
Normal 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",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue