From 7d4381e887ffc3fd85d783f50cfa893af825133d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 3 Oct 2026 09:18:54 +0200 Subject: [PATCH] WARDEN: separate fetch evaluator URL for secret-read checks (CUST-WP-0075) check_fetch_policy now posts only to policy.fetch_flex_auth_url (access-engine pin flex-auth-ops-warden-fetch, workstation 127.0.0.1:19091). Signing keeps policy.flex_auth_url. Unset or unreachable fetch evaluator fails closed with a message naming it; no fallback to the signing pin or to allow. warden access advisory output names the fetch evaluator. Recorded under WARDEN-WP-0039-T03. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3 --- examples/warden.production.example.yaml | 3 + src/warden/access.py | 10 +- src/warden/cli.py | 2 + src/warden/config.py | 8 + src/warden/policy.py | 30 ++-- tests/test_policy_fetch_evaluator.py | 138 ++++++++++++++++++ tests/test_policy_http_refusal.py | 4 +- tests/test_proxy.py | 28 +++- wiki/CredentialRouting.md | 14 ++ wiki/OpsWardenConfig.md | 57 +++++++- .../WARDEN-WP-0039-explicit-policy-refusal.md | 26 +++- 11 files changed, 292 insertions(+), 28 deletions(-) create mode 100644 tests/test_policy_fetch_evaluator.py diff --git a/examples/warden.production.example.yaml b/examples/warden.production.example.yaml index 91e7aa7..a004030 100644 --- a/examples/warden.production.example.yaml +++ b/examples/warden.production.example.yaml @@ -24,6 +24,9 @@ policy: # flex-auth.flex-auth.svc Service does not exist. From a workstation, reach it # through a port-forward or tunnel and point this at that local address. flex_auth_url: http://flex-auth-ops-warden.flex-auth.svc.cluster.local:8080 + # Fetch-check pin (CUST-WP-0075), used only by `warden access` fetches. + # No fallback to flex_auth_url; unset means every fetch is refused. + fetch_flex_auth_url: http://flex-auth-ops-warden-fetch.flex-auth.svc.cluster.local.:8080 zone_registry_path: registry/flex-auth/production_registry_snapshot.json failure_modes: z0-experimental: fail_open diff --git a/src/warden/access.py b/src/warden/access.py index 0b60704..1f217d9 100644 --- a/src/warden/access.py +++ b/src/warden/access.py @@ -98,6 +98,10 @@ def policy_gate_status() -> str: cfg = load_config() except ConfigError: return "advisory — no warden.yaml (caller identity; gate not enforced)" - if cfg.policy.flex_auth_url: - return f"zone-aware — flex-auth at {cfg.policy.flex_auth_url}" - return "zone-aware — evaluator unconfigured; unknown-zone fail_open applies" + fetch_url = cfg.policy.fetch_flex_auth_url + if fetch_url: + return f"zone-aware — fetches gated by flex-auth-ops-warden-fetch at {fetch_url}" + return ( + "fail-closed — secret-read evaluator unconfigured " + "(policy.fetch_flex_auth_url unset); fetches are refused" + ) diff --git a/src/warden/cli.py b/src/warden/cli.py index 2dce2db..a7fdd46 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -1551,6 +1551,8 @@ def access( ) if expanded.policy_ref: console.print(f" policy : {expanded.policy_ref} [dim]({gate})[/dim]") + elif entry.exec_capable: + console.print(f" gate : [dim]{gate}[/dim]") console.print(f" wiki : {entry.wiki_ref}") console.print(f" canon : {entry.canon_ref}") diff --git a/src/warden/config.py b/src/warden/config.py index 8cf83f3..d0a612a 100644 --- a/src/warden/config.py +++ b/src/warden/config.py @@ -45,7 +45,11 @@ class CallerAuthConfig: @dataclass class PolicyConfig: + # SSH signing pin (flex-auth-ops-warden). Admits only resource.system ops-warden. flex_auth_url: Optional[str] = None + # Secret-read pin (flex-auth-ops-warden-fetch, CUST-WP-0075). Used only by + # check_fetch_policy; there is no fallback to flex_auth_url. + fetch_flex_auth_url: Optional[str] = None zone_registry_path: Optional[Path] = None failure_modes: Dict[str, str] = field( default_factory=lambda: { @@ -211,8 +215,12 @@ def load_config(path: Optional[Path] = None) -> WardenConfig: ) zone_registry_path = policy_raw.get("zone_registry_path") flex_auth_url = str(policy_raw.get("flex_auth_url", "")).strip() or None + fetch_flex_auth_url = ( + str(policy_raw.get("fetch_flex_auth_url", "") or "").strip() or None + ) policy_cfg = PolicyConfig( flex_auth_url=flex_auth_url, + fetch_flex_auth_url=fetch_flex_auth_url, zone_registry_path=( Path(os.path.expanduser(str(zone_registry_path))) if zone_registry_path diff --git a/src/warden/policy.py b/src/warden/policy.py index 31b9161..3476259 100644 --- a/src/warden/policy.py +++ b/src/warden/policy.py @@ -191,15 +191,21 @@ def check_fetch_policy( The action is ``read`` on a ``secret`` resource owned by another subsystem — ops-warden is the conduit, not the owner. Unresolved target workload identity selects the explicit ``unknown`` profile; no secret value enters the request. + + The check goes only to ``cfg.fetch_flex_auth_url`` — the secret-read pin + (flex-auth-ops-warden-fetch, CUST-WP-0075). It never falls back to the SSH + signing pin (``cfg.flex_auth_url``), whose caller binding admits only the + ``ops-warden`` system. An unset or unreachable fetch evaluator always fails + closed, whatever the zone failure mode. """ zone = "unknown" fail_closed = _is_fail_closed(cfg, zone) - if cfg.flex_auth_url is None: - _evaluator_failure( - "flex-auth URL is not configured for security zone 'unknown'", - fail_closed=fail_closed, + if cfg.fetch_flex_auth_url is None: + raise CAError( + "secret-read evaluator not configured: set policy.fetch_flex_auth_url " + "(flex-auth-ops-warden-fetch pin); fetch checks never use the SSH " + "signing pin policy.flex_auth_url" ) - return None subject_id = os.environ.get(cfg.subject_env, "").strip() or "operator" request = { "subject": {"id": subject_id, "type": "operator", "tenant": cfg.tenant}, @@ -213,8 +219,8 @@ def check_fetch_policy( "context": {"need_id": need_id, "owner_repo": owner_repo, "domain": domain}, } - url = cfg.flex_auth_url.rstrip("/") + "/v1/check" - headers = _caller_headers(cfg, fail_closed=fail_closed) + url = cfg.fetch_flex_auth_url.rstrip("/") + "/v1/check" + headers = _caller_headers(cfg, fail_closed=True) try: response = httpx.post(url, json=request, headers=headers, timeout=10.0) response.raise_for_status() @@ -231,12 +237,10 @@ def check_fetch_policy( ) return None except httpx.RequestError as e: - _evaluator_failure( - f"flex-auth unreachable at {cfg.flex_auth_url!r} for security zone 'unknown'", - fail_closed=fail_closed, - cause=e, - ) - return None + raise CAError( + "secret-read evaluator (flex-auth-ops-warden-fetch) unreachable at " + f"{cfg.fetch_flex_auth_url!r}; fetch refused (no fallback to the signing pin)" + ) from e try: decision = response.json() diff --git a/tests/test_policy_fetch_evaluator.py b/tests/test_policy_fetch_evaluator.py new file mode 100644 index 0000000..72b6ab1 --- /dev/null +++ b/tests/test_policy_fetch_evaluator.py @@ -0,0 +1,138 @@ +"""Secret-read checks use their own evaluator pin, never the SSH signing pin (CUST-WP-0075).""" +from unittest.mock import patch + +import httpx +import pytest + +from warden.access import policy_gate_status +from warden.ca import CAError +from warden.config import PolicyConfig, load_config +from warden.models import ActorType, CertSpec +from warden.policy import check_fetch_policy, check_sign_policy + +SIGN_URL = "http://127.0.0.1:19090" +FETCH_URL = "http://127.0.0.1:19091" + + +def _allow(url, json=None, headers=None, timeout=None): + return httpx.Response( + 200, + json={"effect": "allow", "id": "decision:fetch", "reason": "secret_read_lane_matched"}, + request=httpx.Request("POST", url), + ) + + +def _fetch(cfg): + return check_fetch_policy( + cfg, need_id="forgejo-admin-api-token", owner_repo="railiance-platform", domain=None + ) + + +def test_fetch_check_goes_to_fetch_pin_never_sign_pin(): + cfg = PolicyConfig(flex_auth_url=SIGN_URL, fetch_flex_auth_url=FETCH_URL) + with patch("warden.policy.httpx.post", side_effect=_allow) as post: + assert _fetch(cfg) == "decision:fetch" + urls = [c.args[0] for c in post.call_args_list] + assert urls == [f"{FETCH_URL}/v1/check"] + body = post.call_args.kwargs["json"] + assert body["resource"]["system"] == "railiance-platform" + assert body["action"] == "read" + + +@pytest.mark.parametrize("failure_mode", ["fail_open", "fail_closed"]) +def test_unset_fetch_url_fails_closed_without_using_sign_pin(failure_mode): + cfg = PolicyConfig(flex_auth_url=SIGN_URL) + cfg.failure_modes["unknown"] = failure_mode + with patch("warden.policy.httpx.post") as post: + with pytest.raises(CAError, match="policy.fetch_flex_auth_url") as err: + _fetch(cfg) + assert "secret-read evaluator not configured" in str(err.value) + post.assert_not_called() + + +@pytest.mark.parametrize("failure_mode", ["fail_open", "fail_closed"]) +def test_unreachable_fetch_url_fails_closed(failure_mode): + cfg = PolicyConfig(flex_auth_url=SIGN_URL, fetch_flex_auth_url=FETCH_URL) + cfg.failure_modes["unknown"] = failure_mode + calls = [] + + def boom(url, **kwargs): + calls.append(url) + raise httpx.ConnectError("refused", request=httpx.Request("POST", url)) + + with patch("warden.policy.httpx.post", side_effect=boom): + with pytest.raises(CAError, match="flex-auth-ops-warden-fetch\\) unreachable"): + _fetch(cfg) + assert calls == [f"{FETCH_URL}/v1/check"] + + +def test_fetch_403_keeps_existing_refusal_text(): + cfg = PolicyConfig(flex_auth_url=SIGN_URL, fetch_flex_auth_url=FETCH_URL) + response = httpx.Response(403, request=httpx.Request("POST", f"{FETCH_URL}/v1/check")) + with patch("warden.policy.httpx.post", return_value=response): + with pytest.raises(CAError) as err: + _fetch(cfg) + assert str(err.value) == ( + "flex-auth refused fetch policy check (HTTP 403); " + "caller authentication or system binding was rejected" + ) + + +def test_sign_check_still_uses_sign_pin(tmp_path): + cfg = PolicyConfig(flex_auth_url=SIGN_URL, fetch_flex_auth_url=FETCH_URL) + pub = tmp_path / "id.pub" + pub.write_text("ssh-ed25519 AAAA test\n") + spec = CertSpec(actor_name="agt-example", actor_type=ActorType.AGT, + pubkey_path=pub, ttl_hours=1, principals=["agt"]) + with patch("warden.policy.httpx.post", side_effect=_allow) as post: + assert check_sign_policy(cfg, spec) == "decision:fetch" + assert [c.args[0] for c in post.call_args_list] == [f"{SIGN_URL}/v1/check"] + assert post.call_args.kwargs["json"]["resource"]["system"] == "ops-warden" + + +def _write_cfg(tmp_path, monkeypatch, policy_block): + cfg = tmp_path / "warden.yaml" + cfg.write_text( + "backend: local\nca_key: /tmp/none\n" + policy_block + ) + monkeypatch.setenv("WARDEN_CONFIG", str(cfg)) + return cfg + + +def test_config_loads_fetch_url(tmp_path, monkeypatch): + _write_cfg(tmp_path, monkeypatch, + f"policy:\n flex_auth_url: {SIGN_URL}\n fetch_flex_auth_url: {FETCH_URL}\n") + cfg = load_config() + assert cfg.policy.flex_auth_url == SIGN_URL + assert cfg.policy.fetch_flex_auth_url == FETCH_URL + + +def test_gate_status_names_fetch_evaluator(tmp_path, monkeypatch): + _write_cfg(tmp_path, monkeypatch, + f"policy:\n flex_auth_url: {SIGN_URL}\n fetch_flex_auth_url: {FETCH_URL}\n") + status = policy_gate_status() + assert "flex-auth-ops-warden-fetch" in status and FETCH_URL in status + assert SIGN_URL not in status + + +def test_gate_status_unset_fetch_url_says_refused(tmp_path, monkeypatch): + _write_cfg(tmp_path, monkeypatch, f"policy:\n flex_auth_url: {SIGN_URL}\n") + status = policy_gate_status() + assert "fetch_flex_auth_url unset" in status and "refused" in status + + +def test_access_advisory_prints_fetch_evaluator(tmp_path, monkeypatch): + from pathlib import Path + + from typer.testing import CliRunner + + from warden.cli import app + + catalog = Path(__file__).resolve().parents[1] / "registry" / "routing" / "catalog.yaml" + monkeypatch.setenv("WARDEN_ROUTING_CATALOG", str(catalog)) + _write_cfg(tmp_path, monkeypatch, + f"policy:\n flex_auth_url: {SIGN_URL}\n fetch_flex_auth_url: {FETCH_URL}\n") + result = CliRunner().invoke(app, ["access", "forgejo-admin-api-token"]) + assert result.exit_code == 0, result.output + flat = " ".join(result.output.split()) + assert "flex-auth-ops-warden-fetch" in flat and FETCH_URL in flat diff --git a/tests/test_policy_http_refusal.py b/tests/test_policy_http_refusal.py index 09fed46..88f88be 100644 --- a/tests/test_policy_http_refusal.py +++ b/tests/test_policy_http_refusal.py @@ -14,7 +14,7 @@ from warden.policy import check_fetch_policy, check_sign_policy @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 = PolicyConfig(flex_auth_url="http://pdp.test", fetch_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") @@ -37,7 +37,7 @@ def test_explicit_refusal_blocks_under_every_outage_mode(tmp_path, status, failu @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 = PolicyConfig(flex_auth_url="http://sign.test", fetch_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): diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 76d7beb..72fa6c7 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -203,11 +203,18 @@ def _proxy_env(monkeypatch, tmp_path): monkeypatch.setenv("WARDEN_CONFIG", str(_warden_yaml(tmp_path))) -def test_cli_proxy_unknown_zone_fail_open_reaches_transport_guard(monkeypatch, tmp_path): +def _allow_fetch_gate(monkeypatch): + """Stand in for an allowing secret-read evaluator (flex-auth-ops-warden-fetch).""" + monkeypatch.setattr( + "warden.policy.check_fetch_policy", lambda *a, **k: "decision:test-fetch" + ) + + +def test_cli_proxy_unset_fetch_evaluator_fails_closed(monkeypatch, tmp_path): _proxy_env(monkeypatch, tmp_path) monkeypatch.setenv("VAULT_TOKEN", "caller") - # The unknown-zone profile proceeds when no evaluator is configured, then - # the independent safe-transport boundary still refuses captured stdout. + # No policy.fetch_flex_auth_url: the fetch is refused before any transport, + # whatever the unknown-zone failure mode (CUST-WP-0075). monkeypatch.setattr( "warden.proxy.subprocess.run", lambda *a, **k: (_ for _ in ()).throw(AssertionError("fetch ran despite gate")), @@ -217,8 +224,9 @@ def test_cli_proxy_unknown_zone_fail_open_reaches_transport_guard(monkeypatch, t ["access", "npm", "--domain", "coulomb_social", "--field", "NPM_AUTH_TOKEN", "--path", "platform/x/y/z", "--fetch"], ) - assert r.exit_code == 6 - assert "unknown-zone fail_open" in r.output + assert r.exit_code == 4 + assert "fetch_flex_auth_url" in r.output + assert "fail_open applied" not in r.output def test_cli_proxy_requires_caller_auth(monkeypatch, tmp_path): @@ -240,7 +248,10 @@ def test_cli_explicit_policy_refusal_never_fetches_or_starts_child(monkeypatch, _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") + cfg.write_text( + cfg.read_text() + + "policy:\n flex_auth_url: http://sign.test\n fetch_flex_auth_url: http://pdp.test\n" + ) monkeypatch.setenv("VAULT_TOKEN", "caller-test-value") monkeypatch.setattr( "warden.policy.httpx.post", @@ -634,6 +645,7 @@ def test_build_wrapped_fetch_refuses_placeholder_path(): def test_access_fetch_to_nonterminal_stdout_is_refused(tmp_path, monkeypatch): """The anti-pattern: streaming a value to captured stdout is refused (exit 6).""" _proxy_env(monkeypatch, tmp_path) + _allow_fetch_gate(monkeypatch) monkeypatch.setenv("VAULT_TOKEN", "caller-token") # G1 caller-auth precheck # The guard trips before the fetch runs; make a real bao call fail loudly if reached. monkeypatch.setattr( @@ -649,6 +661,7 @@ def test_access_fetch_to_nonterminal_stdout_is_refused(tmp_path, monkeypatch): def test_access_fingerprint_masks_and_bypasses_stdout_guard(monkeypatch, tmp_path): """--fingerprint prints a masked fingerprint (never the value) even to captured stdout.""" _proxy_env(monkeypatch, tmp_path) + _allow_fetch_gate(monkeypatch) monkeypatch.setenv("VAULT_TOKEN", "caller-token") class _Fake: @@ -668,6 +681,7 @@ def test_access_fingerprint_masks_and_bypasses_stdout_guard(monkeypatch, tmp_pat def test_access_agent_high_risk_raw_stream_refused(tmp_path, monkeypatch): """WP-0026 T04: WARDEN_AGENT_ID + risk=high refuses raw value stream (exit 7).""" _proxy_env(monkeypatch, tmp_path) + _allow_fetch_gate(monkeypatch) monkeypatch.setenv("VAULT_TOKEN", "caller-token") monkeypatch.setenv("WARDEN_AGENT_ID", "grok") # Prefer high-risk lane; use --unsafe-stdout so T02 would allow if T04 failed. @@ -687,6 +701,7 @@ def test_access_issued_agent_subject_refuses_raw_stream_without_legacy_marker( ): """WP-0034 T02: the issued KeyCape subject is sufficient for exit 7.""" _proxy_env(monkeypatch, tmp_path) + _allow_fetch_gate(monkeypatch) monkeypatch.setenv("VAULT_TOKEN", "caller-token") monkeypatch.delenv("WARDEN_AGENT_ID", raising=False) monkeypatch.setenv( @@ -706,6 +721,7 @@ def test_access_issued_agent_subject_refuses_raw_stream_without_legacy_marker( def test_access_agent_high_risk_fingerprint_allowed(tmp_path, monkeypatch): """Agents may use --fingerprint on high-risk lanes (no raw value).""" _proxy_env(monkeypatch, tmp_path) + _allow_fetch_gate(monkeypatch) monkeypatch.setenv("VAULT_TOKEN", "caller-token") monkeypatch.setenv("WARDEN_AGENT_ID", "grok") diff --git a/wiki/CredentialRouting.md b/wiki/CredentialRouting.md index eef4a91..e741543 100644 --- a/wiki/CredentialRouting.md +++ b/wiki/CredentialRouting.md @@ -128,6 +128,20 @@ value; the owner remains OpenBao, key-cape, flex-auth, or the routed subsystem. --- +## Policy gate for proxied credential reads + +`warden access ` with `--exec`, `--fetch`, `--to-file`, `--wrap` or +`--fingerprint` runs a flex-auth `read` check before any fetch. That check goes +to the **fetch evaluator** `flex-auth-ops-warden-fetch` +(`policy.fetch_flex_auth_url`, workstation `http://127.0.0.1:19091` through +ops-bridge tunnel `flex-auth-ops-warden-fetch-railiance01`). It does not go to +the SSH signing pin `flex-auth-ops-warden` (`policy.flex_auth_url`, +`127.0.0.1:19090`), which admits only the `ops-warden` system. If the fetch +evaluator is unset or unreachable, the fetch is refused (exit 4). It is never +sent to the signing pin and never allowed by default. The `gate` line of +`warden access ` names the evaluator in use. Config: +`wiki/OpsWardenConfig.md` → "Two evaluators" (CUST-WP-0075). + ## Examples — do NOT ask ops-warden to own or vend | Request | Correct path | diff --git a/wiki/OpsWardenConfig.md b/wiki/OpsWardenConfig.md index 4f65114..a51d156 100644 --- a/wiki/OpsWardenConfig.md +++ b/wiki/OpsWardenConfig.md @@ -36,10 +36,12 @@ ca_key: ~/.ssh/ops-ca-user inventory_path: ~/.config/warden/inventory.yaml state_dir: ~/.local/state/warden -# Zone-aware flex-auth gate. With no URL, the explicit unknown/build profile -# fails open and records that no evaluator decision was available. +# Zone-aware flex-auth gate. With no signing URL, the explicit unknown/build +# profile fails open for signing and records that no evaluator decision was +# available. Fetch checks need fetch_flex_auth_url and fail closed without it. policy: flex_auth_url: http://127.0.0.1:8080 + fetch_flex_auth_url: http://127.0.0.1:8081 zone_registry_path: /path/to/compiled-flex-auth-registry.json failure_modes: z0-experimental: fail_open @@ -96,6 +98,7 @@ state_dir: ~/.local/state/warden # Configure after flex-auth ssh-certificate policies are deployed: # policy: # flex_auth_url: http://flex-auth-ops-warden.flex-auth.svc.cluster.local:8080 +# fetch_flex_auth_url: http://flex-auth-ops-warden-fetch.flex-auth.svc.cluster.local.:8080 # zone_registry_path: /etc/warden/production_registry_snapshot.json ``` @@ -253,6 +256,7 @@ zone. Signing records `policy_decision_id` when present plus `policy_zone`, ```yaml policy: flex_auth_url: http://127.0.0.1:8080 + fetch_flex_auth_url: http://127.0.0.1:8081 zone_registry_path: registry/flex-auth/production_registry_snapshot.json failure_modes: z0-experimental: fail_open @@ -274,6 +278,32 @@ for an unavailable or invalid evaluator. Full request shape and rollout notes: `wiki/PolicyGatedSigning.md`. +### Two evaluators: signing and fetch checks (CUST-WP-0075) + +| Key | Pin | Used by | Admits | +|-----|-----|---------|--------| +| `policy.flex_auth_url` | `flex-auth-ops-warden` (workstation `127.0.0.1:19090`) | `warden sign`, `warden issue` | `resource.system: ops-warden` only | +| `policy.fetch_flex_auth_url` | `flex-auth-ops-warden-fetch` (workstation `127.0.0.1:19091`) | `warden access ` fetch transports (`check_fetch_policy`) | `read` for catalog lanes with status `active`, `exec_capable: true` and lane `secret`, for owner systems bound in access-engine `values/ops-warden-fetch.yaml` | + +Both use the same caller identity (`policy.caller_auth`: the ops-warden service +account token, audience `flex-auth`). For fetches a missing caller token refuses +the fetch instead of sending an unauthenticated request. + +The fetch check never falls back to `flex_auth_url`: the signing pin binds only +the `ops-warden` system, so every fetch for another owner would get 403. If +`fetch_flex_auth_url` is unset or the evaluator is unreachable, the fetch fails +closed (exit 4) with a message naming the missing fetch evaluator, whatever the +`unknown` failure mode says. An explicit 401/403 still maps to "flex-auth +refused fetch policy check (HTTP 403); caller authentication or system binding +was rejected". `warden access ` prints which evaluator gates fetches on +its `gate` line (or `policy` line). + +In-cluster address: `http://flex-auth-ops-warden-fetch.flex-auth.svc.cluster.local.:8080` +(keep the trailing dot). access-engine `tools/gen_ops_warden_fetch.py` generates +the pin's readable set from this repo's routing catalog. When you add or retire a +lane of that kind, tell access-engine so it can regenerate the set and roll a new +image. Deployment notes: access-engine `examples/ops-warden-fetch/README.md`. + --- ## Environment variables @@ -302,6 +332,29 @@ tunnels: cert_command: "warden sign agt-state-hub-bridge --pubkey ~/.ssh/agt-state-hub-bridge_ed25519.pub" ``` +Workstation tunnels to the two flex-auth pins (`remote_host` is each pin +Service's ClusterIP, see access-engine `examples/ops-warden-fetch/README.md`): + +```yaml +tunnels: + flex-auth-ops-warden-railiance01: # signing pin -> policy.flex_auth_url + host: 92.205.62.239 + direction: local + remote_host: + remote_port: 8080 + local_port: 19090 + health_check: + url: http://127.0.0.1:19090/healthz + flex-auth-ops-warden-fetch-railiance01: # fetch pin -> policy.fetch_flex_auth_url + host: 92.205.62.239 + direction: local + remote_host: + remote_port: 8080 + local_port: 19091 + health_check: + url: http://127.0.0.1:19091/healthz +``` + `ops-bridge` runs `cert_command` before each SSH launch, captures stdout as the cert, and passes it alongside the private key via `ssh -i -i `. See `wiki/CertCommandInterface.md` for the full contract and diff --git a/workplans/WARDEN-WP-0039-explicit-policy-refusal.md b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md index 46f06df..0813af7 100644 --- a/workplans/WARDEN-WP-0039-explicit-policy-refusal.md +++ b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md @@ -9,7 +9,7 @@ flavor: residual owner: codex topic_slug: custodian created: "2026-09-08" -updated: "2026-09-28" +updated: "2026-10-03" origin: residual origin_ref: HFACT-WP-0001 state_hub_workstream_id: "ae44a935-6fca-514c-a385-4550dd2b1fe8" @@ -64,7 +64,8 @@ status: wait flavor: residual 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." +needs_human: true +blocking_reason: "Code side done 2026-10-03 (policy.fetch_flex_auth_url). Waits on founder go: deploy access-engine pin flex-auth-ops-warden-fetch, add tunnel flex-auth-ops-warden-fetch-railiance01, set the key in live warden.yaml, then record positive and wrong-owner evidence." state_hub_task_id: "8ca28b63-2e04-5333-b635-011b124c1806" ``` @@ -116,3 +117,24 @@ confirms resource.system is runtime policy vocabulary, not a repository rename. Retain the explicit refusal. A native owner route's success does not grant this interim proxy general delegated-read authority; exact positive and caller/owner/ tenant negative evidence is still required for the route that replaces it. + +**T03 code side done 2026-10-03 (CUST-WP-0075, founder build-mode directive).** +access-engine answered the contract question with a dedicated fetch evaluator: +pin `flex-auth-ops-warden-fetch` (access-engine 02b15a7, `values/ops-warden-fetch.yaml`), +same caller (ops-warden SA, audience flex-auth), owner systems bound per lane, +request shape unchanged. ops-warden now sends `check_fetch_policy` only to the new +key `policy.fetch_flex_auth_url`; signing keeps `policy.flex_auth_url`. An unset or +unreachable fetch evaluator fails closed with a message naming it, with no fallback +to the signing pin and no fail-open. 401/403 keep the existing refusal text. +`warden access ` names the fetch evaluator on its `gate` (or `policy`) line. Tests: +`tests/test_policy_fetch_evaluator.py`, plus updated proxy and refusal tests. Full +suite: 494 passed, 4 deselected. Docs: `wiki/OpsWardenConfig.md` ("Two +evaluators", tunnel example), `wiki/CredentialRouting.md`, production example. + +Still open (founder go, `needs_human`): deploy the pin (access-engine +ADHOC-2026-10-03-T02), add the ops-bridge tunnel (local 19091 → the pin's Service +ClusterIP:8080), set `fetch_flex_auth_url: http://127.0.0.1:19091` in the live +`~/.config/warden/warden.yaml`, then record exact positive evidence and +wrong-owner/caller negative evidence before T03 closes. Until the key is set, +every proxied fetch refuses with "secret-read evaluator not configured". Before +that change, those fetches got a 403 from the signing pin.