Skip to content

Commit 8329cad

Browse files
markturanskyAmbient Code Botclaude
authored
fix(runner): use CP OIDC token for backend credential fetches (#1219)
## Summary - The credential endpoint (`GET /api/ambient/v1/credentials/{id}/token`) lives on the **ambient-api-server**, which validates tokens via **RHSSO JWT signature** — the same issuer that signs the CP OIDC token used for gRPC - The K8s SA token path added in the previous commit was unnecessary: the CP OIDC token is already a valid RHSSO JWT and authenticates to the same server for both gRPC and HTTP credential fetches - Removes `get_sa_token()` from the credential fetch path; `get_bot_token()` (CP OIDC) is now used as the primary token when no caller token is present - The CP's OIDC client identity requires a `role_binding` granting `credential:read` in the DB (infra/ops concern) ## Test plan - [ ] 31 tests pass in `test_shared_session_credentials.py` - [ ] Regression tests updated: verify CP OIDC token (`get_bot_token()`) is sent in `Authorization` header when no caller token present - [ ] Deploy and verify runner can fetch GitHub credentials without HTTP 401 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for reading Kubernetes ServiceAccount tokens when available. * Emit a debug log when falling back to a bot/CP token source for credential requests. * **Tests** * Added regression tests verifying credential fetches and outgoing Authorization headers when the caller token is absent. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06f7626 commit 8329cad

3 files changed

Lines changed: 108 additions & 0 deletions

File tree

components/runners/ambient-runner/ambient_runner/platform/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ async def _fetch_credential(context: RunnerContext, credential_type: str) -> dic
147147
bot = get_bot_token()
148148
if bot:
149149
req.add_header("Authorization", f"Bearer {bot}")
150+
logger.debug(f"Using CP OIDC token for {credential_type} credentials")
150151

151152
loop = asyncio.get_running_loop()
152153

components/runners/ambient-runner/ambient_runner/platform/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,30 @@
2323
# Kubelet automatically refreshes this file when the Secret is updated.
2424
_BOT_TOKEN_FILE = Path("/var/run/secrets/ambient/bot-token")
2525

26+
# K8s SA token mounted in every pod by the kubelet.
27+
_SA_TOKEN_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/token")
28+
2629
# In-process cache for the token fetched from the CP token endpoint.
2730
# Set once at startup by _grpc_client.py after a successful CP token fetch.
2831
_cp_fetched_token: str = ""
2932

3033

34+
def get_sa_token() -> str:
35+
"""Return the Kubernetes ServiceAccount token mounted in the pod.
36+
37+
This is a long-lived K8s-managed token that authenticates to the K8s API
38+
as system:serviceaccount:<namespace>:<sa-name>. The backend's
39+
enforceCredentialRBAC classifies this as isBotToken=true, which grants
40+
access to the session owner's credentials without an owner-match check.
41+
"""
42+
try:
43+
if _SA_TOKEN_FILE.exists():
44+
return _SA_TOKEN_FILE.read_text().strip()
45+
except OSError:
46+
pass
47+
return ""
48+
49+
3150
def set_bot_token(token: str) -> None:
3251
"""Store a token fetched from the CP token endpoint for use by get_bot_token()."""
3352
global _cp_fetched_token

components/runners/ambient-runner/tests/test_shared_session_credentials.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,3 +728,91 @@ async def test_returns_success_on_successful_refresh(self):
728728

729729
assert result.get("isError") is None or result.get("isError") is False
730730
assert "successfully" in result["content"][0]["text"].lower()
731+
732+
733+
# ---------------------------------------------------------------------------
734+
# _fetch_credential — CP OIDC token used when no caller token (regression)
735+
# ---------------------------------------------------------------------------
736+
737+
738+
class TestFetchCredentialBotToken:
739+
@pytest.mark.asyncio
740+
async def test_uses_bot_token_when_no_caller_token(self):
741+
"""_fetch_credential sends the CP OIDC token when caller_token is absent.
742+
743+
The api-server validates the CP OIDC token via RHSSO JWT signature verification.
744+
The CP's OIDC client identity must have a role_binding granting credential:read.
745+
746+
Regression for: runner gets HTTP 401 on credential fetch in gRPC-initiated runs.
747+
"""
748+
server = HTTPServer(("127.0.0.1", 0), _CredentialHandler)
749+
port = server.server_address[1]
750+
thread = Thread(target=server.handle_request, daemon=True)
751+
thread.start()
752+
753+
_CredentialHandler.response_body = {"token": "gh-tok-via-oidc"}
754+
_CredentialHandler.captured_headers = {}
755+
756+
cp_oidc_token = "cp-oidc-jwt-token"
757+
758+
try:
759+
with (
760+
patch.dict(
761+
os.environ,
762+
{
763+
"BACKEND_API_URL": f"http://127.0.0.1:{port}/api",
764+
"CREDENTIAL_IDS": json.dumps({"github": "cred-gh-bot-test"}),
765+
},
766+
),
767+
patch("ambient_runner.platform.auth.get_bot_token", return_value=cp_oidc_token),
768+
):
769+
ctx = _make_context() # no caller_token
770+
result = await _fetch_credential(ctx, "github")
771+
772+
assert result.get("token") == "gh-tok-via-oidc", (
773+
"credential fetch must succeed using CP OIDC token — "
774+
"regression for HTTP 401 on gRPC-initiated runs"
775+
)
776+
assert _CredentialHandler.captured_headers.get("Authorization") == (
777+
f"Bearer {cp_oidc_token}"
778+
), "request must use the CP OIDC token"
779+
finally:
780+
server.server_close()
781+
thread.join(timeout=2)
782+
783+
@pytest.mark.asyncio
784+
async def test_bot_token_used_when_no_caller_token(self):
785+
"""CP OIDC token (get_bot_token) is used when caller_token is absent.
786+
787+
The credential endpoint on the api-server validates via RHSSO JWT,
788+
the same issuer that signs the CP OIDC token — one token for both
789+
gRPC and HTTP credential fetches.
790+
"""
791+
called_with = {}
792+
793+
def fake_urlopen(req, timeout=None):
794+
called_with["auth"] = req.get_header("Authorization")
795+
mock_resp = MagicMock()
796+
mock_resp.read.return_value = json.dumps({"token": "ok"}).encode()
797+
mock_resp.__enter__ = lambda s: s
798+
mock_resp.__exit__ = MagicMock(return_value=False)
799+
return mock_resp
800+
801+
with (
802+
patch.dict(
803+
os.environ,
804+
{
805+
"BACKEND_API_URL": "http://backend.svc.cluster.local/api",
806+
"CREDENTIAL_IDS": json.dumps({"github": "cred-gh-pref"}),
807+
},
808+
),
809+
patch("urllib.request.urlopen", side_effect=fake_urlopen),
810+
patch("ambient_runner.platform.auth.get_bot_token", return_value="cp-oidc-token"),
811+
):
812+
ctx = _make_context() # no caller_token
813+
await _fetch_credential(ctx, "github")
814+
815+
assert called_with.get("auth") == "Bearer cp-oidc-token", (
816+
"CP OIDC token must be used for credential fetch — "
817+
"same token used for gRPC and HTTP credential endpoint"
818+
)

0 commit comments

Comments
 (0)