mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
feat(mcp): allow admin to set user credentials on behalf of any user
Add ?user_id query param to MCP credential endpoints so system admins and tenant admin/owner can manage credentials for other users. Backend: - Add resolveTargetUserID() with permission checks (system admin, tenant admin/owner) and cross-tenant defense-in-depth - Add TenantStore dependency to MCPUserCredentialsHandler Frontend: - Add searchable Combobox user selector for admin users in the credentials dialog (visible only when canManageUsers) - Update credential API calls to pass optional userId param - Add i18n keys for admin mode (en/vi/zh)
This commit is contained in:
1 parent
e1cb5c411b
commit
83c79d1b3c
6 files changed
+160
-28
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"userCredentials": {
|
||||
"title": "我的凭证",
|
||||
"description": "为此 MCP 服务器设置您的个人凭证,仅对您的账户生效。",
|
||||
"titleAdmin": "用户凭证",
|
||||
"descriptionAdmin": "为特定用户设置此 MCP 服务器的凭证。这些凭证仅覆盖所选用户的服务器默认值。",
|
||||
"selectUser": "选择用户",
|
||||
"apiKey": "API 密钥",
|
||||
"apiKeyPlaceholder": "留空以保持当前值",
|
||||
"headers": "请求头",
|
||||
|
||||
@@ -118,22 +118,25 @@ export function useMCP() {
|
||||
);
|
||||
|
||||
const getUserCredentials = useCallback(
|
||||
async (serverId: string) => {
|
||||
return http.get<MCPUserCredentialStatus>(`/v1/mcp/servers/${serverId}/user-credentials`);
|
||||
async (serverId: string, userId?: string) => {
|
||||
const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
||||
return http.get<MCPUserCredentialStatus>(`/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],
|
||||
);
|
||||
|
||||
@@ -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<MCPUserCredentialStatus>;
|
||||
onSetCredentials: (serverId: string, creds: MCPUserCredentialInput) => Promise<void>;
|
||||
onDeleteCredentials: (serverId: string) => Promise<void>;
|
||||
onGetCredentials: (serverId: string, userId?: string) => Promise<MCPUserCredentialStatus>;
|
||||
onSetCredentials: (serverId: string, creds: MCPUserCredentialInput, userId?: string) => Promise<void>;
|
||||
onDeleteCredentials: (serverId: string, userId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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<MCPUserCredentialStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -53,6 +69,20 @@ export function MCPUserCredentialsDialog({
|
||||
const [headers, setHeaders] = useState<Record<string, string>>({});
|
||||
const [env, setEnv] = useState<Record<string, string>>({});
|
||||
|
||||
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({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
{t("userCredentials.title")}
|
||||
{canManageUsers ? t("userCredentials.titleAdmin") : t("userCredentials.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("userCredentials.description")}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{canManageUsers ? t("userCredentials.descriptionAdmin") : t("userCredentials.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loadingStatus ? (
|
||||
@@ -113,6 +148,19 @@ export function MCPUserCredentialsDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* User selector — admin only */}
|
||||
{canManageUsers && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t("userCredentials.selectUser")}</Label>
|
||||
<Combobox
|
||||
value={selectedUserId}
|
||||
onChange={(val) => setSelectedUserId(val)}
|
||||
options={userOptions}
|
||||
placeholder={t("userCredentials.selectUser")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current status badges */}
|
||||
{status && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
Reference in new issue
Block a user