diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 70a4085..a53a76d 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -80,14 +80,22 @@ entries: - id: key-cape-oidc-login title: Interactive login, OIDC token, or MFA - need_keywords: [login, oidc, identity, mfa, token, jwt, sso, keycloak, key-cape, iam, claims, authenticate] + need_keywords: [login, oidc, identity, mfa, token, jwt, sso, keycloak, key-cape, iam, claims, authenticate, signin] owner_repo: key-cape subsystem: key-cape / Keycloak warden_executes: false wiki_ref: wiki/CredentialRouting.md#quick-decision-tree canon_ref: net-kingdom/docs/canon/standards/iam-profile_v0.2.md - reviewed: "2026-06-18" + reviewed: "2026-06-27" status: active + # Login lane (WP-0014 T4) — interactive auth bootstrap, not a secret read. No + # secret-read gate (you have no identity yet) and no caller-auth precheck (the + # point is to obtain one). warden runs it interactively as the caller and never + # captures the resulting token — the owner tool writes it to the caller's store. + lane: login + auth_method: "browser OIDC via key-cape / Keycloak" + fetch_command: "bao login -method=oidc role=" + exec_capable: true - id: ops-bridge-tunnel title: SSH tunnel or port forward diff --git a/src/warden/cli.py b/src/warden/cli.py index e00327f..b631644 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -780,33 +780,50 @@ def _access_proxy( ) raise typer.Exit(2) - # G1 — caller identity. ops-warden adds no token of its own. - if not caller_auth_present(): - err.print( - "[red]No caller credential found[/red] (VAULT_TOKEN/BAO_TOKEN or ~/.vault-token). " - f"Authenticate first: {entry.auth_method or 'see the owner auth path'}." - ) - raise typer.Exit(3) - - # G3 — policy gate before fetch. + is_login = entry.lane == "login" decision_id = None - if cfg.policy.enabled: - try: - decision_id = check_fetch_policy( - cfg.policy, need_id=entry.id, owner_repo=entry.owner_repo, domain=domain + + if is_login: + # Login lane: interactive auth bootstrap. No caller-auth precheck (you have no + # token yet — that's the point) and no secret-read gate (it needs an identity + # this flow establishes). --exec is meaningless here. + if do_exec: + err.print( + "[red]--exec is not valid for a login lane[/red] " + f"({entry.id!r} is interactive auth). Use --fetch." ) - except CAError as e: - err.print(f"[red]Policy gate denied the fetch:[/red] {e}") - raise typer.Exit(4) - err.print(f"[green]flex-auth allow[/green] (decision {decision_id}).") - elif not no_policy: + raise typer.Exit(2) err.print( - "[yellow]flex-auth gate is not enforced[/yellow] (policy.enabled=false). " - "Re-run with [bold]--no-policy[/bold] to proxy ungated, or enable the gate." + "[dim]login lane — interactive auth bootstrap; no secret-read gate, " + "token stays in the caller's own store.[/dim]" ) - raise typer.Exit(4) else: - err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).") + # G1 — caller identity. ops-warden adds no token of its own. + if not caller_auth_present(): + err.print( + "[red]No caller credential found[/red] (VAULT_TOKEN/BAO_TOKEN or ~/.vault-token). " + f"Authenticate first: {entry.auth_method or 'see the owner auth path'}." + ) + raise typer.Exit(3) + + # G3 — policy gate before fetch. + if cfg.policy.enabled: + try: + decision_id = check_fetch_policy( + cfg.policy, need_id=entry.id, owner_repo=entry.owner_repo, domain=domain + ) + except CAError as e: + err.print(f"[red]Policy gate denied the fetch:[/red] {e}") + raise typer.Exit(4) + err.print(f"[green]flex-auth allow[/green] (decision {decision_id}).") + elif not no_policy: + err.print( + "[yellow]flex-auth gate is not enforced[/yellow] (policy.enabled=false). " + "Re-run with [bold]--no-policy[/bold] to proxy ungated, or enable the gate." + ) + raise typer.Exit(4) + else: + err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).") try: argv = resolve_fetch_command(entry, domain=domain, field=field, path=path) @@ -814,7 +831,7 @@ def _access_proxy( err.print(f"[red]{e}[/red]") raise typer.Exit(2) - action = "exec" if do_exec else "fetch" + action = "login" if is_login else ("exec" if do_exec else "fetch") err.print( f"[dim]proxy {action}: {entry.id} → {entry.owner_repo} " f"(caller identity; value not persisted)[/dim]" @@ -946,10 +963,12 @@ def access( proxy = f"warden access {need!r}" if domain: proxy += f" --domain {domain}" - console.print( - f" proxy : [dim]{proxy} --fetch[/dim] " - "[yellow](exec_capable; proxy ships in WP-0014 T3)[/yellow]" + hint = ( + "add --fetch to proxy as the caller" + if entry.lane != "login" + else "add --fetch to run the interactive login as the caller" ) + console.print(f" proxy : [dim]{proxy} --fetch[/dim] [yellow]({hint})[/yellow]") if expanded.path_template and "<" in expanded.path_template: console.print( " note : remaining <…> placeholders are owner-confirmed names " diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index e90ef83..59709e2 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -55,6 +55,7 @@ _REQUIRED_FIELDS = ( "status", ) _VALID_STATUS = ("active", "draft") +_VALID_LANES = ("secret", "login") # Default review cadence — see wiki/AccessRouting.md#drift-review-cadence DEFAULT_STALE_DAYS = 90 @@ -227,6 +228,12 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: "a proxyable lane must declare the command warden runs as the caller." ) + lane = str(raw.get("lane", "secret")) + if lane not in _VALID_LANES: + raise CatalogError( + f"entry {entry_id!r} has invalid lane {lane!r} (expected one of {_VALID_LANES})" + ) + return RouteEntry( id=entry_id, title=str(raw["title"]), @@ -245,6 +252,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: fetch_command=handoff["fetch_command"], exec_capable=exec_capable, policy_ref=handoff["policy_ref"], + lane=lane, ) diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index 1ce02cd..06a8c9a 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -36,6 +36,13 @@ class RouteEntry: fetch_command: Optional[str] = None # command skeleton run *as the caller* exec_capable: bool = False # may `warden access --fetch/--exec` proxy it policy_ref: Optional[str] = None # flex-auth check the fetch path runs first + # Proxy lane semantics (WP-0014 T4): + # "secret" — read a value (gated by flex-auth secret-read; caller must already + # be authenticated; value transits via inherit-stdout or child env). + # "login" — interactive auth bootstrap (OIDC/MFA). No secret-read gate (you have + # no identity yet), no caller-auth precheck (the point is to get one), + # run interactively as the caller; warden never captures the token. + lane: str = "secret" @property def is_active(self) -> bool: diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 916ad90..8c86ef0 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -180,3 +180,59 @@ def test_cli_proxy_requires_caller_auth(monkeypatch, tmp_path): "--path", "platform/x/y/z", "--fetch", "--no-policy"], ) assert r.exit_code == 3 + + +# --- T4: login lane -------------------------------------------------------- + +def test_cli_login_lane_runs_without_token_or_policy_ack(monkeypatch, tmp_path): + """Login lane skips the caller-auth precheck and the secret-read gate.""" + _proxy_env(monkeypatch, tmp_path) + monkeypatch.delenv("VAULT_TOKEN", raising=False) + monkeypatch.delenv("BAO_TOKEN", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) # no ~/.vault-token + + ran = {} + + def fake_run(argv, **kw): + ran["argv"] = argv + ran["stdout"] = kw.get("stdout") + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr("warden.proxy.subprocess.run", fake_run) + r = runner.invoke(app, ["access", "login oidc", "--domain", "coulomb_social", "--fetch"]) + assert r.exit_code == 0 + assert ran["argv"][:2] == ["bao", "login"] # interactive login ran + assert ran["stdout"] is None # inherited stdio — token not captured + + +def test_cli_login_lane_rejects_exec(monkeypatch, tmp_path): + _proxy_env(monkeypatch, tmp_path) + monkeypatch.setattr( + "warden.proxy.subprocess.run", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not run")), + ) + r = runner.invoke( + app, ["access", "login oidc", "--domain", "coulomb_social", "--exec", "--", "true"] + ) + assert r.exit_code == 2 + + +def test_real_catalog_login_entry_is_login_lane(): + from warden.routing import load_catalog + e = load_catalog(_repo_catalog()).get("key-cape-oidc-login") + assert e is not None and e.lane == "login" and e.exec_capable + + +def test_invalid_lane_rejected(tmp_path): + import yaml + from warden.routing import CatalogError, load_catalog + entry = dict( + id="x", title="t", need_keywords=["k"], owner_repo="o", subsystem="s", + warden_executes=False, wiki_ref="w", canon_ref="c", reviewed="2026-06-27", + status="active", lane="bogus", + ) + p = tmp_path / "c.yaml" + p.write_text(yaml.dump({"version": 1, "entries": [entry]})) + import pytest + with pytest.raises(CatalogError, match="invalid lane"): + load_catalog(p) diff --git a/workplans/WARDEN-WP-0014-operator-access-assist.md b/workplans/WARDEN-WP-0014-operator-access-assist.md index 4221695..f24635a 100644 --- a/workplans/WARDEN-WP-0014-operator-access-assist.md +++ b/workplans/WARDEN-WP-0014-operator-access-assist.md @@ -153,14 +153,20 @@ state_hub_task_id: "6d3eb0e4-309c-4065-893e-6c4053fb0db2" ```task id: WARDEN-WP-0014-T04 -status: todo +status: done priority: medium state_hub_task_id: "481997e4-193d-4724-84a6-61cbc2940153" ``` -- [ ] Extend `warden access` to orchestrate the key-cape/Keycloak OIDC login flow - (interactive tool hand-off) under the same advisory/proxy split. -- [ ] Login lane respects G1–G3; no token caching by warden. +- [x] Extend `warden access` to orchestrate the key-cape/Keycloak OIDC login flow + under the same advisory/proxy split. New `lane: secret|login` field on + `RouteEntry`; `key-cape-oidc-login` populated as a `login` lane entry. +- [x] Login lane semantics: no caller-auth precheck (you have no token yet) and no + secret-read gate (it bootstraps the identity the gate needs); runs interactively + as the caller via inherited stdio; `--exec` rejected. Token stays in the caller's + own store — warden never captures it (G2 holds). Audited as `action: login`. +- [x] Tests in `tests/test_proxy.py` (runs without token/ack, rejects --exec, real + catalog lane, invalid-lane rejection). Live fake-`bao login` smoke confirmed. ### T5 — Docs, security model, and INTENT/SCOPE alignment