diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py index dde218f80..757750f45 100755 --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -147,6 +147,7 @@ async def _fetch_credential(context: RunnerContext, credential_type: str) -> dic bot = get_bot_token() if bot: req.add_header("Authorization", f"Bearer {bot}") + logger.debug(f"Using CP OIDC token for {credential_type} credentials") loop = asyncio.get_running_loop() diff --git a/components/runners/ambient-runner/ambient_runner/platform/utils.py b/components/runners/ambient-runner/ambient_runner/platform/utils.py index 6820c5623..c8643e206 100644 --- a/components/runners/ambient-runner/ambient_runner/platform/utils.py +++ b/components/runners/ambient-runner/ambient_runner/platform/utils.py @@ -23,11 +23,30 @@ # Kubelet automatically refreshes this file when the Secret is updated. _BOT_TOKEN_FILE = Path("/var/run/secrets/ambient/bot-token") +# K8s SA token mounted in every pod by the kubelet. +_SA_TOKEN_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/token") + # In-process cache for the token fetched from the CP token endpoint. # Set once at startup by _grpc_client.py after a successful CP token fetch. _cp_fetched_token: str = "" +def get_sa_token() -> str: + """Return the Kubernetes ServiceAccount token mounted in the pod. + + This is a long-lived K8s-managed token that authenticates to the K8s API + as system:serviceaccount::. The backend's + enforceCredentialRBAC classifies this as isBotToken=true, which grants + access to the session owner's credentials without an owner-match check. + """ + try: + if _SA_TOKEN_FILE.exists(): + return _SA_TOKEN_FILE.read_text().strip() + except OSError: + pass + return "" + + def set_bot_token(token: str) -> None: """Store a token fetched from the CP token endpoint for use by get_bot_token().""" global _cp_fetched_token diff --git a/components/runners/ambient-runner/tests/test_shared_session_credentials.py b/components/runners/ambient-runner/tests/test_shared_session_credentials.py index 185c0a9f0..b23de2d78 100644 --- a/components/runners/ambient-runner/tests/test_shared_session_credentials.py +++ b/components/runners/ambient-runner/tests/test_shared_session_credentials.py @@ -728,3 +728,91 @@ async def test_returns_success_on_successful_refresh(self): assert result.get("isError") is None or result.get("isError") is False assert "successfully" in result["content"][0]["text"].lower() + + +# --------------------------------------------------------------------------- +# _fetch_credential — CP OIDC token used when no caller token (regression) +# --------------------------------------------------------------------------- + + +class TestFetchCredentialBotToken: + @pytest.mark.asyncio + async def test_uses_bot_token_when_no_caller_token(self): + """_fetch_credential sends the CP OIDC token when caller_token is absent. + + The api-server validates the CP OIDC token via RHSSO JWT signature verification. + The CP's OIDC client identity must have a role_binding granting credential:read. + + Regression for: runner gets HTTP 401 on credential fetch in gRPC-initiated runs. + """ + server = HTTPServer(("127.0.0.1", 0), _CredentialHandler) + port = server.server_address[1] + thread = Thread(target=server.handle_request, daemon=True) + thread.start() + + _CredentialHandler.response_body = {"token": "gh-tok-via-oidc"} + _CredentialHandler.captured_headers = {} + + cp_oidc_token = "cp-oidc-jwt-token" + + try: + with ( + patch.dict( + os.environ, + { + "BACKEND_API_URL": f"http://127.0.0.1:{port}/api", + "CREDENTIAL_IDS": json.dumps({"github": "cred-gh-bot-test"}), + }, + ), + patch("ambient_runner.platform.auth.get_bot_token", return_value=cp_oidc_token), + ): + ctx = _make_context() # no caller_token + result = await _fetch_credential(ctx, "github") + + assert result.get("token") == "gh-tok-via-oidc", ( + "credential fetch must succeed using CP OIDC token — " + "regression for HTTP 401 on gRPC-initiated runs" + ) + assert _CredentialHandler.captured_headers.get("Authorization") == ( + f"Bearer {cp_oidc_token}" + ), "request must use the CP OIDC token" + finally: + server.server_close() + thread.join(timeout=2) + + @pytest.mark.asyncio + async def test_bot_token_used_when_no_caller_token(self): + """CP OIDC token (get_bot_token) is used when caller_token is absent. + + The credential endpoint on the api-server validates via RHSSO JWT, + the same issuer that signs the CP OIDC token — one token for both + gRPC and HTTP credential fetches. + """ + called_with = {} + + def fake_urlopen(req, timeout=None): + called_with["auth"] = req.get_header("Authorization") + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps({"token": "ok"}).encode() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + return mock_resp + + with ( + patch.dict( + os.environ, + { + "BACKEND_API_URL": "http://backend.svc.cluster.local/api", + "CREDENTIAL_IDS": json.dumps({"github": "cred-gh-pref"}), + }, + ), + patch("urllib.request.urlopen", side_effect=fake_urlopen), + patch("ambient_runner.platform.auth.get_bot_token", return_value="cp-oidc-token"), + ): + ctx = _make_context() # no caller_token + await _fetch_credential(ctx, "github") + + assert called_with.get("auth") == "Bearer cp-oidc-token", ( + "CP OIDC token must be used for credential fetch — " + "same token used for gRPC and HTTP credential endpoint" + )