Skip to content

Commit 851bab0

Browse files
accorvinclaudegithub-actions[bot]
authored
fix: forward per-user auth headers to chatbot service (#56)
* fix: forward per-user auth headers from Express proxy to chatbot service The chatbot pod calls the backend API (backend:3001) to fetch roster and metrics data, but proxySecretGuard rejects those calls with 401 because they lack auth headers. This forwards the authenticated user's identity through the full request chain so the chatbot's backend API calls carry per-user auth, preventing data leaks from a service account having broader access than the actual user. Express side: - Add buildProxyHeaders() helper that forwards X-Proxy-Secret (from context.secrets) and X-Forwarded-Email (from req.userEmail) to the chatbot service on both /chat and /chat/stream endpoints - Declare PROXY_AUTH_SECRET in module.json secrets (Hard Constraint #9) Chatbot side: - Add _extract_auth() to validate X-Proxy-Secret and X-Forwarded-Email on /agent/chat and /agent/chat/stream (returns 401 if missing) - Add PerRequestClient wrapper that injects per-user auth headers into every backend API call while reusing the shared httpx connection pool - Each request gets an independent cache (no cross-user contamination) - Health endpoint remains unauthenticated (K8s probes unaffected) Deployment: - Add PROXY_AUTH_SECRET env var to chatbot-deployment.yaml from the same proxy-auth-secret Secret used by backend and frontend pods Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: validate proxy secret value in chatbot service Add timing-safe comparison of the received X-Proxy-Secret header against the expected PROXY_AUTH_SECRET env var, matching the backend's defense-in-depth pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent 0542455 commit 851bab0

6 files changed

Lines changed: 380 additions & 9 deletions

File tree

deploy/openshift/base/chatbot-deployment.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ spec:
7070
name: chatbot-secrets
7171
key: CHATBOT_EMBEDDING_API_KEY
7272
optional: true
73+
- name: PROXY_AUTH_SECRET
74+
valueFrom:
75+
secretKeyRef:
76+
name: proxy-auth-secret
77+
key: secret
78+
optional: false
7379
readinessProbe:
7480
httpGet:
7581
path: /health

modules/chatbot/module.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
},
1515
"secrets": {
1616
"module": [
17-
{ "key": "CHATBOT_SERVICE_URL", "description": "URL of the chatbot microservice (e.g. http://localhost:8002)", "required": true }
17+
{ "key": "CHATBOT_SERVICE_URL", "description": "URL of the chatbot microservice (e.g. http://localhost:8002)", "required": true },
18+
{ "key": "PROXY_AUTH_SECRET", "description": "Shared secret for internal service-to-service auth (must match backend)", "required": false }
1819
]
1920
}
2021
}

modules/chatbot/server/index.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ module.exports = function registerRoutes(router, context) {
1212
return (secrets.CHATBOT_SERVICE_URL || '').replace(/\/$/, '')
1313
}
1414

15+
function buildProxyHeaders(req) {
16+
const headers = { 'Content-Type': 'application/json' }
17+
const proxySecret = secrets.PROXY_AUTH_SECRET
18+
if (proxySecret) {
19+
headers['X-Proxy-Secret'] = proxySecret
20+
}
21+
if (req.userEmail) {
22+
headers['X-Forwarded-Email'] = req.userEmail
23+
}
24+
return headers
25+
}
26+
1527
function validateChatBody(body) {
1628
if (!body || typeof body.message !== 'string' || !body.message.trim()) {
1729
return 'message is required and must be a non-empty string'
@@ -70,7 +82,7 @@ module.exports = function registerRoutes(router, context) {
7082
try {
7183
const response = await fetch(`${serviceUrl}/agent/chat`, {
7284
method: 'POST',
73-
headers: { 'Content-Type': 'application/json' },
85+
headers: buildProxyHeaders(req),
7486
body: JSON.stringify(pickChatFields(req.body)),
7587
})
7688

@@ -129,7 +141,7 @@ module.exports = function registerRoutes(router, context) {
129141
try {
130142
const response = await fetch(`${serviceUrl}/agent/chat/stream`, {
131143
method: 'POST',
132-
headers: { 'Content-Type': 'application/json' },
144+
headers: buildProxyHeaders(req),
133145
body: JSON.stringify(pickChatFields(req.body)),
134146
})
135147

services/chatbot/routers/chat.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Chat endpoints with SSE streaming support."""
22

33
import asyncio
4+
import hmac
45
import json
56
import logging
67
import math
8+
import os
79
import time
810
import uuid
911

1012
from fastapi import APIRouter, Request
13+
from fastapi.responses import JSONResponse
1114
from starlette.responses import StreamingResponse
1215

1316
from pydantic_ai.messages import (
@@ -31,6 +34,7 @@
3134
)
3235
from models import ChatRequest, ChatResponse
3336
from prompts.system import SYSTEM_PROMPT
37+
from services.org_pulse_client import PerRequestClient
3438
from structured_logging import log_event
3539
from tools import ALL_TOOLS, retry_events
3640

@@ -50,6 +54,24 @@
5054
"Please contact an administrator."
5155
)
5256

57+
_AUTH_MISSING_MESSAGE = (
58+
"Authentication headers missing. The chatbot must be accessed through the main application."
59+
)
60+
61+
62+
_EXPECTED_PROXY_SECRET = os.environ.get("PROXY_AUTH_SECRET", "")
63+
64+
65+
def _extract_auth(http_request: Request) -> tuple[str, str] | None:
66+
"""Extract and validate proxy auth headers. Returns (proxy_secret, user_email) or None."""
67+
proxy_secret = http_request.headers.get("x-proxy-secret", "").strip()
68+
user_email = http_request.headers.get("x-forwarded-email", "").strip()
69+
if not proxy_secret or not user_email:
70+
return None
71+
if _EXPECTED_PROXY_SECRET and not hmac.compare_digest(proxy_secret, _EXPECTED_PROXY_SECRET):
72+
return None
73+
return proxy_secret, user_email
74+
5375

5476
# ---------------------------------------------------------------------------
5577
# Helpers
@@ -315,15 +337,20 @@ def _llm_error_message(exc: Exception) -> str:
315337
async def chat(request: ChatRequest, http_request: Request):
316338
request_id = uuid.uuid4().hex[:12]
317339

340+
auth = _extract_auth(http_request)
341+
if not auth:
342+
return JSONResponse(status_code=401, content={"error": _AUTH_MISSING_MESSAGE})
343+
proxy_secret, user_email = auth
344+
318345
agent = http_request.app.state.agent
319346
if not agent:
320347
return ChatResponse(message=_NOT_CONFIGURED_MESSAGE, trace={"request_id": request_id})
321348

322349
retries: list[dict] = []
323350
retry_events.set(retries)
324351

325-
org_pulse_client = http_request.app.state.org_pulse_client
326-
org_pulse_client.clear_cache()
352+
shared_client = http_request.app.state.org_pulse_client
353+
org_pulse_client = PerRequestClient(shared_client, proxy_secret, user_email)
327354
message_history = _convert_history(request.history)
328355
turn = _count_turns(request.history) + 1
329356

@@ -455,6 +482,11 @@ async def chat(request: ChatRequest, http_request: Request):
455482
async def chat_stream(request: ChatRequest, http_request: Request):
456483
request_id = uuid.uuid4().hex[:12]
457484

485+
auth = _extract_auth(http_request)
486+
if not auth:
487+
return JSONResponse(status_code=401, content={"error": _AUTH_MISSING_MESSAGE})
488+
proxy_secret, user_email = auth
489+
458490
agent = http_request.app.state.agent
459491
if not agent:
460492
async def _not_configured():
@@ -515,8 +547,8 @@ async def _refusal_stream():
515547
else:
516548
run_toolsets, tool_selection, selector_ms = await selector_coro
517549

518-
org_pulse_client = http_request.app.state.org_pulse_client
519-
org_pulse_client.clear_cache()
550+
shared_client = http_request.app.state.org_pulse_client
551+
org_pulse_client = PerRequestClient(shared_client, proxy_secret, user_email)
520552
message_history = _convert_history(request.history)
521553
turn = _count_turns(request.history) + 1
522554

services/chatbot/services/org_pulse_client.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ def clear_cache(self):
4242
async def close(self):
4343
await self._client.aclose()
4444

45-
async def _get(self, path: str, params: dict | None = None) -> dict | list | None:
45+
async def _get(self, path: str, params: dict | None = None, extra_headers: dict | None = None) -> dict | list | None:
4646
try:
47-
resp = await self._client.get(path, params=params)
47+
resp = await self._client.get(path, params=params, headers=extra_headers)
4848
resp.raise_for_status()
4949
return resp.json()
5050
except httpx.HTTPStatusError as exc:
@@ -76,3 +76,31 @@ async def get_github_contributions(self) -> dict | None:
7676

7777
async def get_gitlab_contributions(self) -> dict | None:
7878
return await self._get("/api/modules/team-tracker/gitlab/contributions")
79+
80+
81+
class PerRequestClient(OrgPulseClient):
82+
"""Wrapper that adds per-request auth headers to a shared OrgPulseClient.
83+
84+
Inherits all data-fetching methods from OrgPulseClient so new methods are
85+
automatically available. Overrides _get() to inject X-Proxy-Secret and
86+
X-Forwarded-Email via the shared client's connection pool. Maintains its
87+
own independent cache so concurrent requests with different user identities
88+
don't cross-contaminate.
89+
"""
90+
91+
def __init__(self, shared_client: OrgPulseClient, proxy_secret: str, user_email: str):
92+
# Skip OrgPulseClient.__init__ — we don't create our own httpx client
93+
self._shared = shared_client
94+
self._extra_headers = {
95+
"X-Proxy-Secret": proxy_secret,
96+
"X-Forwarded-Email": user_email,
97+
}
98+
self._cache: dict = {}
99+
100+
async def _get(self, path: str, params: dict | None = None, extra_headers: dict | None = None) -> dict | list | None:
101+
# Always use our per-request auth headers, ignore any passed extra_headers
102+
return await self._shared._get(path, params=params, extra_headers=self._extra_headers)
103+
104+
async def close(self):
105+
# No-op — the shared client owns the connection pool
106+
pass

0 commit comments

Comments
 (0)