generated from coulomb/repo-seed
feat(WARDEN-WP-0014): T4 — key-cape login orchestration lane
Adds a lane: secret|login field to RouteEntry. The login lane is an interactive auth bootstrap: it skips the caller-auth precheck (no token yet — that's the point) and the secret-read gate (it establishes the identity the gate needs), runs the owner's login command interactively as the caller via inherited stdio, and rejects --exec. The token stays in the caller's own store; warden never captures it (G2 holds). Audited as action: login. key-cape-oidc-login populated as the reference login entry. Advisory proxy hint updated now that T3 has shipped. 172 passed, lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -80,14 +80,22 @@ entries:
|
|||||||
|
|
||||||
- id: key-cape-oidc-login
|
- id: key-cape-oidc-login
|
||||||
title: Interactive login, OIDC token, or MFA
|
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
|
owner_repo: key-cape
|
||||||
subsystem: key-cape / Keycloak
|
subsystem: key-cape / Keycloak
|
||||||
warden_executes: false
|
warden_executes: false
|
||||||
wiki_ref: wiki/CredentialRouting.md#quick-decision-tree
|
wiki_ref: wiki/CredentialRouting.md#quick-decision-tree
|
||||||
canon_ref: net-kingdom/docs/canon/standards/iam-profile_v0.2.md
|
canon_ref: net-kingdom/docs/canon/standards/iam-profile_v0.2.md
|
||||||
reviewed: "2026-06-18"
|
reviewed: "2026-06-27"
|
||||||
status: active
|
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=<domain>"
|
||||||
|
exec_capable: true
|
||||||
|
|
||||||
- id: ops-bridge-tunnel
|
- id: ops-bridge-tunnel
|
||||||
title: SSH tunnel or port forward
|
title: SSH tunnel or port forward
|
||||||
|
|||||||
@@ -780,33 +780,50 @@ def _access_proxy(
|
|||||||
)
|
)
|
||||||
raise typer.Exit(2)
|
raise typer.Exit(2)
|
||||||
|
|
||||||
# G1 — caller identity. ops-warden adds no token of its own.
|
is_login = entry.lane == "login"
|
||||||
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.
|
|
||||||
decision_id = None
|
decision_id = None
|
||||||
if cfg.policy.enabled:
|
|
||||||
try:
|
if is_login:
|
||||||
decision_id = check_fetch_policy(
|
# Login lane: interactive auth bootstrap. No caller-auth precheck (you have no
|
||||||
cfg.policy, need_id=entry.id, owner_repo=entry.owner_repo, domain=domain
|
# 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:
|
raise typer.Exit(2)
|
||||||
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(
|
err.print(
|
||||||
"[yellow]flex-auth gate is not enforced[/yellow] (policy.enabled=false). "
|
"[dim]login lane — interactive auth bootstrap; no secret-read gate, "
|
||||||
"Re-run with [bold]--no-policy[/bold] to proxy ungated, or enable the gate."
|
"token stays in the caller's own store.[/dim]"
|
||||||
)
|
)
|
||||||
raise typer.Exit(4)
|
|
||||||
else:
|
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:
|
try:
|
||||||
argv = resolve_fetch_command(entry, domain=domain, field=field, path=path)
|
argv = resolve_fetch_command(entry, domain=domain, field=field, path=path)
|
||||||
@@ -814,7 +831,7 @@ def _access_proxy(
|
|||||||
err.print(f"[red]{e}[/red]")
|
err.print(f"[red]{e}[/red]")
|
||||||
raise typer.Exit(2)
|
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(
|
err.print(
|
||||||
f"[dim]proxy {action}: {entry.id} → {entry.owner_repo} "
|
f"[dim]proxy {action}: {entry.id} → {entry.owner_repo} "
|
||||||
f"(caller identity; value not persisted)[/dim]"
|
f"(caller identity; value not persisted)[/dim]"
|
||||||
@@ -946,10 +963,12 @@ def access(
|
|||||||
proxy = f"warden access {need!r}"
|
proxy = f"warden access {need!r}"
|
||||||
if domain:
|
if domain:
|
||||||
proxy += f" --domain {domain}"
|
proxy += f" --domain {domain}"
|
||||||
console.print(
|
hint = (
|
||||||
f" proxy : [dim]{proxy} --fetch[/dim] "
|
"add --fetch to proxy as the caller"
|
||||||
"[yellow](exec_capable; proxy ships in WP-0014 T3)[/yellow]"
|
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:
|
if expanded.path_template and "<" in expanded.path_template:
|
||||||
console.print(
|
console.print(
|
||||||
" note : remaining <…> placeholders are owner-confirmed names "
|
" note : remaining <…> placeholders are owner-confirmed names "
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ _REQUIRED_FIELDS = (
|
|||||||
"status",
|
"status",
|
||||||
)
|
)
|
||||||
_VALID_STATUS = ("active", "draft")
|
_VALID_STATUS = ("active", "draft")
|
||||||
|
_VALID_LANES = ("secret", "login")
|
||||||
|
|
||||||
# Default review cadence — see wiki/AccessRouting.md#drift-review-cadence
|
# Default review cadence — see wiki/AccessRouting.md#drift-review-cadence
|
||||||
DEFAULT_STALE_DAYS = 90
|
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."
|
"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(
|
return RouteEntry(
|
||||||
id=entry_id,
|
id=entry_id,
|
||||||
title=str(raw["title"]),
|
title=str(raw["title"]),
|
||||||
@@ -245,6 +252,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry:
|
|||||||
fetch_command=handoff["fetch_command"],
|
fetch_command=handoff["fetch_command"],
|
||||||
exec_capable=exec_capable,
|
exec_capable=exec_capable,
|
||||||
policy_ref=handoff["policy_ref"],
|
policy_ref=handoff["policy_ref"],
|
||||||
|
lane=lane,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ class RouteEntry:
|
|||||||
fetch_command: Optional[str] = None # command skeleton run *as the caller*
|
fetch_command: Optional[str] = None # command skeleton run *as the caller*
|
||||||
exec_capable: bool = False # may `warden access --fetch/--exec` proxy it
|
exec_capable: bool = False # may `warden access --fetch/--exec` proxy it
|
||||||
policy_ref: Optional[str] = None # flex-auth check the fetch path runs first
|
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
|
@property
|
||||||
def is_active(self) -> bool:
|
def is_active(self) -> bool:
|
||||||
|
|||||||
@@ -180,3 +180,59 @@ def test_cli_proxy_requires_caller_auth(monkeypatch, tmp_path):
|
|||||||
"--path", "platform/x/y/z", "--fetch", "--no-policy"],
|
"--path", "platform/x/y/z", "--fetch", "--no-policy"],
|
||||||
)
|
)
|
||||||
assert r.exit_code == 3
|
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)
|
||||||
|
|||||||
@@ -153,14 +153,20 @@ state_hub_task_id: "6d3eb0e4-309c-4065-893e-6c4053fb0db2"
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0014-T04
|
id: WARDEN-WP-0014-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "481997e4-193d-4724-84a6-61cbc2940153"
|
state_hub_task_id: "481997e4-193d-4724-84a6-61cbc2940153"
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Extend `warden access` to orchestrate the key-cape/Keycloak OIDC login flow
|
- [x] Extend `warden access` to orchestrate the key-cape/Keycloak OIDC login flow
|
||||||
(interactive tool hand-off) under the same advisory/proxy split.
|
under the same advisory/proxy split. New `lane: secret|login` field on
|
||||||
- [ ] Login lane respects G1–G3; no token caching by warden.
|
`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
|
### T5 — Docs, security model, and INTENT/SCOPE alignment
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user