Files
Duy /zuey/ 2cd750ac44 feat(channels): add context capability admin surface
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.
2026-05-31 13:50:31 +07:00

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()
}