diff --git a/src/warden/policy.py b/src/warden/policy.py index 140c8c4..31b9161 100644 --- a/src/warden/policy.py +++ b/src/warden/policy.py @@ -131,6 +131,12 @@ def check_sign_policy(cfg: PolicyConfig, spec: CertSpec) -> str | None: response = httpx.post(url, json=request, headers=headers, timeout=10.0) response.raise_for_status() except httpx.HTTPStatusError as e: + if e.response.status_code in {401, 403}: + spec.policy_outcome = "deny" + raise CAError( + f"flex-auth refused sign policy check (HTTP {e.response.status_code}); " + "caller authentication or system binding was rejected" + ) from e _evaluator_failure( f"flex-auth rejected sign policy check (HTTP {e.response.status_code}) " f"for security zone {zone!r}", @@ -213,6 +219,11 @@ def check_fetch_policy( response = httpx.post(url, json=request, headers=headers, timeout=10.0) response.raise_for_status() except httpx.HTTPStatusError as e: + if e.response.status_code in {401, 403}: + raise CAError( + f"flex-auth refused fetch policy check (HTTP {e.response.status_code}); " + "caller authentication or system binding was rejected" + ) from e _evaluator_failure( f"flex-auth rejected fetch policy check (HTTP {e.response.status_code})", fail_closed=fail_closed, diff --git a/tests/test_policy_http_refusal.py b/tests/test_policy_http_refusal.py new file mode 100644 index 0000000..09fed46 --- /dev/null +++ b/tests/test_policy_http_refusal.py @@ -0,0 +1,48 @@ +"""An explicit caller refusal cannot authorize a CA or credential side effect.""" +from unittest.mock import patch + +import httpx +import pytest + +from warden.ca import CAError +from warden.config import PolicyConfig +from warden.models import ActorType, CertSpec +from warden.policy import check_fetch_policy, check_sign_policy + + +@pytest.mark.parametrize("status", [401, 403]) +@pytest.mark.parametrize("failure_mode", ["fail_open", "fail_closed"]) +@pytest.mark.parametrize("operation", ["sign", "fetch"]) +def test_explicit_refusal_blocks_under_every_outage_mode(tmp_path, status, failure_mode, operation): + cfg = PolicyConfig(flex_auth_url="http://pdp.test") + cfg.failure_modes["unknown"] = failure_mode + public_key = tmp_path / "id.pub" + public_key.write_text("ssh-ed25519 AAAA test\n") + spec = CertSpec(actor_name="agt-example", actor_type=ActorType.AGT, + pubkey_path=public_key, ttl_hours=1, principals=["agt"]) + response = httpx.Response(status, request=httpx.Request("POST", "http://pdp.test/v1/check"), + text="untrusted response body must not be exposed") + with patch("warden.policy.httpx.post", return_value=response): + with pytest.raises(CAError, match=f"HTTP {status}") as error: + if operation == "sign": + check_sign_policy(cfg, spec) + else: + check_fetch_policy(cfg, need_id="forgejo-admin-api-token", + owner_repo="railiance-platform", domain=None) + assert "untrusted response" not in str(error.value) + if operation == "sign": + assert spec.policy_outcome == "deny" + + +@pytest.mark.parametrize("status", [500, 503]) +@pytest.mark.parametrize("failure_mode", ["fail_open", "fail_closed"]) +def test_actual_evaluator_failure_retains_declared_outage_mode(status, failure_mode): + cfg = PolicyConfig(flex_auth_url="http://pdp.test") + cfg.failure_modes["unknown"] = failure_mode + response = httpx.Response(status, request=httpx.Request("POST", "http://pdp.test/v1/check")) + with patch("warden.policy.httpx.post", return_value=response): + if failure_mode == "fail_closed": + with pytest.raises(CAError, match=f"HTTP {status}"): + check_fetch_policy(cfg, need_id="example", owner_repo="example", domain=None) + else: + assert check_fetch_policy(cfg, need_id="example", owner_repo="example", domain=None) is None diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 9955add..34f961e 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -234,6 +234,28 @@ def test_cli_proxy_requires_caller_auth(monkeypatch, tmp_path): assert r.exit_code == 3 +@pytest.mark.parametrize("status", [401, 403]) +def test_cli_explicit_policy_refusal_never_fetches_or_starts_child(monkeypatch, tmp_path, status): + import httpx + + _proxy_env(monkeypatch, tmp_path) + cfg = tmp_path / "warden.yaml" + cfg.write_text(cfg.read_text() + "policy:\n flex_auth_url: http://pdp.test\n") + monkeypatch.setenv("VAULT_TOKEN", "caller-test-value") + monkeypatch.setattr( + "warden.policy.httpx.post", + lambda *a, **k: httpx.Response(status, request=httpx.Request("POST", "http://pdp.test/v1/check")), + ) + calls = [] + for name in ("proxy_exec", "proxy_fetch", "proxy_fetch_to_file", "proxy_fetch_wrapped"): + monkeypatch.setattr("warden.proxy." + name, lambda *a, **k: calls.append(True)) + result = runner.invoke(app, ["access", "forgejo-admin-api-token", "--exec", "--field", "API_TOKEN", "--", "true"]) + assert result.exit_code == 4 + assert f"HTTP {status}" in result.output + assert "fail_open applied" not in result.output + assert calls == [] + + def test_cli_proxy_rejects_retired_no_policy_bypass(monkeypatch, tmp_path): _proxy_env(monkeypatch, tmp_path) monkeypatch.setenv("VAULT_TOKEN", "caller") diff --git a/wiki/PolicyGatedSigning.md b/wiki/PolicyGatedSigning.md index b711a32..07e89d2 100644 --- a/wiki/PolicyGatedSigning.md +++ b/wiki/PolicyGatedSigning.md @@ -134,6 +134,11 @@ truth. ## Caller identity +An HTTP 401 or 403 from the policy service refuses the operation under every +outage profile. It is an explicit caller-authentication or system-binding refusal, +not evaluator unavailability. Resolve the admitted caller and resource contract; +do not retry with a different resource owner or disable caller enforcement. + The production flex-auth pin authenticates ops-warden with Kubernetes TokenReview and binds `resource.system: ops-warden` to `system:serviceaccount:ops-warden:ops-warden`. Supported token sources are: diff --git a/workplans/WARDEN-WP-0039-explicit-policy-refusal.md b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md new file mode 100644 index 0000000..05920ae --- /dev/null +++ b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md @@ -0,0 +1,65 @@ +--- +id: WARDEN-WP-0039 +type: workplan +title: "Preserve explicit policy caller refusals before credential and CA effects" +domain: infotech +repo: ops-warden +status: active +owner: codex +topic_slug: custodian +created: "2026-09-08" +updated: "2026-09-08" +origin: residual +origin_ref: HFACT-WP-0001 +--- + +The factory critical-path review reproduced a live HTTP 403 from the reachable +PDP for the configured ops-warden caller and a railiance-platform secret read. +`check_fetch_policy` treated it as evaluator unavailability and used unknown-zone +fail-open. Caller-auth rejection is an explicit refusal, not a transport outage. + +## Preserve explicit authentication and binding refusals + +```task +id: WARDEN-WP-0039-T01 +status: done +priority: high +assignee: the-custodian +``` + +HTTP 401 and 403 stop signing and credential fetch irrespective of the outage +profile. Do not expose response bodies or alter system identity to bypass the +refusal. Preserve configured behavior for genuine evaluator failures. Prove +both permissive and closed outage profiles and refusal before child execution. + +## Verify and publish the correction + +```task +id: WARDEN-WP-0039-T02 +status: progress +priority: high +assignee: the-custodian +``` + +Run the policy and proxy suites, retain a value-free live refusal receipt, +publish the reviewed source, and verify the installed CLI refuses before any +credential transport. A refusal is not a successful credential-read admission. + +## Resolve the credential proxy's admitted policy binding + +```task +id: WARDEN-WP-0039-T03 +status: wait +priority: high +assignee: the-custodian +blocking_reason: "The configured ops-warden caller represents ops-warden; credential requests name their owner as resource.system. Need the flex-auth/credential-owner contract for that exact delegated read, without broadening caller bindings or relabelling resource ownership." +``` + +Consume the existing native-lane handoff (WARDEN-WP-0033 / SECRETS-WP-0006) +and flex-auth caller contract. Establish whether this interim transport needs an +admitted dedicated policy route or must finish its native handoff. Retain the +refusal until that contract yields positive and wrong-caller/owner/tenant +negative evidence. No credential read, secret generation, or policy grant is +authorized by this workplan alone. HFACT-WP-0001-T03 consumes this return. + +Validation: 429 tests passed (4 integration tests deselected by the repository default); Ruff passed for changed Python files. Full tests used the declared phase-memory source and an isolated temporary memory store. The focused policy/proxy suite passed 69 tests. The existing authenticated SSH policy probe still returns HTTP 200/ALLOW, decision:f3f7c88f9585582a; the credential-owner request returns 403. No CA issue or credential read was performed by these probes.