fix(http): inject tenant context in ServeHTTP handlers for multi-tenant isolation

Four HTTP handlers (chat completions, wake, tools invoke, responses) called
resolveAuth() directly instead of using requireAuth middleware, missing
WithTenantID and WithRole context injection. This broke tenant isolation
when backend services used tenant-scoped API keys on these endpoints.

Extract enrichContext() helper from requireAuth to DRY the context setup
logic. Refactor requireAuth and requireAuthBearer to use it. Block wake
endpoint body user_id override when API key has bound owner_id to prevent
impersonation.
This commit is contained in:
viettranx committed 2026-03-24 22:15:16 +07:00
1 parent 68406f4a44
commit 781ed2a2fd
5 files changed
+67 -79

No files matched your search

+34 -48
View File
@@ -238,6 +238,38 @@ func httpMinRole(method string) permissions.Role {
}
}
// enrichContext injects locale, role, userID, and tenantID from authResult into ctx.
// Used by requireAuth middleware and ServeHTTP handlers that do their own auth checks.
func enrichContext(ctx context.Context, r *http.Request, auth authResult) context.Context {
ctx = store.WithLocale(ctx, extractLocale(r))
ctx = store.WithRole(ctx, string(auth.Role))
userID := extractUserID(r)
// If the API key has a bound owner, force user_id to owner regardless of header.
if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
if userID != "" && userID != auth.KeyData.OwnerID {
slog.Warn("security.api_key_owner_override",
"header_user_id", userID,
"owner_id", auth.KeyData.OwnerID,
)
}
userID = auth.KeyData.OwnerID
}
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
tenantID := auth.TenantID
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
ctx = store.WithTenantID(ctx, tenantID)
slog.Debug("security.http_auth_resolved",
"path", r.URL.Path,
"role", string(auth.Role),
"tenant_id", tenantID.String(),
)
return ctx
}
// requireAuth is a middleware that checks authentication and minimum role.
// Pass "" for minRole to auto-detect from HTTP method (GET→Viewer, POST→Operator).
// Injects locale, role, userID and tenantID into request context.
@@ -265,32 +297,7 @@ func requireAuth(minRole permissions.Role, next http.HandlerFunc) http.HandlerFu
return
}
ctx := store.WithLocale(r.Context(), locale)
ctx = store.WithRole(ctx, string(auth.Role))
userID := extractUserID(r)
// If the API key has a bound owner, force user_id to owner regardless of header.
if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
if userID != "" && userID != auth.KeyData.OwnerID {
slog.Warn("security.api_key_owner_override",
"header_user_id", userID,
"owner_id", auth.KeyData.OwnerID,
)
}
userID = auth.KeyData.OwnerID
}
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
tenantID := auth.TenantID
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
ctx = store.WithTenantID(ctx, tenantID)
slog.Debug("security.http_auth_resolved",
"path", r.URL.Path,
"role", string(auth.Role),
"tenant_id", tenantID.String(),
)
ctx := enrichContext(r.Context(), r, auth)
next(w, r.WithContext(ctx))
}
}
@@ -321,28 +328,7 @@ func requireAuthBearer(minRole permissions.Role, bearer string, w http.ResponseW
return r, false
}
// Apply user context + owner_id enforcement (same as requireAuth).
ctx := store.WithLocale(r.Context(), locale)
ctx = store.WithRole(ctx, string(auth.Role))
userID := extractUserID(r)
if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
if userID != "" && userID != auth.KeyData.OwnerID {
slog.Warn("security.api_key_owner_override",
"header_user_id", userID,
"owner_id", auth.KeyData.OwnerID,
"path", r.URL.Path,
)
}
userID = auth.KeyData.OwnerID
}
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
tenantID := auth.TenantID
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
ctx = store.WithTenantID(ctx, tenantID)
ctx := enrichContext(r.Context(), r, auth)
return r.WithContext(ctx), true
}
+6 -9
View File
@@ -99,6 +99,9 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
return
}
// Inject tenant, role, user, and locale into context for downstream stores/tools.
r = r.WithContext(enrichContext(r.Context(), r, auth))
// Rate limit check (per IP or bearer token)
if h.rateLimiter != nil {
key := r.RemoteAddr
@@ -128,7 +131,7 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
}
agentID := extractAgentID(r, req.Model)
userID := extractUserID(r)
userID := store.UserIDFromContext(r.Context()) // resolved by enrichContext (respects API key owner binding)
if h.isManaged && userID == "" {
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgUserIDHeader)), http.StatusBadRequest)
return
@@ -153,12 +156,6 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
return
}
// Inject user_id and locale into context for downstream stores/tools
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
runID := uuid.NewString()
// Include userID in session key for multi-tenant isolation
sessionSuffix := "http-" + runID[:8]
@@ -170,9 +167,9 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
slog.Info("chat completions request", "agent", agentID, "stream", req.Stream, "user", userID)
if req.Stream {
h.handleStream(w, r.WithContext(ctx), loop, runID, sessionKey, lastMessage, req.Model, userID)
h.handleStream(w, r, loop, runID, sessionKey, lastMessage, req.Model, userID)
} else {
h.handleNonStream(w, r.WithContext(ctx), loop, runID, sessionKey, lastMessage, req.Model, userID)
h.handleNonStream(w, r, loop, runID, sessionKey, lastMessage, req.Model, userID)
}
}
+6 -9
View File
@@ -60,6 +60,9 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Inject tenant, role, user, and locale into context for downstream stores/tools.
r = r.WithContext(enrichContext(r.Context(), r, auth))
// Limit request body size to prevent DoS
const maxRequestBodySize = 1 << 20 // 1MB
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
@@ -76,7 +79,7 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
agentID := extractAgentID(r, req.Model)
userID := extractUserID(r)
userID := store.UserIDFromContext(r.Context()) // resolved by enrichContext (respects API key owner binding)
loop, err := h.agents.Get(r.Context(), agentID)
if err != nil {
@@ -96,12 +99,6 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Inject user_id into context for downstream stores/tools
ctx := r.Context()
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
runID := uuid.NewString()
responseID := "resp-" + runID[:8]
sessionSuffix := "responses-" + runID[:8]
@@ -113,9 +110,9 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
slog.Info("responses request", "agent", agentID, "stream", req.Stream, "user", userID)
if req.Stream {
h.handleStream(w, r.WithContext(ctx), loop, runID, responseID, sessionKey, lastMessage, userID)
h.handleStream(w, r, loop, runID, responseID, sessionKey, lastMessage, userID)
} else {
h.handleNonStream(w, r.WithContext(ctx), loop, runID, responseID, sessionKey, lastMessage, userID)
h.handleNonStream(w, r, loop, runID, responseID, sessionKey, lastMessage, userID)
}
}
+5 -5
View File
@@ -55,6 +55,9 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Inject tenant, role, user, and locale into context for downstream stores/tools.
r = r.WithContext(enrichContext(r.Context(), r, auth))
var req toolsInvokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
@@ -86,13 +89,10 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Inject userID and agentID into context for interceptors (bootstrap, memory).
// Inject agentID into context for interceptors (bootstrap, memory).
// Note: userID, tenantID, role, locale already injected by enrichContext above.
ctx := r.Context()
if userID := extractUserID(r); userID != "" {
ctx = store.WithUserID(ctx, userID)
}
agentIDStr := req.AgentID
if agentIDStr == "" {
agentIDStr = extractAgentID(r, "")
+16 -8
View File
@@ -71,6 +71,9 @@ func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) {
return
}
// Inject tenant, role, user, and locale into context for downstream stores/tools.
r = r.WithContext(enrichContext(r.Context(), r, auth))
agentID := r.PathValue("id")
if agentID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
@@ -104,14 +107,19 @@ func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) {
sessionKey = sessions.SessionKey(agentID, "wake-"+uuid.NewString()[:8])
}
userID := req.UserID
if userID == "" {
userID = extractUserID(r)
}
ctx := store.WithLocale(r.Context(), locale)
if userID != "" {
ctx = store.WithUserID(ctx, userID)
// Body user_id override: allowed only when API key has no bound owner (prevents impersonation).
userID := store.UserIDFromContext(r.Context())
ctx := r.Context()
if req.UserID != "" && req.UserID != userID {
if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
slog.Warn("security.wake_owner_override_blocked",
"req_user_id", req.UserID,
"owner_id", auth.KeyData.OwnerID,
)
} else {
userID = req.UserID
ctx = store.WithUserID(ctx, req.UserID)
}
}
runID := uuid.NewString()