secrets-engine/tests/test_auth_capability.py
tegwick 3a1bd4f1c8
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Harden secret provisioning and lifecycle controls
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
2026-08-23 12:05:58 +02:00

247 lines
7.9 KiB
Python

import copy
import os
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
import pytest
from secrets_engine.apply import apply_plan
from secrets_engine.catalog import load_catalog, validate_entry
from secrets_engine.config import repo_root
from secrets_engine.errors import PolicyGuardError, ProvisioningError
from secrets_engine.handoff import write_approle_handoff
from secrets_engine.plan import build_plan
from secrets_engine.provision import provision_from_file
from secrets_engine.routing import route_lane
from secrets_engine.verify import run_verification
AUTH = {
"id": "warden-sign",
"kind": "auth-capability",
"org": "netkingdom",
"repo": "ops-warden",
"stage": "prod",
"mount": "ssh",
"path": "sign",
"consumers": [
{"name": "ops-warden-policy-smoke", "auth": "approle", "claim": "agent:agt"}
],
"delivery_modes": ["approle-login"],
"approval": {"model": "decision", "decision_ref": "SECRETS-WP-0004"},
"verification": {"positive": "allowlisted sign", "negative": "deny probes"},
"auth_capability": {
"policy_name": "warden-sign",
"role_name": "warden-sign",
"token_ttl": "15m",
"token_max_ttl": "15m",
"secret_id_ttl": "30m",
"secret_id_num_uses": 1,
"token_num_uses": 0,
"allowed_paths": [
{"path": "ssh/sign/agt-role", "capabilities": ["update"]},
{"path": "ssh/sign/adm-role", "capabilities": ["update"]},
{"path": "ssh/sign/atm-role", "capabilities": ["update"]},
],
"denied_probe_paths": ["ssh/sign/unlisted-role", "ssh/roles/agt-role"],
},
"rotation": {"expectation": "single-use handoff", "ttl": "15m"},
"deactivation": {"expectation": "delete approle and policy"},
"audit": {"evidence": "non-secret pointers only"},
}
def _auth_entry(**over):
data = copy.deepcopy(AUTH)
for key, value in over.items():
if key == "auth_capability":
data[key].update(value)
else:
data[key] = value
return validate_entry(data)
def test_auth_capability_entry_parses_without_fields():
entry = _auth_entry()
assert entry.kind == "auth-capability"
assert entry.fields == []
assert entry.policy_name == "warden-sign"
assert entry.role_name == "warden-sign"
assert sorted(entry.auth_allowed_paths) == [
"ssh/sign/adm-role",
"ssh/sign/agt-role",
"ssh/sign/atm-role",
]
def test_repo_catalog_loads_warden_sign_lane():
entries = load_catalog(repo_root() / "catalog")
entry = entries["warden-sign"]
assert entry.kind == "auth-capability"
assert entry.mount == "ssh"
assert entry.token_ttl == "15m"
assert entry.secret_id_num_uses == 1
def test_auth_capability_rejects_kv_fields():
data = copy.deepcopy(AUTH)
data["fields"] = ["token"]
with pytest.raises(Exception):
validate_entry(data)
def test_auth_capability_plan_has_no_kv_mount():
entry = _auth_entry()
plan = build_plan(entry, "prod", decision_id="d1")
assert not any(action.kind == "kv-mount" for action in plan.actions)
assert any(action.kind == "policy" and action.target == "warden-sign" for action in plan.actions)
assert "ssh/sign/agt-role" in plan.policy_hcl
assert "ssh/sign/adm-role" in plan.policy_hcl
assert "ssh/sign/atm-role" in plan.policy_hcl
assert "secret/data" not in plan.policy_hcl
@pytest.mark.parametrize(
"path",
[
"ssh/*",
"ssh/sign/*",
"ssh/roles/agt-role",
"sys/policies/acl/warden-sign",
"auth/token/create",
"identity/entity/id",
],
)
def test_auth_capability_broad_or_admin_paths_refused(path):
entry = _auth_entry(
auth_capability={
"allowed_paths": [{"path": path, "capabilities": ["update"]}],
}
)
with pytest.raises(PolicyGuardError):
build_plan(entry, "prod")
def test_auth_capability_root_like_policy_name_refused():
entry = _auth_entry(auth_capability={"policy_name": "root-warden-sign"})
with pytest.raises(PolicyGuardError):
build_plan(entry, "prod")
def test_auth_capability_provision_rejected(tmp_path):
value_file = tmp_path / "value"
value_file.write_text("not-real-secret")
os.chmod(value_file, 0o600)
with pytest.raises(ProvisioningError):
provision_from_file(object(), _auth_entry(), "", value_file)
class FakeApplyClient:
def __init__(self):
self.policy = ""
self.approle = None
def kv_mount_exists(self, mount):
raise AssertionError("auth-capability apply must not inspect KV mounts")
def ensure_kv_mount(self, mount):
raise AssertionError("auth-capability apply must not create KV mounts")
def read_policy(self, name):
return None
def write_policy(self, name, hcl):
self.policy = hcl
def ensure_approle_enabled(self):
pass
def write_approle(self, role_name, policies, ttl="30m", **kwargs):
self.approle = (role_name, policies, ttl, kwargs)
def test_apply_auth_capability_bypasses_kv_and_writes_ttl_options():
entry = _auth_entry()
plan = build_plan(entry, "prod", decision_id="d1")
client = FakeApplyClient()
result = apply_plan(client, entry, plan)
assert any("not applicable" in item for item in result.skipped)
assert "ssh/sign/agt-role" in client.policy
assert client.approle[0] == "warden-sign"
assert client.approle[2] == "15m"
assert client.approle[3]["secret_id_num_uses"] == 1
assert client.approle[3]["token_num_uses"] == 0
class FakeVerifyClient:
def __init__(self):
self.token = "test-token"
self.sessions_closed = 0
@contextmanager
def approle_session(self, role_name):
assert role_name == "warden-sign"
try:
yield SimpleNamespace(client=self)
finally:
self.sessions_closed += 1
def token_capabilities(self, path, *, token):
assert token == "test-token"
if path.startswith("ssh/sign/") and path != "ssh/sign/unlisted-role":
return ["update"]
return ["deny"]
def test_auth_capability_verification_uses_capability_probes():
client = FakeVerifyClient()
results = run_verification(
client, _auth_entry(), "", positive=True, negative=True
)
assert [result.passed for result in results] == [True, True]
assert client.sessions_closed == 2
class FakeHandoffClient:
def read_approle_role_id(self, role_name):
assert role_name == "warden-sign"
return "role-id-test"
def create_approle_secret_id(self, role_name):
assert role_name == "warden-sign"
return "secret-id-test"
def test_handoff_rejects_repo_paths():
with pytest.raises(ProvisioningError):
write_approle_handoff(
FakeHandoffClient(),
_auth_entry(),
role_id_file=Path("role-id.out"),
secret_id_file=Path("secret-id.out"),
)
def test_handoff_writes_mode_0600_files(tmp_path):
role_file = tmp_path / "role-id"
secret_file = tmp_path / "secret-id"
result = write_approle_handoff(
FakeHandoffClient(),
_auth_entry(),
role_id_file=role_file,
secret_id_file=secret_file,
)
assert result.role_name == "warden-sign"
assert role_file.read_text().strip() == "role-id-test"
assert secret_file.read_text().strip() == "secret-id-test"
assert (role_file.stat().st_mode & 0o077) == 0
assert (secret_file.stat().st_mode & 0o077) == 0
def test_route_missing_decision_blocks_auth_capability(tmp_path):
result = route_lane(_auth_entry(), hub_url="", repo_root=tmp_path, client=None)
assert result.kind == "auth-capability"
assert result.decision_status == "missing"
assert result.ready is False
assert result.missing == "approved decision for 'SECRETS-WP-0004'"
assert result.next_command == "secrets-engine decision inspect SECRETS-WP-0004"