diff --git a/internal/http/auth.go b/internal/http/auth.go index 24c03d91..c5353bda 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -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 } diff --git a/internal/http/chat_completions.go b/internal/http/chat_completions.go index 2836b7da..4c2a6d21 100644 --- a/internal/http/chat_completions.go +++ b/internal/http/chat_completions.go @@ -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) } } diff --git a/internal/http/responses.go b/internal/http/responses.go index 70c8ddc8..d1a1709b 100644 --- a/internal/http/responses.go +++ b/internal/http/responses.go @@ -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) } } diff --git a/internal/http/tools_invoke.go b/internal/http/tools_invoke.go index 7805ba08..61126017 100644 --- a/internal/http/tools_invoke.go +++ b/internal/http/tools_invoke.go @@ -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, "") diff --git a/internal/http/wake.go b/internal/http/wake.go index 003d877a..2ebf7f73 100644 --- a/internal/http/wake.go +++ b/internal/http/wake.go @@ -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()