diff --git a/internal/http/mcp_user_credentials.go b/internal/http/mcp_user_credentials.go index 3e2ddd1d..71c23edf 100644 --- a/internal/http/mcp_user_credentials.go +++ b/internal/http/mcp_user_credentials.go @@ -2,22 +2,24 @@ package http import ( "encoding/json" + "log/slog" "net/http" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) // MCPUserCredentialsHandler handles per-user MCP credential endpoints. type MCPUserCredentialsHandler struct { - store store.MCPServerStore - token string + store store.MCPServerStore + tenantStore store.TenantStore } // NewMCPUserCredentialsHandler creates a handler for MCP user credential endpoints. -func NewMCPUserCredentialsHandler(s store.MCPServerStore, token string) *MCPUserCredentialsHandler { - return &MCPUserCredentialsHandler{store: s, token: token} +func NewMCPUserCredentialsHandler(s store.MCPServerStore, ts store.TenantStore) *MCPUserCredentialsHandler { + return &MCPUserCredentialsHandler{store: s, tenantStore: ts} } // RegisterRoutes registers MCP user credential routes. @@ -28,7 +30,48 @@ func (h *MCPUserCredentialsHandler) RegisterRoutes(mux *http.ServeMux) { } func (h *MCPUserCredentialsHandler) auth(next http.HandlerFunc) http.HandlerFunc { - return requireAuth(h.token, "", next) + return requireAuth("", next) +} + +// resolveTargetUserID returns the effective user ID for credential operations. +// If ?user_id is absent or same as caller, returns callerID (self-service). +// If ?user_id targets another user, checks that caller is system admin or tenant admin/owner. +func (h *MCPUserCredentialsHandler) resolveTargetUserID(r *http.Request, callerID string) (string, int) { + targetID := r.URL.Query().Get("user_id") + if targetID == "" || targetID == callerID { + return callerID, 0 + } + + if err := store.ValidateUserID(targetID); err != nil { + return "", http.StatusBadRequest + } + + // System admin can always target another user. + role := permissions.Role(store.RoleFromContext(r.Context())) + if permissions.HasMinRole(role, permissions.RoleAdmin) { + return targetID, 0 + } + + // Tenant admin/owner can target users within their tenant. + if h.tenantStore != nil { + tid := store.TenantIDFromContext(r.Context()) + if tid != uuid.Nil { + callerTenantRole, err := h.tenantStore.GetUserRole(r.Context(), tid, callerID) + if err == nil && (callerTenantRole == store.TenantRoleOwner || callerTenantRole == store.TenantRoleAdmin) { + // Verify target user belongs to the same tenant. + if _, err := h.tenantStore.GetUserRole(r.Context(), tid, targetID); err == nil { + return targetID, 0 + } + } + } + } + + slog.Warn("security.mcp_credentials_forbidden", + "caller", callerID, + "target", targetID, + "role", string(role), + ) + return "", http.StatusForbidden } func (h *MCPUserCredentialsHandler) handleSet(w http.ResponseWriter, r *http.Request) { @@ -38,12 +81,18 @@ func (h *MCPUserCredentialsHandler) handleSet(w http.ResponseWriter, r *http.Req return } - userID := store.UserIDFromContext(r.Context()) - if userID == "" { + callerID := store.UserIDFromContext(r.Context()) + if callerID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) return } + userID, errCode := h.resolveTargetUserID(r, callerID) + if errCode != 0 { + writeJSON(w, errCode, map[string]string{"error": httpStatusText(errCode)}) + return + } + var creds store.MCPUserCredentials if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&creds); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) @@ -65,19 +114,24 @@ func (h *MCPUserCredentialsHandler) handleGet(w http.ResponseWriter, r *http.Req return } - userID := store.UserIDFromContext(r.Context()) - if userID == "" { + callerID := store.UserIDFromContext(r.Context()) + if callerID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) return } + userID, errCode := h.resolveTargetUserID(r, callerID) + if errCode != 0 { + writeJSON(w, errCode, map[string]string{"error": httpStatusText(errCode)}) + return + } + creds, err := h.store.GetUserCredentials(r.Context(), serverID, userID) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"has_credentials": false}) return } - // Return masked status (never expose actual credentials) writeJSON(w, http.StatusOK, map[string]any{ "has_credentials": true, "has_api_key": creds.APIKey != "", @@ -93,12 +147,18 @@ func (h *MCPUserCredentialsHandler) handleDelete(w http.ResponseWriter, r *http. return } - userID := store.UserIDFromContext(r.Context()) - if userID == "" { + callerID := store.UserIDFromContext(r.Context()) + if callerID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) return } + userID, errCode := h.resolveTargetUserID(r, callerID) + if errCode != 0 { + writeJSON(w, errCode, map[string]string{"error": httpStatusText(errCode)}) + return + } + if err := h.store.DeleteUserCredentials(r.Context(), serverID, userID); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -106,3 +166,15 @@ func (h *MCPUserCredentialsHandler) handleDelete(w http.ResponseWriter, r *http. writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } + +// httpStatusText returns a short error message for common HTTP status codes. +func httpStatusText(code int) string { + switch code { + case http.StatusBadRequest: + return "invalid user_id" + case http.StatusForbidden: + return "permission denied: admin or tenant admin required" + default: + return http.StatusText(code) + } +} diff --git a/ui/web/src/i18n/locales/en/mcp.json b/ui/web/src/i18n/locales/en/mcp.json index 695e3970..edf932c1 100644 --- a/ui/web/src/i18n/locales/en/mcp.json +++ b/ui/web/src/i18n/locales/en/mcp.json @@ -103,6 +103,9 @@ "userCredentials": { "title": "My Credentials", "description": "Set your personal credentials for this MCP server. These override the server defaults for your account only.", + "titleAdmin": "User Credentials", + "descriptionAdmin": "Set credentials for a specific user on this MCP server. These override the server defaults for the selected user only.", + "selectUser": "Select user", "apiKey": "API Key", "apiKeyPlaceholder": "Leave empty to keep current", "headers": "Headers", diff --git a/ui/web/src/i18n/locales/vi/mcp.json b/ui/web/src/i18n/locales/vi/mcp.json index 328702f1..0d7724ff 100644 --- a/ui/web/src/i18n/locales/vi/mcp.json +++ b/ui/web/src/i18n/locales/vi/mcp.json @@ -103,6 +103,9 @@ "userCredentials": { "title": "Thông tin xác thực", "description": "Đặt thông tin xác thực cá nhân của bạn cho máy chủ MCP này. Chỉ áp dụng cho tài khoản của bạn.", + "titleAdmin": "Thông tin xác thực", + "descriptionAdmin": "Đặt thông tin xác thực cho người dùng cụ thể trên máy chủ MCP này. Chúng ghi đè cài đặt mặc định của máy chủ chỉ cho người dùng được chọn.", + "selectUser": "Chọn người dùng", "apiKey": "API Key", "apiKeyPlaceholder": "Để trống để giữ nguyên", "headers": "Headers", diff --git a/ui/web/src/i18n/locales/zh/mcp.json b/ui/web/src/i18n/locales/zh/mcp.json index 8048935c..5d7d7906 100644 --- a/ui/web/src/i18n/locales/zh/mcp.json +++ b/ui/web/src/i18n/locales/zh/mcp.json @@ -103,6 +103,9 @@ "userCredentials": { "title": "我的凭证", "description": "为此 MCP 服务器设置您的个人凭证,仅对您的账户生效。", + "titleAdmin": "用户凭证", + "descriptionAdmin": "为特定用户设置此 MCP 服务器的凭证。这些凭证仅覆盖所选用户的服务器默认值。", + "selectUser": "选择用户", "apiKey": "API 密钥", "apiKeyPlaceholder": "留空以保持当前值", "headers": "请求头", diff --git a/ui/web/src/pages/mcp/hooks/use-mcp.ts b/ui/web/src/pages/mcp/hooks/use-mcp.ts index 67baa911..70ca55ad 100644 --- a/ui/web/src/pages/mcp/hooks/use-mcp.ts +++ b/ui/web/src/pages/mcp/hooks/use-mcp.ts @@ -118,22 +118,25 @@ export function useMCP() { ); const getUserCredentials = useCallback( - async (serverId: string) => { - return http.get(`/v1/mcp/servers/${serverId}/user-credentials`); + async (serverId: string, userId?: string) => { + const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; + return http.get(`/v1/mcp/servers/${serverId}/user-credentials${qs}`); }, [http], ); const setUserCredentials = useCallback( - async (serverId: string, creds: MCPUserCredentialInput) => { - await http.put(`/v1/mcp/servers/${serverId}/user-credentials`, creds); + async (serverId: string, creds: MCPUserCredentialInput, userId?: string) => { + const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; + await http.put(`/v1/mcp/servers/${serverId}/user-credentials${qs}`, creds); }, [http], ); const deleteUserCredentials = useCallback( - async (serverId: string) => { - await http.delete(`/v1/mcp/servers/${serverId}/user-credentials`); + async (serverId: string, userId?: string) => { + const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; + await http.delete(`/v1/mcp/servers/${serverId}/user-credentials${qs}`); }, [http], ); diff --git a/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx b/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx index 8fb903dd..22b88473 100644 --- a/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx +++ b/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { KeyRound, Loader2 } from "lucide-react"; import { @@ -14,7 +14,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { KeyValueEditor } from "@/components/shared/key-value-editor"; +import { Combobox } from "@/components/ui/combobox"; import { toast } from "@/stores/use-toast-store"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { useTenants } from "@/hooks/use-tenants"; +import { useTenantUsersList } from "@/pages/contacts/hooks/use-tenant-users-list"; import i18next from "i18next"; import type { MCPServerData, MCPUserCredentialStatus, MCPUserCredentialInput } from "./hooks/use-mcp"; @@ -30,9 +34,9 @@ interface MCPUserCredentialsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; server: MCPServerData; - onGetCredentials: (serverId: string) => Promise; - onSetCredentials: (serverId: string, creds: MCPUserCredentialInput) => Promise; - onDeleteCredentials: (serverId: string) => Promise; + onGetCredentials: (serverId: string, userId?: string) => Promise; + onSetCredentials: (serverId: string, creds: MCPUserCredentialInput, userId?: string) => Promise; + onDeleteCredentials: (serverId: string, userId?: string) => Promise; } export function MCPUserCredentialsDialog({ @@ -44,6 +48,18 @@ export function MCPUserCredentialsDialog({ onDeleteCredentials, }: MCPUserCredentialsDialogProps) { const { t } = useTranslation("mcp"); + const role = useAuthStore((s) => s.role); + const currentUserId = useAuthStore((s) => s.userId); + const { currentTenant } = useTenants(); + const { users } = useTenantUsersList(); + + const canManageUsers = + role === "admin" || + currentTenant?.role === "owner" || + currentTenant?.role === "admin"; + + const [selectedUserId, setSelectedUserId] = useState(currentUserId); + const [status, setStatus] = useState(null); const [loadingStatus, setLoadingStatus] = useState(false); const [saving, setSaving] = useState(false); @@ -53,6 +69,20 @@ export function MCPUserCredentialsDialog({ const [headers, setHeaders] = useState>({}); const [env, setEnv] = useState>({}); + const userOptions = useMemo( + () => + users.map((u) => ({ + value: u.user_id, + label: u.display_name || u.user_id, + })), + [users], + ); + + // Reset selected user when dialog opens + useEffect(() => { + if (open) setSelectedUserId(currentUserId); + }, [open, currentUserId]); + useEffect(() => { if (!open) return; setApiKey(""); @@ -60,11 +90,12 @@ export function MCPUserCredentialsDialog({ setEnv({}); setStatus(null); setLoadingStatus(true); - onGetCredentials(server.id) + const targetUser = canManageUsers ? selectedUserId : undefined; + onGetCredentials(server.id, targetUser) .then(setStatus) .catch(() => {}) .finally(() => setLoadingStatus(false)); - }, [open, server.id, onGetCredentials]); + }, [open, server.id, onGetCredentials, canManageUsers, selectedUserId]); const handleSave = async () => { setSaving(true); @@ -73,7 +104,8 @@ export function MCPUserCredentialsDialog({ if (apiKey.trim()) creds.api_key = apiKey.trim(); if (Object.keys(headers).length > 0) creds.headers = headers; if (Object.keys(env).length > 0) creds.env = env; - await onSetCredentials(server.id, creds); + const targetUser = canManageUsers ? selectedUserId : undefined; + await onSetCredentials(server.id, creds, targetUser); toast.success(i18next.t("mcp:userCredentials.saved")); onOpenChange(false); } catch (err) { @@ -86,7 +118,8 @@ export function MCPUserCredentialsDialog({ const handleDelete = async () => { setDeleting(true); try { - await onDeleteCredentials(server.id); + const targetUser = canManageUsers ? selectedUserId : undefined; + await onDeleteCredentials(server.id, targetUser); toast.success(i18next.t("mcp:userCredentials.deleted")); onOpenChange(false); } catch (err) { @@ -102,9 +135,11 @@ export function MCPUserCredentialsDialog({ - {t("userCredentials.title")} + {canManageUsers ? t("userCredentials.titleAdmin") : t("userCredentials.title")} - {t("userCredentials.description")} + + {canManageUsers ? t("userCredentials.descriptionAdmin") : t("userCredentials.description")} + {loadingStatus ? ( @@ -113,6 +148,19 @@ export function MCPUserCredentialsDialog({ ) : (
+ {/* User selector — admin only */} + {canManageUsers && ( +
+ + setSelectedUserId(val)} + options={userOptions} + placeholder={t("userCredentials.selectUser")} + /> +
+ )} + {/* Current status badges */} {status && (