diff --git a/api/services/forge_credential.py b/api/services/forge_credential.py new file mode 100644 index 0000000..5714b65 --- /dev/null +++ b/api/services/forge_credential.py @@ -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 diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index 0a8da10..e04f6a3 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -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: diff --git a/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml b/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml index a72abae..d91989b 100644 --- a/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml +++ b/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml @@ -18,11 +18,14 @@ spec: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} labels: {{- include "statehub.labels" . | nindent 8 }} spec: + {{- if .Values.serviceAccount.name }} + serviceAccountName: {{ .Values.serviceAccount.name | quote }} + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} - {{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled }} + {{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled .Values.forgeRead.enabled }} volumes: {{- if .Values.sweep.enabled }} - name: sweep-repos @@ -39,6 +42,19 @@ spec: configMap: name: {{ .Values.classificationAllowed.name | quote }} {{- end }} + {{- if .Values.forgeRead.enabled }} + # A projected token with an explicit audience, not the legacy + # auto-mounted one: the auto-mounted token has the API server as its + # audience, so a copy of it is a credential for the cluster. This one is + # only accepted by OpenBao, and the kubelet rotates it in place. + - name: openbao-token + projected: + sources: + - serviceAccountToken: + path: token + audience: {{ .Values.forgeRead.openbao.audience | quote }} + expirationSeconds: {{ .Values.forgeRead.openbao.expirationSeconds }} + {{- end }} {{- end }} containers: - name: state-hub @@ -58,7 +74,7 @@ spec: - -c - git config --global --add safe.directory '*' {{- end }} - {{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled }} + {{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled .Values.forgeRead.enabled }} volumeMounts: {{- if .Values.sweep.enabled }} - name: sweep-repos @@ -72,6 +88,11 @@ spec: mountPath: {{ .Values.classificationAllowed.mountPath | quote }} readOnly: true {{- end }} + {{- if .Values.forgeRead.enabled }} + - name: openbao-token + mountPath: /var/run/secrets/openbao + readOnly: true + {{- end }} env: {{- if .Values.sweep.enabled }} - name: STATE_HUB_SWEEP_HOSTNAME @@ -83,6 +104,20 @@ spec: - name: REPO_CLASSIFICATION_ALLOWED_PATH value: {{ printf "%s/repo-classification.allowed.yaml" .Values.classificationAllowed.mountPath | quote }} {{- end }} + {{- if .Values.forgeRead.enabled }} + # Coordinates only. The token itself is never a chart value, never + # in the image, and never in a Kubernetes Secret in this release. + - name: OPENBAO_ADDR + value: {{ .Values.forgeRead.openbao.addr | quote }} + - name: OPENBAO_K8S_ROLE + value: {{ .Values.forgeRead.openbao.role | quote }} + - name: OPENBAO_K8S_TOKEN_PATH + value: /var/run/secrets/openbao/token + - name: FORGE_READ_SECRET_PATH + value: {{ .Values.forgeRead.openbao.secretPath | quote }} + - name: FORGE_READ_SECRET_KEY + value: {{ .Values.forgeRead.openbao.secretKey | quote }} + {{- end }} {{- end }} envFrom: {{- if .Values.config.enabled }} diff --git a/deploy/railiance/apps/charts/state-hub/templates/serviceaccount.yaml b/deploy/railiance/apps/charts/state-hub/templates/serviceaccount.yaml new file mode 100644 index 0000000..6efb4c9 --- /dev/null +++ b/deploy/railiance/apps/charts/state-hub/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create }} +# STATE-WP-0084-T02. The OpenBao Kubernetes auth role built by MASON-WP-0003-T02 +# binds to this ServiceAccount by name and deliberately does not bind to +# `default` — so the pod must stop running as `default` before the lane can +# carry anything. Creating it here keeps that binding in the release rather than +# in an operator's memory. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.serviceAccount.name | quote }} + labels: {{- include "statehub.labels" . | nindent 4 }} +{{- end }} diff --git a/deploy/railiance/apps/charts/state-hub/values.yaml b/deploy/railiance/apps/charts/state-hub/values.yaml index a1a6200..0ec45e9 100644 --- a/deploy/railiance/apps/charts/state-hub/values.yaml +++ b/deploy/railiance/apps/charts/state-hub/values.yaml @@ -30,9 +30,34 @@ config: instanceRole: unknown instanceLabel: "" +# STATE-WP-0084-T02. The OpenBao Kubernetes auth role binds to this name and +# not to `default`, so this is load-bearing, not cosmetic. +serviceAccount: + create: true + name: state-hub + secret: name: state-hub-env +# Forge read credential for deriving private repositories (STATE-WP-0084, +# MASON-WP-0003). Disabled by default: a hub without it still derives every +# public repository, so this is added capability, not a prerequisite. +# +# There is no agent injector and no secrets-store CSI driver on this cluster, so +# the pod authenticates to OpenBao itself with its projected ServiceAccount +# token and reads the KV path. Nothing here is the credential; these are only +# coordinates. Rotating the token in OpenBao needs no chart change and no +# redeploy. +forgeRead: + enabled: false + openbao: + addr: "" + role: state-hub-forge-derivation + audience: openbao + expirationSeconds: 3600 + secretPath: "" + secretKey: token + resources: requests: cpu: 250m diff --git a/docs/evidence/legacy-meter-weekly-review-20260827.json b/docs/evidence/legacy-meter-weekly-review-20260827.json new file mode 100644 index 0000000..bc31c52 --- /dev/null +++ b/docs/evidence/legacy-meter-weekly-review-20260827.json @@ -0,0 +1,882 @@ +{ + "captured_at": "2026-08-27T14:00:07.862953+00:00", + "api_base": "http://127.0.0.1:8000", + "workplan": "STATE-WP-0070", + "retired_interfaces": [], + "weekly_review": { + "generated_at": "2026-08-27T14:00:09.212468Z", + "window_start": "2026-08-20T14:00:09.134254Z", + "window_end": "2026-08-27T14:00:09.134254Z", + "cadence": "weekly", + "activity_core_handoff": { + "activity_id": "statehub-legacy-interface-review", + "cadence": "weekly", + "source_endpoint": "/legacy-meter/weekly-review", + "state_owner": "state-hub", + "scheduler_owner": "activity-core" + }, + "interfaces": [ + { + "interface": { + "id": "8051fb40-66c9-4dc4-a413-13e8573e04a3", + "interface_key": "event_subject:org.statehub.workstream.completed", + "interface_kind": "event_subject", + "legacy_since": "2026-06-04T06:09:42.198193Z", + "replacement_ref": "org.statehub.workplan.completed", + "owner_component": "state-hub.events", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T06:09:42.198193Z", + "updated_at": "2026-06-04T06:09:42.198193Z" + }, + "all_time": { + "calls": 247, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 247 + }, + "users": { + "unknown": 247 + }, + "components": { + "state-hub.events": 247 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-07-13T13:22:00.748140Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 45d (>= 30d required for 247 all-time call(s))" + }, + { + "interface": { + "id": "cd9342fd-27e7-4a46-a1be-d41499e106a3", + "interface_key": "rest_api:DELETE /workstreams/{workstream_id}", + "interface_kind": "rest_api", + "legacy_since": "2026-06-06T17:28:13.052813Z", + "replacement_ref": "/workplans/{workplan_id}", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-06T17:28:13.052813Z", + "updated_at": "2026-06-06T17:28:13.052813Z" + }, + "all_time": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-06-06T17:28:13.047529Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 81d (>= 7d required for 1 all-time call(s))" + }, + { + "interface": { + "id": "d0294dce-7913-4268-94c3-1b2502aefb0f", + "interface_key": "rest_api:GET /sbom/", + "interface_kind": "rest_api", + "legacy_since": "2026-08-22T17:06:38.703608Z", + "replacement_ref": "sbom-nexus:/sbom/", + "owner_component": "state-hub.sbom-compat", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-08-22T17:06:38.703608Z", + "updated_at": "2026-08-22T17:23:43.730661Z" + }, + "all_time": { + "calls": 4, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 4 + }, + "users": { + "unknown": 4 + }, + "components": { + "unknown": 4 + } + }, + "window": { + "calls": 4, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 4 + }, + "users": { + "unknown": 4 + }, + "components": { + "unknown": 4 + } + }, + "last_seen_at": "2026-08-22T17:23:46.146788Z", + "retirement_candidate": false, + "retirement_reason": "4 call(s) in review window" + }, + { + "interface": { + "id": "2f77ac12-8c97-4974-a2f9-2996927982d1", + "interface_key": "rest_api:GET /sbom/report/licences/", + "interface_kind": "rest_api", + "legacy_since": "2026-08-22T17:06:41.978426Z", + "replacement_ref": "sbom-nexus:/sbom/", + "owner_component": "state-hub.sbom-compat", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-08-22T17:06:41.978426Z", + "updated_at": "2026-08-22T17:23:44.794796Z" + }, + "all_time": { + "calls": 4, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 4 + }, + "users": { + "unknown": 4 + }, + "components": { + "unknown": 4 + } + }, + "window": { + "calls": 4, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 4 + }, + "users": { + "unknown": 4 + }, + "components": { + "unknown": 4 + } + }, + "last_seen_at": "2026-08-22T17:56:34.405129Z", + "retirement_candidate": false, + "retirement_reason": "4 call(s) in review window" + }, + { + "interface": { + "id": "b627edf5-3302-4366-ba2e-547d32f634b5", + "interface_key": "rest_api:GET /sbom/snapshots/", + "interface_kind": "rest_api", + "legacy_since": "2026-08-22T17:06:37.057774Z", + "replacement_ref": "sbom-nexus:/sbom/", + "owner_component": "state-hub.sbom-compat", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-08-22T17:06:37.057774Z", + "updated_at": "2026-08-22T17:22:15.080213Z" + }, + "all_time": { + "calls": 5, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 5 + }, + "users": { + "unknown": 5 + }, + "components": { + "unknown": 5 + } + }, + "window": { + "calls": 5, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 5 + }, + "users": { + "unknown": 5 + }, + "components": { + "unknown": 5 + } + }, + "last_seen_at": "2026-08-22T17:23:53.844021Z", + "retirement_candidate": false, + "retirement_reason": "5 call(s) in review window" + }, + { + "interface": { + "id": "d03ee274-cd6f-4cd5-8f22-e4d298416299", + "interface_key": "rest_api:GET /sbom/snapshots/{snapshot_id}", + "interface_kind": "rest_api", + "legacy_since": "2026-08-22T17:23:45.969876Z", + "replacement_ref": "sbom-nexus:/sbom/", + "owner_component": "state-hub.sbom-compat", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-08-22T17:23:45.969876Z", + "updated_at": "2026-08-22T17:23:45.969876Z" + }, + "all_time": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "window": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "last_seen_at": "2026-08-22T17:23:45.967492Z", + "retirement_candidate": false, + "retirement_reason": "1 call(s) in review window" + }, + { + "interface": { + "id": "a6cb291e-8312-42f8-8a66-fde369482881", + "interface_key": "rest_api:GET /sbom/{repo_slug}", + "interface_kind": "rest_api", + "legacy_since": "2026-08-22T17:23:45.089542Z", + "replacement_ref": "sbom-nexus:/sbom/", + "owner_component": "state-hub.sbom-compat", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-08-22T17:23:45.089542Z", + "updated_at": "2026-08-22T17:23:45.089542Z" + }, + "all_time": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "window": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "last_seen_at": "2026-08-22T17:23:45.670511Z", + "retirement_candidate": false, + "retirement_reason": "1 call(s) in review window" + }, + { + "interface": { + "id": "3e4a3d0b-08fa-45c3-91e2-4f514a914b97", + "interface_key": "rest_api:GET /workstreams/", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T00:26:14.533764Z", + "replacement_ref": "/workplans/", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T00:26:14.533764Z", + "updated_at": "2026-06-04T00:26:14.533764Z" + }, + "all_time": { + "calls": 830269, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 830269 + }, + "users": { + "unknown": 830269 + }, + "components": { + "unknown": 830269 + } + }, + "window": { + "calls": 24746, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 24746 + }, + "users": { + "unknown": 24746 + }, + "components": { + "unknown": 24746 + } + }, + "last_seen_at": "2026-08-21T13:50:37.994459Z", + "retirement_candidate": false, + "retirement_reason": "24746 call(s) in review window" + }, + { + "interface": { + "id": "e4c140ec-7479-46f9-b185-4b533f19d639", + "interface_key": "rest_api:GET /workstreams/workplan-index", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T05:20:58.321869Z", + "replacement_ref": "/workplans/index", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T05:20:58.321869Z", + "updated_at": "2026-06-04T05:20:58.321869Z" + }, + "all_time": { + "calls": 39, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 39 + }, + "users": { + "unknown": 39 + }, + "components": { + "unknown": 39 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-07-08T05:20:33.378203Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 50d (>= 7d required for 39 all-time call(s))" + }, + { + "interface": { + "id": "3a00b19e-e7db-4403-98aa-a77c9ab61ecc", + "interface_key": "rest_api:GET /workstreams/{workstream_id}", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T00:25:59.014966Z", + "replacement_ref": "/workplans/{workplan_id}", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T00:25:59.014966Z", + "updated_at": "2026-06-04T00:25:59.014966Z" + }, + "all_time": { + "calls": 3411470, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 3411470 + }, + "users": { + "unknown": 3411470 + }, + "components": { + "unknown": 3411470 + } + }, + "window": { + "calls": 111410, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 111410 + }, + "users": { + "unknown": 111410 + }, + "components": { + "unknown": 111410 + } + }, + "last_seen_at": "2026-08-21T13:50:32.103349Z", + "retirement_candidate": false, + "retirement_reason": "111410 call(s) in review window" + }, + { + "interface": { + "id": "69e1a255-a8b9-4101-8a68-b1bed23abfda", + "interface_key": "rest_api:GET /workstreams/{workstream_id}/dependencies/", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T00:25:59.291135Z", + "replacement_ref": "/workplans/{workplan_id}/dependencies/", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T00:25:59.291135Z", + "updated_at": "2026-06-04T00:25:59.291135Z" + }, + "all_time": { + "calls": 1643952, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1643952 + }, + "users": { + "unknown": 1643952 + }, + "components": { + "unknown": 1643952 + } + }, + "window": { + "calls": 52105, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 52105 + }, + "users": { + "unknown": 52105 + }, + "components": { + "unknown": 52105 + } + }, + "last_seen_at": "2026-08-21T13:50:30.082480Z", + "retirement_candidate": false, + "retirement_reason": "52105 call(s) in review window" + }, + { + "interface": { + "id": "b1aae931-b51f-4a85-a723-865dd65f6132", + "interface_key": "rest_api:PATCH /workstreams/{workstream_id}", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T06:09:41.901035Z", + "replacement_ref": "/workplans/{workplan_id}", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T06:09:41.901035Z", + "updated_at": "2026-06-04T06:09:41.901035Z" + }, + "all_time": { + "calls": 584, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 584 + }, + "users": { + "unknown": 584 + }, + "components": { + "unknown": 584 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-08-16T16:33:34.045078Z", + "retirement_candidate": false, + "retirement_reason": "quiet 10d of 30d required for 584 all-time call(s)" + }, + { + "interface": { + "id": "ed6451c9-d2cc-4486-9afe-d3852782cabc", + "interface_key": "rest_api:POST /workstreams/", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T07:40:24.168535Z", + "replacement_ref": "/workplans/", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T07:40:24.168535Z", + "updated_at": "2026-06-04T07:40:24.168535Z" + }, + "all_time": { + "calls": 811, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 811 + }, + "users": { + "unknown": 811 + }, + "components": { + "unknown": 811 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-08-16T01:03:41.093965Z", + "retirement_candidate": false, + "retirement_reason": "quiet 11d of 30d required for 811 all-time call(s)" + }, + { + "interface": { + "id": "281fd706-b192-4194-8055-2d8733c115f9", + "interface_key": "rest_api:POST /workstreams/{workstream_id}/dependencies/", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T22:57:37.149207Z", + "replacement_ref": "/workplans/{workplan_id}/dependencies/", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T22:57:37.149207Z", + "updated_at": "2026-06-04T22:57:37.149207Z" + }, + "all_time": { + "calls": 4975, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 4975 + }, + "users": { + "unknown": 4975 + }, + "components": { + "unknown": 4975 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-07-07T20:31:35.389791Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 50d (>= 30d required for 4975 all-time call(s))" + } + ], + "retirement_candidates": [ + { + "interface": { + "id": "8051fb40-66c9-4dc4-a413-13e8573e04a3", + "interface_key": "event_subject:org.statehub.workstream.completed", + "interface_kind": "event_subject", + "legacy_since": "2026-06-04T06:09:42.198193Z", + "replacement_ref": "org.statehub.workplan.completed", + "owner_component": "state-hub.events", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T06:09:42.198193Z", + "updated_at": "2026-06-04T06:09:42.198193Z" + }, + "all_time": { + "calls": 247, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 247 + }, + "users": { + "unknown": 247 + }, + "components": { + "state-hub.events": 247 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-07-13T13:22:00.748140Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 45d (>= 30d required for 247 all-time call(s))" + }, + { + "interface": { + "id": "cd9342fd-27e7-4a46-a1be-d41499e106a3", + "interface_key": "rest_api:DELETE /workstreams/{workstream_id}", + "interface_kind": "rest_api", + "legacy_since": "2026-06-06T17:28:13.052813Z", + "replacement_ref": "/workplans/{workplan_id}", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-06T17:28:13.052813Z", + "updated_at": "2026-06-06T17:28:13.052813Z" + }, + "all_time": { + "calls": 1, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 1 + }, + "users": { + "unknown": 1 + }, + "components": { + "unknown": 1 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-06-06T17:28:13.047529Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 81d (>= 7d required for 1 all-time call(s))" + }, + { + "interface": { + "id": "e4c140ec-7479-46f9-b185-4b533f19d639", + "interface_key": "rest_api:GET /workstreams/workplan-index", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T05:20:58.321869Z", + "replacement_ref": "/workplans/index", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T05:20:58.321869Z", + "updated_at": "2026-06-04T05:20:58.321869Z" + }, + "all_time": { + "calls": 39, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 39 + }, + "users": { + "unknown": 39 + }, + "components": { + "unknown": 39 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-07-08T05:20:33.378203Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 50d (>= 7d required for 39 all-time call(s))" + }, + { + "interface": { + "id": "281fd706-b192-4194-8055-2d8733c115f9", + "interface_key": "rest_api:POST /workstreams/{workstream_id}/dependencies/", + "interface_kind": "rest_api", + "legacy_since": "2026-06-04T22:57:37.149207Z", + "replacement_ref": "/workplans/{workplan_id}/dependencies/", + "owner_component": "state-hub.api", + "status": "legacy", + "replacement_verified": true, + "manual_hold": false, + "hold_reason": null, + "notes": null, + "retired_at": null, + "created_at": "2026-06-04T22:57:37.149207Z", + "updated_at": "2026-06-04T22:57:37.149207Z" + }, + "all_time": { + "calls": 4975, + "tenant_count": 1, + "user_count": 1, + "component_count": 1, + "tenants": { + "unknown": 4975 + }, + "users": { + "unknown": 4975 + }, + "components": { + "unknown": 4975 + } + }, + "window": { + "calls": 0, + "tenant_count": 0, + "user_count": 0, + "component_count": 0, + "tenants": {}, + "users": {}, + "components": {} + }, + "last_seen_at": "2026-07-07T20:31:35.389791Z", + "retirement_candidate": true, + "retirement_reason": "no usage in review window; quiet 50d (>= 30d required for 4975 all-time call(s))" + } + ] + }, + "days": 7 +} diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index ffa2e84..0c2ca38 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -12,6 +12,8 @@ from pathlib import Path import pytest +from api.services import forge_credential as fc + from api.services import forge_projection as fp @@ -532,3 +534,153 @@ class TestUnreadableIsNotMissing: d = outcome.to_dict() assert d["unreadable_count"] == 1 and d["errored"] == 1 assert d["repositories"] == 3 + + +class TestForgeCredential: + """Optional forge read credential (STATE-WP-0084-T03). + + Absent is a valid state: a hub with no credential must still derive every + public repository. The credential must never reach argv, a log, or an + exception — the places a secret leaks without anyone deciding to leak it. + """ + + @pytest.fixture(autouse=True) + def _clear_cache(self): + """The resolved credential is cached for 5 minutes in production. + + That cache is deliberate — a fleet reset of 121 repositories must not + authenticate to OpenBao 121 times — so tests clear it rather than + disable it, and exercise the same code path production uses. + """ + fc.reset_cache() + yield + fc.reset_cache() + + def test_absent_credential_is_none_not_empty_string(self, monkeypatch): + monkeypatch.delenv(fc.TOKEN_ENV, raising=False) + monkeypatch.delenv(fc.TOKEN_FILE_ENV, raising=False) + assert fc.forge_read_token() is None + + def test_a_file_is_preferred_over_the_environment(self, tmp_path, monkeypatch): + """Kubernetes rotates a mounted file without a redeploy.""" + f = tmp_path / "token" + f.write_text("from-file\n", encoding="utf-8") + monkeypatch.setenv(fc.TOKEN_ENV, "from-env") + monkeypatch.setenv(fc.TOKEN_FILE_ENV, str(f)) + assert fc.forge_read_token() == "from-file" + + def test_an_unreadable_token_file_does_not_fall_back_silently(self, monkeypatch): + """Falling back to a stale env value would hide a broken mount.""" + monkeypatch.setenv(fc.TOKEN_FILE_ENV, "/nonexistent/token") + monkeypatch.setenv(fc.TOKEN_ENV, "from-env") + assert fc.forge_read_token() is None + + def test_openbao_is_the_last_resort_not_the_first(self, tmp_path, monkeypatch): + """A file or env value must not trigger a network call.""" + f = tmp_path / "token" + f.write_text("local", encoding="utf-8") + monkeypatch.setenv(fc.TOKEN_FILE_ENV, str(f)) + monkeypatch.setattr( + fc, "_from_openbao", lambda: pytest.fail("OpenBao consulted unnecessarily") + ) + assert fc.forge_read_token() == "local" + + def test_openbao_failure_resolves_to_absent_not_an_exception(self, monkeypatch): + """A hub that cannot reach OpenBao must still derive public repos. + + Raising here would turn "nine repositories are unreadable" into "the + whole derivation pass failed" — the outcome STATE-WP-0084-T01 exists to + prevent. + """ + monkeypatch.delenv(fc.TOKEN_FILE_ENV, raising=False) + monkeypatch.delenv(fc.TOKEN_ENV, raising=False) + monkeypatch.setenv(fc.OPENBAO_ADDR_ENV, "https://openbao.invalid") + monkeypatch.setenv(fc.SECRET_PATH_ENV, "kv/data/forge") + monkeypatch.setenv(fc.OPENBAO_ROLE_ENV, "state-hub-forge-derivation") + monkeypatch.setenv(fc.OPENBAO_JWT_PATH_ENV, "/nonexistent/sa-token") + assert fc.forge_read_token() is None + + def test_openbao_unwraps_kv_v2(self, tmp_path, monkeypatch): + jwt = tmp_path / "sa" + jwt.write_text("jwt-value", encoding="utf-8") + monkeypatch.delenv(fc.TOKEN_FILE_ENV, raising=False) + monkeypatch.delenv(fc.TOKEN_ENV, raising=False) + monkeypatch.setenv(fc.OPENBAO_ADDR_ENV, "https://openbao.test") + monkeypatch.setenv(fc.SECRET_PATH_ENV, "kv/data/forge") + monkeypatch.setenv(fc.OPENBAO_ROLE_ENV, "state-hub-forge-derivation") + monkeypatch.setenv(fc.OPENBAO_JWT_PATH_ENV, str(jwt)) + + class R: + def __init__(self, payload): + self._p = payload + + def raise_for_status(self): + return None + + def json(self): + return self._p + + class C: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def post(self, url, json): + assert json["jwt"] == "jwt-value" + assert json["role"] == "state-hub-forge-derivation" + return R({"auth": {"client_token": "bao-token"}}) + + def get(self, url, headers): + assert headers["X-Vault-Token"] == "bao-token" + return R({"data": {"data": {"token": "forge-secret"}}}) + + monkeypatch.setattr(fc.httpx, "Client", lambda **kw: C()) + assert fc.forge_read_token() == "forge-secret" + + def test_credential_never_appears_in_argv(self, monkeypatch): + """`-c http.extraHeader=` would put the token in every ps listing.""" + seen = {} + + class P: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(cmd, **kw): + seen["cmd"] = cmd + seen["env"] = kw.get("env") or {} + return P() + + monkeypatch.setattr(fp.subprocess, "run", fake_run) + fp._run_git("clone", "url", "dir", token="s3cret") + assert not any("s3cret" in part for part in seen["cmd"]) + assert seen["env"]["GIT_CONFIG_COUNT"] == "1" + assert "s3cret" not in seen["env"]["GIT_CONFIG_KEY_0"] + + def test_credential_is_redacted_from_failures(self, monkeypatch): + class P: + returncode = 128 + stdout = "" + stderr = "fatal: auth failed using s3cret" + + monkeypatch.setattr(fp.subprocess, "run", lambda *a, **k: P()) + with pytest.raises(fp.ForgeDeriveError) as exc: + fp._run_git("clone", token="s3cret") + assert "s3cret" not in str(exc.value) and "***" in str(exc.value) + + def test_no_credential_still_runs(self, monkeypatch): + class P: + returncode = 0 + stdout = "ok" + stderr = "" + seen = {} + + def fake_run(cmd, **kw): + seen["env"] = kw.get("env") or {} + return P() + + monkeypatch.setattr(fp.subprocess, "run", fake_run) + assert fp._run_git("status") == "ok" + assert "GIT_CONFIG_COUNT" not in seen["env"] diff --git a/workplans/STATE-WP-0084-forge-read-for-private-repositories.md b/workplans/STATE-WP-0084-forge-read-for-private-repositories.md index 89fb80c..5ba4de1 100644 --- a/workplans/STATE-WP-0084-forge-read-for-private-repositories.md +++ b/workplans/STATE-WP-0084-forge-read-for-private-repositories.md @@ -112,7 +112,7 @@ covered by tests that fail if the retirement path is reachable from either. ```task id: STATE-WP-0084-T02 -status: wait +status: progress priority: medium state_hub_task_id: "d8d41a89-1ebc-5118-bb73-cf8d8c114e16" ``` @@ -129,11 +129,37 @@ Rotation must not require a chart change or a redeploy. Acceptance: the pod can read the credential; nothing in the repository contains it; rotating the token does not require a redeploy. +**Chart landed 2026-08-27; awaits the minted token.** The cluster has no OpenBao +agent injector and no secrets-store CSI driver — checked, not assumed — so +there is nothing to inject with. The pod authenticates to OpenBao itself, which +is what `MASON-WP-0003-T02`'s Kubernetes auth role was built for. + +- `templates/serviceaccount.yaml` creates ServiceAccount `state-hub`, and the + Deployment now sets `serviceAccountName`. The auth role binds to this name and + deliberately not to `default`, so until this ships the pod cannot authenticate + at all. **This changes the identity the running pod uses** — expect a pod + restart on upgrade. +- The OpenBao token is a *projected* ServiceAccount token with audience + `openbao`, not the legacy auto-mounted one. The auto-mounted token's audience + is the API server, so a copy of it is a credential for the cluster; this one + is only accepted by OpenBao, and the kubelet rotates it in place. +- `forgeRead.*` carries coordinates only — address, role, KV path, key. No + credential is a chart value, an image layer, or a Kubernetes Secret in this + release. Rotating the token in OpenBao needs no chart change and no redeploy: + the value is re-read every 5 minutes. +- Default `forgeRead.enabled: false`. A hub without the credential still derives + every public repository, so this is added capability, not a prerequisite. + +Remaining before this is `done`: the minted read-only Forgejo token in the KV +path (`paste_once_provision`, outside both repositories), and a deploy with +`forgeRead.enabled=true` plus the real address and path in +`deploy/railiance/apps/helm/state-hub-values.yaml`. + ## Teach the derivation to use it ```task id: STATE-WP-0084-T03 -status: wait +status: progress priority: medium state_hub_task_id: "b22b24d9-7ed3-533c-bda7-3130693cf4d2" ``` @@ -152,6 +178,43 @@ Acceptance: private repositories derive; a hub without the credential still derives public ones; no credential appears in logs, process listings, or recorded clone URLs. +**Code landed 2026-08-27**, ahead of T02 — writing the consumer does not need +the secret to exist. `api/services/forge_projection.py`: + +- `forge_read_token()` reads `FORGE_READ_TOKEN_FILE` in preference to + `FORGE_READ_TOKEN`. A mounted file is what lets T02 satisfy "rotation must + not require a redeploy"; the environment cannot be rotated in place. An + unreadable token *file* returns `None` rather than falling back to the + environment — falling back would let a broken mount look like success while + quietly using a stale value. +- Absent is a supported state, not a degraded one: with no credential the git + environment is untouched and public derivation runs exactly as before. +- The credential is passed as `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/ + `GIT_CONFIG_VALUE_0` setting `http.extraHeader`, not as `-c` on the command + line and not as userinfo in the URL. Both alternatives put the token in the + process listing, where anything that can run `ps` reads it. +- `_run_git` redacts the token from `ForgeDeriveError` before raising, because + that message is logged, stored in a reset outcome, and returned over the API. +- `api/services/forge_credential.py` resolves the credential from a mounted + file, an environment variable, or OpenBao via Kubernetes auth, in that order. + Production uses the third; the first two make the code runnable and testable + outside the cluster. Configuring a file means *that* file and nothing else — + no fall-through, so a broken mount cannot silently resolve to a stale value. +- Every failure resolves to `None`, never an exception: no configuration, no + network, OpenBao down, permission denied. Raising would convert "nine + repositories are unreadable" into "the whole pass failed", which is the + outcome T01 exists to prevent. +- The resolved value is cached for 5 minutes — a fleet reset of 121 + repositories must not authenticate 121 times, and a rotated token must still + be picked up without a redeploy. + +Nine tests in `TestForgeCredential` cover each of those, including the two that +are silent when wrong: token-in-argv and token-in-exception. 43 pass in +`tests/test_forge_projection.py`; 717 pass across the suite. + +Remains `progress` because the acceptance clause "private repositories derive" +cannot be observed until T02 supplies a credential; T04 confirms it. + ## Confirm the nine ```task