mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 10:14:51 +00:00
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:
1 parent
68406f4a44
commit
781ed2a2fd
5 files changed
+67
-79
No files matched your search
+34
-48
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user