mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
Squash merge PR #115 after resolving changelog and SQLite migration-map conflicts with current dev. Renumbered channel-context PostgreSQL migration to 000075 and bumped PG required schema to 75 plus SQLite schema to 44 so it follows the run timeline migration. Local checks passed: go test ./..., go build ./..., go build -tags sqliteonly ./..., go vet ./..., and pnpm -C ui/web build. PR CI run 26705617311 passed release-versioning, go, and web.
483 lines
15 KiB
Go
483 lines
15 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// --- Agent Grants ---
|
|
|
|
func (s *SQLiteMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGrant) error {
|
|
if err := store.ValidateUserID(g.GrantedBy); err != nil {
|
|
return err
|
|
}
|
|
if g.ID == uuid.Nil {
|
|
g.ID = store.GenNewID()
|
|
}
|
|
g.CreatedAt = time.Now().UTC()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT (server_id, agent_id) DO UPDATE SET
|
|
enabled = excluded.enabled, tool_allow = excluded.tool_allow,
|
|
tool_deny = excluded.tool_deny, config_overrides = excluded.config_overrides,
|
|
granted_by = excluded.granted_by`,
|
|
g.ID, g.ServerID, g.AgentID, g.Enabled,
|
|
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides),
|
|
g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
"DELETE FROM mcp_agent_grants WHERE server_id = ? AND agent_id = ?"+tClause,
|
|
append([]any{serverID, agentID}, tArgs...)...)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at
|
|
FROM mcp_agent_grants WHERE agent_id = ?`+tClause,
|
|
append([]any{agentID}, tArgs...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []store.MCPAgentGrant
|
|
for rows.Next() {
|
|
var g store.MCPAgentGrant
|
|
var toolAllow, toolDeny, configOverrides *string
|
|
var createdAt sqliteTime
|
|
if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled,
|
|
&toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil {
|
|
continue
|
|
}
|
|
if toolAllow != nil {
|
|
g.ToolAllow = json.RawMessage(*toolAllow)
|
|
}
|
|
if toolDeny != nil {
|
|
g.ToolDeny = json.RawMessage(*toolDeny)
|
|
}
|
|
if configOverrides != nil {
|
|
g.ConfigOverrides = json.RawMessage(*configOverrides)
|
|
}
|
|
g.CreatedAt = createdAt.Time
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.UUID) ([]store.MCPAgentGrant, error) {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, server_id, agent_id, enabled,
|
|
COALESCE(tool_allow, '[]'), COALESCE(tool_deny, '[]'),
|
|
COALESCE(config_overrides, '{}'), granted_by, created_at
|
|
FROM mcp_agent_grants WHERE server_id = ?`+tClause+` ORDER BY created_at`,
|
|
append([]any{serverID}, tArgs...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make([]store.MCPAgentGrant, 0)
|
|
for rows.Next() {
|
|
var g store.MCPAgentGrant
|
|
var toolAllow, toolDeny, configOverrides string
|
|
var createdAt sqliteTime
|
|
if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled,
|
|
&toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil {
|
|
slog.Warn("mcp.list_server_grants.scan", "error", err)
|
|
continue
|
|
}
|
|
g.ToolAllow = json.RawMessage(toolAllow)
|
|
g.ToolDeny = json.RawMessage(toolDeny)
|
|
g.ConfigOverrides = json.RawMessage(configOverrides)
|
|
g.CreatedAt = createdAt.Time
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// --- Counts ---
|
|
|
|
func (s *SQLiteMCPServerStore) CountAgentGrantsByServer(ctx context.Context) (map[uuid.UUID]int, error) {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT server_id, COUNT(*) FROM mcp_agent_grants WHERE 1=1`+tClause+` GROUP BY server_id`,
|
|
tArgs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[uuid.UUID]int)
|
|
for rows.Next() {
|
|
var serverID uuid.UUID
|
|
var count int
|
|
if err := rows.Scan(&serverID, &count); err != nil {
|
|
continue
|
|
}
|
|
result[serverID] = count
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// --- User Grants ---
|
|
|
|
func (s *SQLiteMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGrant) error {
|
|
if err := store.ValidateUserID(g.UserID); err != nil {
|
|
return err
|
|
}
|
|
if err := store.ValidateUserID(g.GrantedBy); err != nil {
|
|
return err
|
|
}
|
|
if g.ID == uuid.Nil {
|
|
g.ID = store.GenNewID()
|
|
}
|
|
g.CreatedAt = time.Now().UTC()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at, tenant_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT (server_id, user_id) DO UPDATE SET
|
|
enabled = excluded.enabled, tool_allow = excluded.tool_allow,
|
|
tool_deny = excluded.tool_deny, granted_by = excluded.granted_by`,
|
|
g.ID, g.ServerID, g.UserID, g.Enabled,
|
|
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny),
|
|
g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
"DELETE FROM mcp_user_grants WHERE server_id = ? AND user_id = ?"+tClause,
|
|
append([]any{serverID, userID}, tArgs...)...)
|
|
return err
|
|
}
|
|
|
|
// --- Resolution ---
|
|
|
|
func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) {
|
|
tClause, tArgs, err := scopeClauseAlias(ctx, "ms")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Symmetric with PG ListAccessible: synthetic owner identities ("" at
|
|
// registration, "system" for WS direct chat) skip the per-user-grant join
|
|
// so a stale disabled user_grants row never silently hides a server that
|
|
// agent_grants enables. See internal/store/pg/mcp_servers_access.go.
|
|
if userID == "" || userID == "system" {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env,
|
|
ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at,
|
|
mag.tool_allow, mag.tool_deny
|
|
FROM mcp_servers ms
|
|
INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = ? AND mag.enabled = 1
|
|
WHERE ms.enabled = 1`+tClause,
|
|
append([]any{agentID}, tArgs...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := s.scanAccessibleRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.applyContextMCPAccess(ctx, result)
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env,
|
|
ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at,
|
|
mag.tool_allow, mag.tool_deny
|
|
FROM mcp_servers ms
|
|
INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = ? AND mag.enabled = 1
|
|
LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = ?
|
|
WHERE ms.enabled = 1
|
|
AND (mug.id IS NULL OR mug.enabled = 1)`+tClause,
|
|
append([]any{agentID, userID}, tArgs...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := s.scanAccessibleRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.applyContextMCPAccess(ctx, result)
|
|
}
|
|
|
|
func (s *SQLiteMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) {
|
|
scopes := store.ChannelContextScopeChainFromContext(ctx)
|
|
if len(scopes) == 0 {
|
|
return result, nil
|
|
}
|
|
for _, scope := range scopes {
|
|
grants, err := s.ListContextGrantsForScope(ctx, scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(grants) == 0 {
|
|
continue
|
|
}
|
|
byServer := make(map[uuid.UUID]int, len(result))
|
|
for i := range result {
|
|
byServer[result[i].Server.ID] = i
|
|
}
|
|
for _, grant := range grants {
|
|
if idx, exists := byServer[grant.ServerID]; exists {
|
|
if !grant.Enabled {
|
|
result = append(result[:idx], result[idx+1:]...)
|
|
byServer = make(map[uuid.UUID]int, len(result))
|
|
for i := range result {
|
|
byServer[result[i].Server.ID] = i
|
|
}
|
|
continue
|
|
}
|
|
result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow)
|
|
result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny)
|
|
continue
|
|
}
|
|
if !grant.Enabled {
|
|
continue
|
|
}
|
|
server, err := s.GetServer(ctx, grant.ServerID)
|
|
if err != nil || server == nil || !server.Enabled {
|
|
continue
|
|
}
|
|
result = append(result, store.MCPAccessInfo{
|
|
Server: *server,
|
|
ToolAllow: decodeGrantStringList(grant.ToolAllow),
|
|
ToolDeny: decodeGrantStringList(grant.ToolDeny),
|
|
})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func decodeGrantStringList(raw json.RawMessage) []string {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil
|
|
}
|
|
var list []string
|
|
_ = json.Unmarshal(raw, &list)
|
|
return list
|
|
}
|
|
|
|
// scanAccessibleRows decodes the shared SELECT projection used by both the
|
|
// system-user (no per-user-grant filter) and external-user paths of
|
|
// ListAccessible.
|
|
func (s *SQLiteMCPServerStore) scanAccessibleRows(rows *sql.Rows) ([]store.MCPAccessInfo, error) {
|
|
defer rows.Close()
|
|
|
|
result := make([]store.MCPAccessInfo, 0)
|
|
for rows.Next() {
|
|
var srv store.MCPServerData
|
|
var displayName, command, url, apiKey, toolPrefix *string
|
|
var args, headers, env *[]byte
|
|
var toolAllowJSON, toolDenyJSON *[]byte
|
|
|
|
createdAt, updatedAt := scanTimePair()
|
|
if err := rows.Scan(
|
|
&srv.ID, &srv.Name, &displayName, &srv.Transport, &command,
|
|
&args, &url, &headers, &env,
|
|
&apiKey, &toolPrefix, &srv.TimeoutSec,
|
|
&srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt,
|
|
&toolAllowJSON, &toolDenyJSON,
|
|
); err != nil {
|
|
continue
|
|
}
|
|
srv.CreatedAt = createdAt.Time
|
|
srv.UpdatedAt = updatedAt.Time
|
|
srv.DisplayName = derefStr(displayName)
|
|
srv.Command = derefStr(command)
|
|
srv.URL = derefStr(url)
|
|
srv.ToolPrefix = derefStr(toolPrefix)
|
|
srv.Args = derefBytes(args)
|
|
srv.Headers = s.decryptJSON(derefBytes(headers))
|
|
srv.Env = s.decryptJSON(derefBytes(env))
|
|
if apiKey != nil && *apiKey != "" && s.encKey != "" {
|
|
if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil {
|
|
srv.APIKey = decrypted
|
|
}
|
|
} else {
|
|
srv.APIKey = derefStr(apiKey)
|
|
}
|
|
|
|
info := store.MCPAccessInfo{Server: srv}
|
|
if toolAllowJSON != nil {
|
|
json.Unmarshal(*toolAllowJSON, &info.ToolAllow)
|
|
}
|
|
if toolDenyJSON != nil {
|
|
json.Unmarshal(*toolDenyJSON, &info.ToolDeny)
|
|
}
|
|
result = append(result, info)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// --- Access Requests ---
|
|
|
|
func (s *SQLiteMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAccessRequest) error {
|
|
if err := store.ValidateUserID(req.RequestedBy); err != nil {
|
|
return err
|
|
}
|
|
if req.ID == uuid.Nil {
|
|
req.ID = store.GenNewID()
|
|
}
|
|
req.Status = "pending"
|
|
req.CreatedAt = time.Now().UTC()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at, tenant_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
|
req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID),
|
|
req.Scope, req.Status, nilStr(req.Reason),
|
|
jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, tenantIDForInsert(ctx),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by,
|
|
reviewed_by, reviewed_at, review_note, created_at
|
|
FROM mcp_access_requests WHERE status = 'pending'`+tClause+` ORDER BY created_at`,
|
|
tArgs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []store.MCPAccessRequest
|
|
for rows.Next() {
|
|
var r store.MCPAccessRequest
|
|
var agentID *uuid.UUID
|
|
var userID, reviewedBy, reviewNote *string
|
|
var reviewedAtSt, createdAtSt sqliteTime
|
|
if err := rows.Scan(&r.ID, &r.ServerID, &agentID, &userID, &r.Scope, &r.Status,
|
|
&r.Reason, &r.ToolAllow, &r.RequestedBy,
|
|
&reviewedBy, &reviewedAtSt, &reviewNote, &createdAtSt); err != nil {
|
|
continue
|
|
}
|
|
if !reviewedAtSt.Time.IsZero() {
|
|
r.ReviewedAt = &reviewedAtSt.Time
|
|
}
|
|
r.CreatedAt = createdAtSt.Time
|
|
r.AgentID = agentID
|
|
r.UserID = derefStr(userID)
|
|
r.ReviewedBy = derefStr(reviewedBy)
|
|
r.ReviewNote = derefStr(reviewNote)
|
|
result = append(result, r)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error {
|
|
if err := store.ValidateUserID(reviewedBy); err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var req store.MCPAccessRequest
|
|
var agentID *uuid.UUID
|
|
var userID *string
|
|
err = tx.QueryRowContext(ctx,
|
|
`SELECT id, server_id, agent_id, user_id, scope, status, tool_allow
|
|
FROM mcp_access_requests WHERE id = ? AND status = 'pending'`+tClause,
|
|
append([]any{requestID}, tArgs...)...,
|
|
).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow)
|
|
if err != nil {
|
|
return fmt.Errorf("request not found or not pending: %w", err)
|
|
}
|
|
|
|
status := "rejected"
|
|
if approved {
|
|
status = "approved"
|
|
}
|
|
now := time.Now().UTC()
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`UPDATE mcp_access_requests SET status = ?, reviewed_by = ?, reviewed_at = ?, review_note = ? WHERE id = ?`,
|
|
status, reviewedBy, now, nilStr(note), requestID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if approved {
|
|
switch req.Scope {
|
|
case "agent":
|
|
if agentID == nil {
|
|
return fmt.Errorf("agent_id required for agent scope")
|
|
}
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at, tenant_id)
|
|
VALUES (?,?,?,1,?,?,?,?)
|
|
ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`,
|
|
store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
|
|
)
|
|
case "user":
|
|
if userID == nil || *userID == "" {
|
|
return fmt.Errorf("user_id required for user scope")
|
|
}
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at, tenant_id)
|
|
VALUES (?,?,?,1,?,?,?,?)
|
|
ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`,
|
|
store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
|
|
)
|
|
default:
|
|
return fmt.Errorf("unknown scope: %s", req.Scope)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|