feat(forge): resolve an optional forge read credential (STATE-WP-0084-T02/T03)
Nine repositories are invisible to derivation because central may not read them. This adds the consuming half of the credential lane MASON-WP-0003 built. The cluster has no agent injector and no secrets-store CSI driver, so the pod authenticates to OpenBao with a projected ServiceAccount token (audience `openbao`, not the API server) and reads the KV path itself. `forgeRead.*` carries coordinates only; no credential is a chart value, an image layer, or a Kubernetes Secret. The credential reaches git through GIT_CONFIG_* setting http.extraHeader, not through `-c` and not through userinfo in the clone URL — both of those put the token in the process listing. It is redacted from ForgeDeriveError, which is logged, stored in reset outcomes, and returned over the API. Absent stays a supported state: with no credential, or with OpenBao unreachable, resolution returns None and public derivation runs unchanged. Raising would turn "nine repositories are unreadable" into "the pass failed", which is what T01 exists to prevent. Chart default is disabled. 717 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
parent
ab6438235e
commit
470ece82ed
8 changed files with 1334 additions and 7 deletions
122
api/services/forge_credential.py
Normal file
122
api/services/forge_credential.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Resolve the forge read credential (STATE-WP-0084-T03, MASON-WP-0003-T05).
|
||||
|
||||
Three sources, tried in order: a mounted file, an environment variable, and
|
||||
OpenBao via Kubernetes auth. Production uses the third — this cluster has no
|
||||
agent injector and no secrets-store CSI driver, so the pod authenticates with
|
||||
its projected ServiceAccount token and reads the KV path itself, which is what
|
||||
`MASON-WP-0003-T02` built the Kubernetes auth role for. The first two exist so
|
||||
the code is runnable and testable outside the cluster.
|
||||
|
||||
**Absent is a supported state, never an error.** A hub with no credential still
|
||||
derives every public repository; only private ones become unreadable, and
|
||||
`STATE-WP-0084-T01` already reports that as its own named condition rather than
|
||||
as records that stopped deriving. So every failure here — no configuration, no
|
||||
network, OpenBao down, permission denied — resolves to `None`. Raising would
|
||||
convert "cannot read nine repositories" into "the whole pass failed".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOKEN_ENV = "FORGE_READ_TOKEN"
|
||||
TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE"
|
||||
OPENBAO_ADDR_ENV = "OPENBAO_ADDR"
|
||||
OPENBAO_ROLE_ENV = "OPENBAO_K8S_ROLE"
|
||||
OPENBAO_JWT_PATH_ENV = "OPENBAO_K8S_TOKEN_PATH"
|
||||
OPENBAO_AUTH_MOUNT_ENV = "OPENBAO_K8S_AUTH_MOUNT"
|
||||
SECRET_PATH_ENV = "FORGE_READ_SECRET_PATH"
|
||||
SECRET_KEY_ENV = "FORGE_READ_SECRET_KEY"
|
||||
|
||||
# Long enough that a fleet reset of 121 repositories does not re-authenticate
|
||||
# 121 times; short enough that a rotated token is picked up without a redeploy,
|
||||
# which is what MASON-WP-0003-T02 requires of this lane.
|
||||
CACHE_TTL_SECONDS = 300.0
|
||||
|
||||
_cache: tuple[float, str | None] | None = None
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def _from_file() -> str | None:
|
||||
path = os.environ.get(TOKEN_FILE_ENV)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8").strip() or None
|
||||
except OSError:
|
||||
# Deliberately not falling through to the environment: a broken mount
|
||||
# that silently used a stale value would look like success.
|
||||
logger.warning("forge credential: token file %s is unreadable", path)
|
||||
return None
|
||||
|
||||
|
||||
def _from_env() -> str | None:
|
||||
return (os.environ.get(TOKEN_ENV) or "").strip() or None
|
||||
|
||||
|
||||
def _from_openbao() -> str | None:
|
||||
addr = (os.environ.get(OPENBAO_ADDR_ENV) or "").strip().rstrip("/")
|
||||
secret_path = (os.environ.get(SECRET_PATH_ENV) or "").strip().strip("/")
|
||||
role = (os.environ.get(OPENBAO_ROLE_ENV) or "").strip()
|
||||
jwt_path = os.environ.get(OPENBAO_JWT_PATH_ENV) or "/var/run/secrets/openbao/token"
|
||||
mount = (os.environ.get(OPENBAO_AUTH_MOUNT_ENV) or "kubernetes").strip("/")
|
||||
key = (os.environ.get(SECRET_KEY_ENV) or "token").strip()
|
||||
if not (addr and secret_path and role):
|
||||
return None
|
||||
try:
|
||||
jwt = Path(jwt_path).read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
logger.warning("forge credential: no ServiceAccount token at %s", jwt_path)
|
||||
return None
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
login = client.post(
|
||||
f"{addr}/v1/auth/{mount}/login", json={"role": role, "jwt": jwt}
|
||||
)
|
||||
login.raise_for_status()
|
||||
client_token = login.json()["auth"]["client_token"]
|
||||
read = client.get(
|
||||
f"{addr}/v1/{secret_path}", headers={"X-Vault-Token": client_token}
|
||||
)
|
||||
read.raise_for_status()
|
||||
data = read.json()["data"]
|
||||
# KV v2 nests the payload under a second "data"; v1 does not.
|
||||
if isinstance(data.get("data"), dict):
|
||||
data = data["data"]
|
||||
except (httpx.HTTPError, KeyError, ValueError) as exc:
|
||||
# Never include the response body: a failed KV read can echo content.
|
||||
logger.warning("forge credential: OpenBao lookup failed (%s)", type(exc).__name__)
|
||||
return None
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
logger.warning("forge credential: key %r absent at the KV path", key)
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
|
||||
def forge_read_token(*, use_cache: bool = True) -> str | None:
|
||||
"""The forge read credential, or `None` if this instance has none."""
|
||||
global _cache
|
||||
now = time.monotonic()
|
||||
if use_cache and _cache is not None and now - _cache[0] < CACHE_TTL_SECONDS:
|
||||
return _cache[1]
|
||||
if os.environ.get(TOKEN_FILE_ENV):
|
||||
# Configured to use a file means *that* file and nothing else. Falling
|
||||
# back would let a broken mount quietly resolve to a stale environment
|
||||
# value that nobody knows is in use.
|
||||
token = _from_file()
|
||||
else:
|
||||
token = _from_env() or _from_openbao()
|
||||
_cache = (now, token)
|
||||
return token
|
||||
|
|
@ -13,6 +13,7 @@ what makes the reset in `T03` verifiable: you can always ask what the projection
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import base64
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
|
@ -163,16 +164,50 @@ class DerivedProjection:
|
|||
}
|
||||
|
||||
|
||||
def _run_git(*args: str, cwd: str | None = None, timeout: float = 120.0) -> str:
|
||||
from api.services.forge_credential import forge_read_token # noqa: F401
|
||||
|
||||
# Kept as module attributes so callers and tests that reached for them here
|
||||
# still resolve after the sources moved to `forge_credential`.
|
||||
FORGE_TOKEN_ENV = "FORGE_READ_TOKEN"
|
||||
FORGE_TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE"
|
||||
|
||||
|
||||
def _credential_env(token: str | None) -> dict[str, str]:
|
||||
"""Git config carrying the credential, passed by environment not argv.
|
||||
|
||||
`-c http.extraHeader=...` would place the token in the process command line,
|
||||
where it is readable by anything that can run `ps` and lands in any log that
|
||||
records invocations. GIT_CONFIG_* achieves the same configuration without
|
||||
that exposure.
|
||||
"""
|
||||
if not token:
|
||||
return {}
|
||||
header = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||
return {
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "http.extraHeader",
|
||||
"GIT_CONFIG_VALUE_0": f"Authorization: Basic {header}",
|
||||
}
|
||||
|
||||
|
||||
def _run_git(
|
||||
*args: str, cwd: str | None = None, timeout: float = 120.0, token: str | None = None
|
||||
) -> str:
|
||||
# Without this a clone of a private repository blocks on a username prompt
|
||||
# instead of failing, and an unattended derivation pass hangs rather than
|
||||
# reporting. Failing fast is what makes the unreadable case observable.
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "", "GCM_INTERACTIVE": "never"}
|
||||
env.update(_credential_env(token))
|
||||
proc = subprocess.run(
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout, env=env
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise ForgeDeriveError((proc.stderr or proc.stdout).strip()[:400])
|
||||
detail = (proc.stderr or proc.stdout).strip()[:400]
|
||||
if token:
|
||||
# Never let a credential reach an exception that is logged, stored
|
||||
# in a reset outcome, or returned over the API.
|
||||
detail = detail.replace(token, "***")
|
||||
raise ForgeDeriveError(detail)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
|
|
@ -269,12 +304,13 @@ def derive_from_forge(
|
|||
someone's local state instead (ADR-012 context).
|
||||
"""
|
||||
url = f"{forge_base.rstrip('/')}/{repo_slug}.git"
|
||||
token = forge_read_token()
|
||||
with tempfile.TemporaryDirectory(prefix=f"forge-{repo_slug}-") as tmp:
|
||||
args = ["clone", "--depth", "1", "--quiet"]
|
||||
if ref:
|
||||
args += ["--branch", ref]
|
||||
try:
|
||||
_run_git(*args, url, tmp)
|
||||
_run_git(*args, url, tmp, token=token)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ForgeDeriveError(f"clone timed out for {repo_slug}") from exc
|
||||
except ForgeDeriveError as exc:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue