diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index f5d1bc20..27962bc5 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -62,6 +62,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores != nil && stores.ChannelInstances != nil { channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, stores.Tenants, msgBus) + channelInstancesH.SetCapabilityStores(stores.MCP, stores.SecureCLI) } if stores != nil && stores.Providers != nil { diff --git a/docs/23-ai-agent-permission-matrix.md b/docs/23-ai-agent-permission-matrix.md index c3ef1d92..b64e734b 100644 --- a/docs/23-ai-agent-permission-matrix.md +++ b/docs/23-ai-agent-permission-matrix.md @@ -12,6 +12,7 @@ This matrix documents the effective authorization layers for agent actions acros | Agent config permissions | Agent config mutations from chat | `agent_config_permissions` | Matches by `agent_id`, `scope`, `config_type`, `user_id`, including wildcard rows. | | Workspace file boundary | Filesystem access | tool sandbox/boundary checks | Prevents path escape and unsupported writes. | | Context file boundary | Agent identity/context files | `ContextFileInterceptor` | Routes protected files to store and requires group writer permission in group contexts. | +| Channel context capabilities | MCP + Secure CLI tool execution | `mcp_context_*`, `secure_cli_context_*` | Effective precedence: user credentials > context credentials/grants > agent grants > global defaults. | ## Agent Config Permission Rows @@ -69,3 +70,17 @@ The Permissions tab should expose a full matrix editor: - Permission-store errors fail closed for group mutation boundaries. - Backend validation rejects unknown config types and permissions before writing rules. - Platform send permissions are still separate from GoClaw permissions; a channel adapter may reject delivery even when GoClaw allows the agent action. + +## Channel context capabilities + +Channel instances expose stored contexts in the dashboard and API. The base +context is the channel instance itself; group contexts come from stored channel +contacts. Capability rows combine MCP and Secure CLI visibility for that +context, including source, enabled state, tool allow/deny lists, and credential +presence. + +Context credential rows never return secret material. They only project +metadata such as `has_api_key`, `has_env`, `credential_source`, and key names +where available. Writes are tenant-admin gated, and runtime resolution carries +`ChannelContextScope` so grants and credentials are applied only to matching +channel/group scope. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 86d57df6..a6d18592 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,23 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-05-29 +### Channel context admin surface (issue #66) + +**New** + +- Added channel context APIs under `/v1/channels/instances/{id}/contexts` for stored channel/group contexts, members, and effective MCP/Secure CLI capability matrix. +- Added tenant-scoped context grants and context credentials for MCP servers and Secure CLI binaries, with PostgreSQL migration `000075` and SQLite schema version `44`. +- Runtime tool execution now carries `ChannelContextScope`; MCP and Secure CLI resolution apply context grants/credentials before user credential overrides. +- Web dashboard channel detail page now has a `Contexts` tab showing contexts, stored members, effective granted tools, and scoped credential presence without exposing secret values. + +**Security** + +- Context capability write APIs use tenant-admin checks. +- Credential APIs return presence/metadata only; raw MCP API keys, headers/env values, and Secure CLI env values are never serialized. +- MCP grant-check cache key includes channel scope so scoped grants do not leak across channel contexts. + +--- + ### Group chat context in agent prompts **Features** diff --git a/internal/agent/loop_pipeline_tool_callbacks.go b/internal/agent/loop_pipeline_tool_callbacks.go index 4749ecfe..90373ab1 100644 --- a/internal/agent/loop_pipeline_tool_callbacks.go +++ b/internal/agent/loop_pipeline_tool_callbacks.go @@ -44,6 +44,7 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch }) } + ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req)) // C2 fix: route through executeToolForActor so per-user MCP tools // resolve to the calling user's BridgeTool (not the first user's @@ -106,6 +107,7 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch }) } + ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req)) // C2 fix (parallel path): route through executeToolForActor for per-user // MCP tool isolation. Same rationale as makeExecuteToolCall above. @@ -127,6 +129,22 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc } } +func channelContextScopeForRun(req *RunRequest) store.ChannelContextScope { + if req == nil || req.Channel == "" { + return store.ChannelContextScope{} + } + scope := store.ChannelContextScope{ + ChannelInstanceName: req.Channel, + ScopeType: store.ChannelScopeTypeChannel, + ScopeKey: req.Channel, + } + if req.PeerKind == "group" && req.ChatID != "" { + scope.ScopeType = store.ChannelScopeTypeGroup + scope.ScopeKey = req.ChatID + } + return scope +} + // makeProcessToolResult wraps post-execution bookkeeping (sequential, mutates bridgeRS). // rawData is *toolRawResult from ExecuteToolRaw — no re-execution. func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall, rawMsg providers.Message, rawData any) []providers.Message { diff --git a/internal/http/channel_instance_capabilities.go b/internal/http/channel_instance_capabilities.go new file mode 100644 index 00000000..493f7579 --- /dev/null +++ b/internal/http/channel_instance_capabilities.go @@ -0,0 +1,300 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelCapabilityDTO struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Enabled bool `json:"enabled"` + Source string `json:"source"` + ToolAllow []string `json:"tool_allow,omitempty"` + ToolDeny []string `json:"tool_deny,omitempty"` + CredentialSource string `json:"credential_source"` + HasCredential bool `json:"has_credential"` + ContextGrant bool `json:"context_grant_configured"` + ContextCredentials bool `json:"context_credentials_configured"` +} + +func (h *ChannelInstancesHandler) handleListContextCapabilities(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + scopeType := r.PathValue("scopeType") + scopeKey := r.PathValue("scopeKey") + if scopeType == "" || scopeKey == "" { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopeType and scopeKey")) + return + } + + userID := r.URL.Query().Get("user_id") + if userID == "" { + userID = store.CredentialUserIDFromContext(r.Context()) + } else if userID != store.CredentialUserIDFromContext(r.Context()) && !h.requireContextAdmin(w, r) { + return + } + + scope := store.ChannelContextScope{ChannelInstanceID: inst.ID, ChannelInstanceName: inst.Name, ScopeType: scopeType, ScopeKey: scopeKey} + r = r.WithContext(store.WithChannelContextScope(r.Context(), scope)) + + mcpRows, err := h.listChannelMCPRows(r, inst, scope, userID) + if err != nil { + slog.Error("channel_instances.capabilities_mcp", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP capabilities")) + return + } + cliRows, err := h.listChannelCLIRows(r, inst, scope, userID) + if err != nil { + slog.Error("channel_instances.capabilities_cli", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI capabilities")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "scope_type": scopeType, + "scope_key": scopeKey, + "capabilities": append(mcpRows, cliRows...), + "mcp": mcpRows, + "secure_cli": cliRows, + }) +} + +func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *store.ChannelInstanceData, scope store.ChannelContextScope, userID string) ([]channelCapabilityDTO, error) { + if h.mcpStore == nil { + return []channelCapabilityDTO{}, nil + } + accessible, err := h.mcpStore.ListAccessible(r.Context(), inst.AgentID, userID) + if err != nil { + return nil, err + } + contextGrants := map[uuidString]store.MCPContextGrant{} + if h.mcpContextStore != nil { + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + grants, err := h.mcpContextStore.ListContextGrants(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, grant := range grants { + contextGrants[uuidString(grant.ServerID.String())] = grant + } + } + } + contextCreds := map[uuidString]store.MCPContextCredentials{} + if h.mcpContextStore != nil { + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, cred := range creds { + contextCreds[uuidString(cred.ServerID.String())] = cred + } + } + } + rows := make([]channelCapabilityDTO, 0, len(accessible)) + seen := map[uuidString]bool{} + for _, info := range accessible { + idKey := uuidString(info.Server.ID.String()) + seen[idKey] = true + credentialSource := "global" + hasCredential := info.Server.APIKey != "" || len(info.Server.Headers) > 0 || len(info.Server.Env) > 0 + contextCredential := false + if cred, ok := contextCreds[idKey]; ok { + credentialSource = cred.ScopeType + hasCredential = cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0 + contextCredential = true + } + if userID != "" { + if creds, err := h.mcpStore.GetUserCredentials(r.Context(), info.Server.ID, userID); err == nil && creds != nil { + if creds.APIKey != "" || len(creds.Headers) > 0 || len(creds.Env) > 0 { + credentialSource = "user" + hasCredential = true + } + } + } + source := "agent" + enabled := info.Server.Enabled + toolAllow := info.ToolAllow + toolDeny := info.ToolDeny + contextGrant := false + if grant, ok := contextGrants[idKey]; ok { + source = grant.ScopeType + enabled = grant.Enabled + toolAllow = decodeStringList(grant.ToolAllow) + toolDeny = decodeStringList(grant.ToolDeny) + contextGrant = true + } + rows = append(rows, channelCapabilityDTO{ + Type: "mcp_server", + ID: info.Server.ID.String(), + Name: info.Server.Name, + DisplayName: info.Server.DisplayName, + Enabled: enabled, + Source: source, + ToolAllow: toolAllow, + ToolDeny: toolDeny, + CredentialSource: credentialSource, + HasCredential: hasCredential, + ContextGrant: contextGrant, + ContextCredentials: contextCredential, + }) + } + for _, grant := range contextGrants { + idKey := uuidString(grant.ServerID.String()) + if seen[idKey] { + continue + } + server, err := h.mcpStore.GetServer(r.Context(), grant.ServerID) + if err != nil || server == nil { + continue + } + cred, hasCredRow := contextCreds[idKey] + credentialSource := grant.ScopeType + if hasCredRow { + credentialSource = cred.ScopeType + } + rows = append(rows, channelCapabilityDTO{ + Type: "mcp_server", + ID: server.ID.String(), + Name: server.Name, + DisplayName: server.DisplayName, + Enabled: server.Enabled && grant.Enabled, + Source: grant.ScopeType, + ToolAllow: decodeStringList(grant.ToolAllow), + ToolDeny: decodeStringList(grant.ToolDeny), + CredentialSource: credentialSource, + HasCredential: hasCredRow && (cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0), + ContextGrant: true, + ContextCredentials: hasCredRow, + }) + } + return rows, nil +} + +func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *store.ChannelInstanceData, scope store.ChannelContextScope, userID string) ([]channelCapabilityDTO, error) { + if h.secureCLIStore == nil { + return []channelCapabilityDTO{}, nil + } + binaries, err := h.secureCLIStore.ListForAgent(r.Context(), inst.AgentID) + if err != nil { + return nil, err + } + contextGrants := map[uuidString]store.SecureCLIContextGrant{} + if h.cliContextStore != nil { + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + grants, err := h.cliContextStore.ListContextGrants(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, grant := range grants { + contextGrants[uuidString(grant.BinaryID.String())] = grant + } + } + } + contextCreds := map[uuidString]store.SecureCLIContextCredentials{} + if h.cliContextStore != nil { + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + creds, err := h.cliContextStore.ListContextCredentials(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, cred := range creds { + contextCreds[uuidString(cred.BinaryID.String())] = cred + } + } + } + rows := make([]channelCapabilityDTO, 0, len(binaries)) + seen := map[uuidString]bool{} + for _, b := range binaries { + idKey := uuidString(b.ID.String()) + seen[idKey] = true + source := "agent" + if b.IsGlobal { + source = "global" + } + credentialSource := source + hasCredential := len(b.EncryptedEnv) > 0 + contextGrant := false + if grant, ok := contextGrants[idKey]; ok { + source = grant.ScopeType + contextGrant = true + b.Enabled = b.Enabled && grant.Enabled + } + contextCredential := false + if cred, ok := contextCreds[idKey]; ok { + credentialSource = cred.ScopeType + hasCredential = len(cred.EncryptedEnv) > 0 + contextCredential = true + } + if userID != "" { + if creds, err := h.secureCLIStore.GetUserCredentials(r.Context(), b.ID, userID); err == nil && creds != nil && len(creds.EncryptedEnv) > 0 { + credentialSource = "user" + hasCredential = true + } + } + rows = append(rows, channelCapabilityDTO{ + Type: "secure_cli", + ID: b.ID.String(), + Name: b.BinaryName, + DisplayName: b.Description, + Enabled: b.Enabled, + Source: source, + CredentialSource: credentialSource, + HasCredential: hasCredential, + ContextGrant: contextGrant, + ContextCredentials: contextCredential, + }) + } + for _, grant := range contextGrants { + idKey := uuidString(grant.BinaryID.String()) + if seen[idKey] { + continue + } + binary, err := h.secureCLIStore.Get(r.Context(), grant.BinaryID) + if err != nil || binary == nil { + continue + } + _, hasCredRow := contextCreds[idKey] + credentialSource := grant.ScopeType + if cred, ok := contextCreds[idKey]; ok { + credentialSource = cred.ScopeType + } + rows = append(rows, channelCapabilityDTO{ + Type: "secure_cli", + ID: binary.ID.String(), + Name: binary.BinaryName, + DisplayName: binary.Description, + Enabled: binary.Enabled && grant.Enabled, + Source: grant.ScopeType, + CredentialSource: credentialSource, + HasCredential: hasCredRow, + ContextGrant: true, + ContextCredentials: hasCredRow, + }) + } + return rows, nil +} + +type uuidString string + +func decodeStringList(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var list []string + _ = json.Unmarshal(raw, &list) + return list +} diff --git a/internal/http/channel_instance_capabilities_test.go b/internal/http/channel_instance_capabilities_test.go new file mode 100644 index 00000000..bf719c87 --- /dev/null +++ b/internal/http/channel_instance_capabilities_test.go @@ -0,0 +1,210 @@ +package http + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeChannelCapabilityMCPStore struct { + accessible []store.MCPAccessInfo + userCreds map[uuid.UUID]*store.MCPUserCredentials +} + +func (s *fakeChannelCapabilityMCPStore) CreateServer(context.Context, *store.MCPServerData) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GetServer(context.Context, uuid.UUID) (*store.MCPServerData, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) GetServerByName(context.Context, string) (*store.MCPServerData, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) ListServers(context.Context) ([]store.MCPServerData, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) UpdateServer(context.Context, uuid.UUID, map[string]any) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) DeleteServer(context.Context, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GrantToAgent(context.Context, *store.MCPAgentGrant) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) ListAgentGrants(context.Context, uuid.UUID) ([]store.MCPAgentGrant, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) ListServerGrants(context.Context, uuid.UUID) ([]store.MCPAgentGrant, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) GrantToUser(context.Context, *store.MCPUserGrant) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) RevokeFromUser(context.Context, uuid.UUID, string) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) CountAgentGrantsByServer(context.Context) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{}, nil +} +func (s *fakeChannelCapabilityMCPStore) ListAccessible(context.Context, uuid.UUID, string) ([]store.MCPAccessInfo, error) { + return s.accessible, nil +} +func (s *fakeChannelCapabilityMCPStore) CreateRequest(context.Context, *store.MCPAccessRequest) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) ListPendingRequests(context.Context) ([]store.MCPAccessRequest, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) ReviewRequest(context.Context, uuid.UUID, bool, string, string) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GetUserCredentials(_ context.Context, serverID uuid.UUID, _ string) (*store.MCPUserCredentials, error) { + if creds := s.userCreds[serverID]; creds != nil { + return creds, nil + } + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) SetUserCredentials(context.Context, uuid.UUID, string, store.MCPUserCredentials) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} + +type fakeChannelCapabilityCLIStore struct { + binaries []store.SecureCLIBinary + userCreds map[uuid.UUID]*store.SecureCLIUserCredential +} + +func (s *fakeChannelCapabilityCLIStore) Create(context.Context, *store.SecureCLIBinary) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) Update(context.Context, uuid.UUID, map[string]any) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) Delete(context.Context, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} +func (s *fakeChannelCapabilityCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} +func (s *fakeChannelCapabilityCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) { + return s.binaries, nil +} +func (s *fakeChannelCapabilityCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) { + return false, nil +} +func (s *fakeChannelCapabilityCLIStore) GetUserCredentials(_ context.Context, binaryID uuid.UUID, _ string) (*store.SecureCLIUserCredential, error) { + if creds := s.userCreds[binaryID]; creds != nil { + return creds, nil + } + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) { + return nil, nil +} + +func TestChannelContextCapabilitiesExposeOnlyCredentialMetadata(t *testing.T) { + token := "channel-capabilities-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "tenant-user-1"}, + }) + + instID := uuid.New() + agentID := uuid.New() + channelName := "discord-admin" + mcpID := uuid.New() + cliID := uuid.New() + mcpStore := &fakeChannelCapabilityMCPStore{ + accessible: []store.MCPAccessInfo{{ + Server: store.MCPServerData{ + BaseModel: store.BaseModel{ID: mcpID}, + Name: "github", + DisplayName: "GitHub", + APIKey: "global-value", + Enabled: true, + }, + ToolAllow: []string{"issues.create"}, + }}, + userCreds: map[uuid.UUID]*store.MCPUserCredentials{ + mcpID: {APIKey: "user-value"}, + }, + } + cliStore := &fakeChannelCapabilityCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: cliID}, + BinaryName: "gh", + Description: "GitHub CLI", + EncryptedEnv: []byte(`{"VALUE":"global-value"}`), + IsGlobal: true, + Enabled: true, + }}, + userCreds: map[uuid.UUID]*store.SecureCLIUserCredential{ + cliID: {BinaryID: cliID, UserID: "tenant-user-1", EncryptedEnv: []byte(`{"VALUE":"user-value"}`)}, + }, + } + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "discord", AgentID: agentID}}, + nil, nil, nil, nil, nil, + ) + handler.SetCapabilityStores(mcpStore, cliStore) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/channel/"+channelName+"/capabilities", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + bodyText := rec.Body.String() + if strings.Contains(bodyText, "global-value") || strings.Contains(bodyText, "user-value") { + t.Fatalf("capability response leaked credential material: %s", bodyText) + } + var body struct { + MCP []channelCapabilityDTO `json:"mcp"` + SecureCLI []channelCapabilityDTO `json:"secure_cli"` + } + if err := json.Unmarshal([]byte(bodyText), &body); err != nil { + t.Fatal(err) + } + if len(body.MCP) != 1 || body.MCP[0].CredentialSource != "user" || !body.MCP[0].HasCredential { + t.Fatalf("mcp capability = %+v", body.MCP) + } + if len(body.SecureCLI) != 1 || body.SecureCLI[0].Source != "global" || body.SecureCLI[0].CredentialSource != "user" || !body.SecureCLI[0].HasCredential { + t.Fatalf("secure cli capability = %+v", body.SecureCLI) + } +} diff --git a/internal/http/channel_instance_context_capability_admin.go b/internal/http/channel_instance_context_capability_admin.go new file mode 100644 index 00000000..ac0e7c16 --- /dev/null +++ b/internal/http/channel_instance_context_capability_admin.go @@ -0,0 +1,530 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelContextPath struct { + Instance *store.ChannelInstanceData + ScopeType string + ScopeKey string +} + +func (h *ChannelInstancesHandler) resolveContextPath(w http.ResponseWriter, r *http.Request) (channelContextPath, bool) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return channelContextPath{}, false + } + scopeType := r.PathValue("scopeType") + scopeKey := r.PathValue("scopeKey") + if !validateHTTPChannelScope(scopeType, scopeKey) { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope")) + return channelContextPath{}, false + } + return channelContextPath{Instance: inst, ScopeType: scopeType, ScopeKey: scopeKey}, true +} + +func validateHTTPChannelScope(scopeType, scopeKey string) bool { + scope := store.ChannelContextScope{ChannelInstanceName: "placeholder", ScopeType: scopeType, ScopeKey: scopeKey} + return scope.Valid() +} + +func (h *ChannelInstancesHandler) requireContextAdmin(w http.ResponseWriter, r *http.Request) bool { + return requireTenantAdmin(w, r, h.tenantStore) +} + +type contextMCPGrantRequest struct { + Enabled *bool `json:"enabled,omitempty"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty"` + ConfigOverrides json.RawMessage `json:"config_overrides,omitempty"` +} + +func (h *ChannelInstancesHandler) handleListContextMCPGrants(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + grants, err := h.mcpContextStore.ListContextGrants(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.mcp_grants.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP context grants")) + return + } + writeJSON(w, http.StatusOK, map[string]any{"grants": grants}) +} + +func (h *ChannelInstancesHandler) handleUpsertContextMCPGrant(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + var req contextMCPGrantRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + grant := &store.MCPContextGrant{ + ChannelInstanceID: path.Instance.ID, + ScopeType: path.ScopeType, + ScopeKey: path.ScopeKey, + ServerID: serverID, + Enabled: enabled, + ToolAllow: req.ToolAllow, + ToolDeny: req.ToolDeny, + ConfigOverrides: req.ConfigOverrides, + GrantedBy: store.UserIDFromContext(r.Context()), + } + if err := h.mcpContextStore.UpsertContextGrant(r.Context(), grant); err != nil { + slog.Error("channel_context.mcp_grants.upsert", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_grant_upserted", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextMCPGrant(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + if err := h.mcpContextStore.DeleteContextGrant(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, serverID); err != nil { + slog.Error("channel_context.mcp_grants.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_grant_deleted", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} + +type contextMCPCredentialSummary struct { + ChannelInstanceID uuid.UUID `json:"channel_instance_id"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + ServerID uuid.UUID `json:"server_id"` + HasAPIKey bool `json:"has_api_key"` + HasHeaders bool `json:"has_headers"` + HasEnv bool `json:"has_env"` + CreatedBy string `json:"created_by"` +} + +func summarizeMCPCredentials(creds store.MCPContextCredentials) contextMCPCredentialSummary { + return contextMCPCredentialSummary{ + ChannelInstanceID: creds.ChannelInstanceID, + ScopeType: creds.ScopeType, + ScopeKey: creds.ScopeKey, + ServerID: creds.ServerID, + HasAPIKey: creds.APIKey != "", + HasHeaders: len(creds.Headers) > 0, + HasEnv: len(creds.Env) > 0, + CreatedBy: creds.CreatedBy, + } +} + +func (h *ChannelInstancesHandler) handleListContextMCPCredentials(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.mcp_credentials.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP context credentials")) + return + } + out := make([]contextMCPCredentialSummary, 0, len(creds)) + for _, c := range creds { + out = append(out, summarizeMCPCredentials(c)) + } + writeJSON(w, http.StatusOK, map[string]any{"credentials": out}) +} + +func (h *ChannelInstancesHandler) handleSetContextMCPCredentials(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + var creds store.MCPContextCredentials + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&creds); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + creds.ChannelInstanceID = path.Instance.ID + creds.ScopeType = path.ScopeType + creds.ScopeKey = path.ScopeKey + creds.ServerID = serverID + creds.CreatedBy = store.UserIDFromContext(r.Context()) + if err := h.mcpContextStore.SetContextCredentials(r.Context(), &creds); err != nil { + slog.Error("channel_context.mcp_credentials.set", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_credentials_set", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextMCPCredentials(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + if err := h.mcpContextStore.DeleteContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, serverID); err != nil { + slog.Error("channel_context.mcp_credentials.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_credentials_deleted", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} + +type contextCLIGrantRequest struct { + Enabled *bool `json:"enabled,omitempty"` + EnvVars json.RawMessage `json:"env_vars,omitempty"` + DenyArgs *json.RawMessage `json:"deny_args,omitempty"` + DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty"` + Tips *string `json:"tips,omitempty"` +} + +func (h *ChannelInstancesHandler) handleListContextCLIGrants(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + grants, err := h.cliContextStore.ListContextGrants(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.cli_grants.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI context grants")) + return + } + for i := range grants { + populateContextGrantEnvFields(&grants[i]) + } + writeJSON(w, http.StatusOK, map[string]any{"grants": grants}) +} + +func populateContextGrantEnvFields(g *store.SecureCLIContextGrant) { + if len(g.EncryptedEnv) == 0 { + g.EnvKeys = []string{} + g.Env = nil + g.EnvSet = false + return + } + g.EnvKeys = store.SecureCLIEnvKeys(g.EncryptedEnv) + g.Env = store.SanitizeSecureCLIEnvJSON(g.EncryptedEnv) + g.EnvSet = len(g.EnvKeys) > 0 +} + +func (h *ChannelInstancesHandler) handleUpsertContextCLIGrant(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + var req contextCLIGrantRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + var envJSON []byte + if len(req.EnvVars) > 0 { + var ok bool + envJSON, ok = validateAndSerializeEnvVars(w, locale, req.EnvVars) + if !ok { + return + } + } + grant := &store.SecureCLIContextGrant{ + ChannelInstanceID: path.Instance.ID, + ScopeType: path.ScopeType, + ScopeKey: path.ScopeKey, + BinaryID: binaryID, + DenyArgs: req.DenyArgs, + DenyVerbose: req.DenyVerbose, + TimeoutSeconds: req.TimeoutSeconds, + Tips: req.Tips, + EncryptedEnv: envJSON, + Enabled: enabled, + GrantedBy: store.UserIDFromContext(r.Context()), + } + if err := h.cliContextStore.UpsertContextGrant(r.Context(), grant); err != nil { + slog.Error("channel_context.cli_grants.upsert", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_grant_upserted", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextCLIGrant(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + if err := h.cliContextStore.DeleteContextGrant(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, binaryID); err != nil { + slog.Error("channel_context.cli_grants.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_grant_deleted", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} + +type contextCLICredentialRequest struct { + EnvVars json.RawMessage `json:"env_vars"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CredentialType *string `json:"credential_type,omitempty"` + HostScope *string `json:"host_scope,omitempty"` +} + +type contextCLICredentialSummary struct { + ChannelInstanceID uuid.UUID `json:"channel_instance_id"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + BinaryID uuid.UUID `json:"binary_id"` + EnvSet bool `json:"env_set"` + EnvKeys []string `json:"env_keys"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CredentialType *string `json:"credential_type,omitempty"` + HostScope *string `json:"host_scope,omitempty"` + CreatedBy string `json:"created_by"` +} + +func summarizeCLICredentials(creds store.SecureCLIContextCredentials) contextCLICredentialSummary { + keys := store.SecureCLIEnvKeys(creds.EncryptedEnv) + return contextCLICredentialSummary{ + ChannelInstanceID: creds.ChannelInstanceID, + ScopeType: creds.ScopeType, + ScopeKey: creds.ScopeKey, + BinaryID: creds.BinaryID, + EnvSet: len(keys) > 0, + EnvKeys: keys, + Metadata: creds.Metadata, + CredentialType: creds.CredentialType, + HostScope: creds.HostScope, + CreatedBy: creds.CreatedBy, + } +} + +func (h *ChannelInstancesHandler) handleListContextCLICredentials(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + creds, err := h.cliContextStore.ListContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.cli_credentials.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI context credentials")) + return + } + out := make([]contextCLICredentialSummary, 0, len(creds)) + for _, c := range creds { + out = append(out, summarizeCLICredentials(c)) + } + writeJSON(w, http.StatusOK, map[string]any{"credentials": out}) +} + +func (h *ChannelInstancesHandler) handleSetContextCLICredentials(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + var req contextCLICredentialRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + if len(req.EnvVars) == 0 { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "env_vars")) + return + } + envJSON, ok := validateAndSerializeEnvVars(w, locale, req.EnvVars) + if !ok { + return + } + creds := &store.SecureCLIContextCredentials{ + ChannelInstanceID: path.Instance.ID, + ScopeType: path.ScopeType, + ScopeKey: path.ScopeKey, + BinaryID: binaryID, + EncryptedEnv: envJSON, + Metadata: req.Metadata, + CredentialType: req.CredentialType, + HostScope: req.HostScope, + CreatedBy: store.UserIDFromContext(r.Context()), + } + if err := h.cliContextStore.SetContextCredentials(r.Context(), creds); err != nil { + slog.Error("channel_context.cli_credentials.set", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_credentials_set", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextCLICredentials(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + if err := h.cliContextStore.DeleteContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, binaryID); err != nil { + slog.Error("channel_context.cli_credentials.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_credentials_deleted", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} diff --git a/internal/http/channel_instance_contexts.go b/internal/http/channel_instance_contexts.go new file mode 100644 index 00000000..b34130b5 --- /dev/null +++ b/internal/http/channel_instance_contexts.go @@ -0,0 +1,215 @@ +package http + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelContextDTO struct { + ChannelInstanceID string `json:"channel_instance_id"` + ChannelType string `json:"channel_type"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + DisplayName string `json:"display_name"` + Source string `json:"source"` + PeerKind string `json:"peer_kind,omitempty"` + ContactType string `json:"contact_type,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + MemberCount int `json:"member_count,omitempty"` + LiveMembers bool `json:"live_members_supported"` +} + +type channelContextMemberDTO struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name,omitempty"` + Username string `json:"username,omitempty"` + PlatformID string `json:"platform_id"` + Source string `json:"source"` + Status string `json:"membership_status"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` +} + +func (h *ChannelInstancesHandler) handleListContexts(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + if h.contactStore == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "contexts": []channelContextDTO{baseChannelContextDTO(*inst)}, + }) + return + } + + opts := store.ContactListOpts{ + ChannelType: inst.ChannelType, + ChannelInstance: inst.Name, + ContactType: "group", + Limit: 200, + } + contacts, err := h.contactStore.ListContacts(r.Context(), opts) + if err != nil { + slog.Error("channel_instances.contexts", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel contexts")) + return + } + + contexts := []channelContextDTO{baseChannelContextDTO(*inst)} + for _, c := range contacts { + ctxDTO := contextDTOFromContact(inst.ID, c) + contexts = append(contexts, ctxDTO) + } + + writeJSON(w, http.StatusOK, map[string]any{"contexts": contexts}) +} + +func (h *ChannelInstancesHandler) handleListContextMembers(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + + scopeType := strings.TrimSpace(r.PathValue("scopeType")) + scopeKey := strings.TrimSpace(r.PathValue("scopeKey")) + if scopeType == "" || scopeKey == "" { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopeType and scopeKey")) + return + } + + var liveErr string + members := make([]channelContextMemberDTO, 0) + if h.channelMgr != nil && scopeType == store.ChannelScopeTypeGroup { + if live, err := h.channelMgr.ListGroupMembers(r.Context(), inst.Name, scopeKey); err == nil { + for _, m := range live { + members = append(members, channelContextMemberDTO{ + UserID: m.MemberID, + DisplayName: m.Name, + PlatformID: m.MemberID, + Source: "live_provider", + Status: "visible", + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "members": members, + "live_members_supported": true, + "source": "live_provider", + }) + return + } else { + liveErr = err.Error() + } + } + + if h.contactStore != nil && scopeType != store.ChannelScopeTypeGroup && scopeType != "topic" { + contacts, err := h.contactStore.ListContacts(r.Context(), store.ContactListOpts{ + ChannelType: inst.ChannelType, + ChannelInstance: inst.Name, + ContactType: "user", + PeerKind: peerKindForScope(scopeType), + Limit: 200, + }) + if err != nil { + slog.Error("channel_instances.context_members", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel members")) + return + } + for _, c := range contacts { + members = append(members, memberDTOFromContact(c)) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "members": members, + "live_members_supported": false, + "source": "stored_contacts", + "unsupported_reason": liveErr, + }) +} + +func baseChannelContextDTO(inst store.ChannelInstanceData) channelContextDTO { + name := inst.DisplayName + if name == "" { + name = inst.Name + } + return channelContextDTO{ + ChannelInstanceID: inst.ID.String(), + ChannelType: inst.ChannelType, + ScopeType: store.ChannelScopeTypeChannel, + ScopeKey: inst.Name, + DisplayName: name, + Source: "channel_instance", + PeerKind: "direct", + ContactType: "channel", + LiveMembers: false, + } +} + +func contextDTOFromContact(instanceID uuid.UUID, c store.ChannelContact) channelContextDTO { + scopeType := store.ChannelScopeTypeGroup + if c.ContactType == "topic" { + scopeType = "topic" + } + displayName := c.SenderID + if c.DisplayName != nil && *c.DisplayName != "" { + displayName = *c.DisplayName + } + peerKind := "" + if c.PeerKind != nil { + peerKind = *c.PeerKind + } + lastSeen := c.LastSeenAt + return channelContextDTO{ + ChannelInstanceID: instanceID.String(), + ChannelType: c.ChannelType, + ScopeType: scopeType, + ScopeKey: c.SenderID, + DisplayName: displayName, + Source: "stored_contact", + PeerKind: peerKind, + ContactType: c.ContactType, + LastSeenAt: &lastSeen, + } +} + +func memberDTOFromContact(c store.ChannelContact) channelContextMemberDTO { + displayName := "" + if c.DisplayName != nil { + displayName = *c.DisplayName + } + username := "" + if c.Username != nil { + username = *c.Username + } + userID := c.SenderID + if c.UserID != nil && *c.UserID != "" { + userID = *c.UserID + } + lastSeen := c.LastSeenAt + return channelContextMemberDTO{ + UserID: userID, + DisplayName: displayName, + Username: username, + PlatformID: c.SenderID, + Source: "stored_contact", + Status: "visible", + LastSeenAt: &lastSeen, + } +} + +func peerKindForScope(scopeType string) string { + if scopeType == store.ChannelScopeTypeGroup || scopeType == "topic" { + return "group" + } + return "direct" +} diff --git a/internal/http/channel_instance_contexts_test.go b/internal/http/channel_instance_contexts_test.go new file mode 100644 index 00000000..1d5a91ac --- /dev/null +++ b/internal/http/channel_instance_contexts_test.go @@ -0,0 +1,189 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeChannelContextContactStore struct { + contacts []store.ChannelContact + seenOpts []store.ContactListOpts +} + +func (s *fakeChannelContextContactStore) UpsertContact(context.Context, string, string, string, string, string, string, string, string, string, string) error { + return nil +} + +func (s *fakeChannelContextContactStore) ListContacts(_ context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) { + s.seenOpts = append(s.seenOpts, opts) + var out []store.ChannelContact + for _, c := range s.contacts { + if opts.ChannelType != "" && c.ChannelType != opts.ChannelType { + continue + } + if opts.ChannelInstance != "" && (c.ChannelInstance == nil || *c.ChannelInstance != opts.ChannelInstance) { + continue + } + if opts.ContactType != "" && c.ContactType != opts.ContactType { + continue + } + if opts.PeerKind != "" && (c.PeerKind == nil || *c.PeerKind != opts.PeerKind) { + continue + } + out = append(out, c) + } + return out, nil +} + +func (s *fakeChannelContextContactStore) CountContacts(context.Context, store.ContactListOpts) (int, error) { + return len(s.contacts), nil +} + +func (s *fakeChannelContextContactStore) GetContactsBySenderIDs(context.Context, []string) (map[string]store.ChannelContact, error) { + return map[string]store.ChannelContact{}, nil +} + +func (s *fakeChannelContextContactStore) GetContactByID(context.Context, uuid.UUID) (*store.ChannelContact, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) GetSenderIDsByContactIDs(context.Context, []uuid.UUID) ([]string, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) MergeContacts(context.Context, []uuid.UUID, uuid.UUID) error { + return nil +} + +func (s *fakeChannelContextContactStore) UnmergeContacts(context.Context, []uuid.UUID) error { + return nil +} + +func (s *fakeChannelContextContactStore) GetContactsByMergedID(context.Context, uuid.UUID) ([]store.ChannelContact, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) ResolveTenantUserID(context.Context, string, string) (string, error) { + return "", nil +} + +func TestChannelContextsListUsesStoredGroupsAndMasksToContextShape(t *testing.T) { + token := "channel-contexts-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + channelName := "discord-admin" + displayName := "Ops Discord" + peerKind := "group" + lastSeen := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) + contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{ + ID: uuid.New(), + ChannelType: "discord", + ChannelInstance: &channelName, + SenderID: "guild-123/channel-456", + DisplayName: &displayName, + PeerKind: &peerKind, + ContactType: "group", + LastSeenAt: lastSeen, + }}} + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, DisplayName: "Discord Admin", ChannelType: "discord", AgentID: uuid.New()}}, + nil, nil, contacts, nil, nil, + ) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Contexts []channelContextDTO `json:"contexts"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Contexts) != 2 { + t.Fatalf("contexts len = %d, want 2: %+v", len(body.Contexts), body.Contexts) + } + if body.Contexts[0].ScopeType != store.ChannelScopeTypeChannel || body.Contexts[0].ScopeKey != channelName { + t.Fatalf("base context = %+v", body.Contexts[0]) + } + gotGroup := body.Contexts[1] + if gotGroup.ScopeType != store.ChannelScopeTypeGroup || gotGroup.ScopeKey != "guild-123/channel-456" || gotGroup.Source != "stored_contact" { + t.Fatalf("group context = %+v", gotGroup) + } + if len(contacts.seenOpts) == 0 || contacts.seenOpts[0].ChannelInstance != channelName { + t.Fatalf("expected channel_instance filter %q, got %+v", channelName, contacts.seenOpts) + } +} + +func TestChannelContextMembersDoNotShowChannelWideContactsAsGroupMembers(t *testing.T) { + token := "channel-context-members-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + channelName := "telegram-main" + peerKind := "group" + displayName := "Duy" + username := "duy" + userID := "tenant-user-1" + contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{ + ID: uuid.New(), + ChannelType: "telegram", + ChannelInstance: &channelName, + SenderID: "386246614", + UserID: &userID, + DisplayName: &displayName, + Username: &username, + PeerKind: &peerKind, + ContactType: "user", + LastSeenAt: time.Date(2026, 5, 29, 10, 5, 0, 0, time.UTC), + }}} + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "telegram", AgentID: uuid.New()}}, + nil, nil, contacts, nil, nil, + ) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/group/group:telegram:-100123/members", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Members []channelContextMemberDTO `json:"members"` + LiveMembersSupported bool `json:"live_members_supported"` + Source string `json:"source"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.LiveMembersSupported || body.Source != "stored_contacts" { + t.Fatalf("expected unsupported stored contact response, got %+v", body) + } + if len(body.Members) != 0 { + t.Fatalf("members = %+v", body.Members) + } +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 4ab471bf..1f63429f 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -39,6 +39,10 @@ type ChannelInstancesHandler struct { msgBus *bus.MessageBus memberResolver channels.MemberResolver // optional — enriches file_writer metadata on addwriter channelMgr *channels.Manager // optional — enables ChannelDestroyer hook on delete + mcpStore store.MCPServerStore + secureCLIStore store.SecureCLIStore + mcpContextStore store.MCPContextAdminStore + cliContextStore store.SecureCLIContextAdminStore // orphanCleaners is keyed by channel_type; called when channelMgr.GetChannel // returns false. Keeps handler agnostic of per-channel packages. orphanCleaners map[string]OrphanChannelCleaner @@ -64,6 +68,19 @@ func (h *ChannelInstancesHandler) SetChannelManager(mgr *channels.Manager) { h.channelMgr = mgr } +// SetCapabilityStores wires MCP and Secure CLI stores for channel-context +// capability visibility. Kept as a setter to preserve startup ordering. +func (h *ChannelInstancesHandler) SetCapabilityStores(mcpStore store.MCPServerStore, secureCLIStore store.SecureCLIStore) { + h.mcpStore = mcpStore + h.secureCLIStore = secureCLIStore + if contextStore, ok := mcpStore.(store.MCPContextAdminStore); ok { + h.mcpContextStore = contextStore + } + if contextStore, ok := secureCLIStore.(store.SecureCLIContextAdminStore); ok { + h.cliContextStore = contextStore + } +} + // RegisterOrphanCleaner registers a per-channel-type cleanup function that // fires during handleDelete when the channel is no longer loaded in the // Manager (typically because admin disabled it). Without this, deleting a @@ -109,6 +126,24 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.adminAuth(h.handleAddWriter)) mux.HandleFunc("DELETE /v1/channels/instances/{id}/writers/{userId}", h.adminAuth(h.handleRemoveWriter)) } + + if h.contactStore != nil { + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts", h.auth(h.handleListContexts)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members", h.auth(h.handleListContextMembers)) + } + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities", h.auth(h.handleListContextCapabilities)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants", h.adminAuth(h.handleListContextMCPGrants)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleUpsertContextMCPGrant)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleDeleteContextMCPGrant)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials", h.adminAuth(h.handleListContextMCPCredentials)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleSetContextMCPCredentials)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleDeleteContextMCPCredentials)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants", h.adminAuth(h.handleListContextCLIGrants)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleUpsertContextCLIGrant)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleDeleteContextCLIGrant)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials", h.adminAuth(h.handleListContextCLICredentials)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleSetContextCLICredentials)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleDeleteContextCLICredentials)) } func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -631,6 +666,9 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h if v := r.URL.Query().Get("channel_type"); v != "" { opts.ChannelType = v } + if v := r.URL.Query().Get("channel_instance"); v != "" { + opts.ChannelInstance = v + } if v := r.URL.Query().Get("peer_kind"); v != "" { opts.PeerKind = v } diff --git a/internal/mcp/grant_checker.go b/internal/mcp/grant_checker.go index a144f463..a032b818 100644 --- a/internal/mcp/grant_checker.go +++ b/internal/mcp/grant_checker.go @@ -26,8 +26,11 @@ type GrantChecker interface { // cacheKey identifies a unique agent+user combination for caching. type cacheKey struct { - agentID uuid.UUID - userID string + agentID uuid.UUID + userID string + channelInstanceName string + scopeType string + scopeKey string } // cacheEntry holds the resolved access info for an agent+user. @@ -75,6 +78,11 @@ func (gc *storeGrantChecker) IsAllowed(ctx context.Context, agentID uuid.UUID, u } key := cacheKey{agentID: agentID, userID: userID} + if scope, ok := store.ChannelContextScopeFromContext(ctx); ok { + key.channelInstanceName = scope.ChannelInstanceName + key.scopeType = scope.ScopeType + key.scopeKey = scope.ScopeKey + } // Try cache first if cached, ok := gc.cache.Load(key); ok { diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 331a8d44..84abce6d 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -62,7 +62,7 @@ type connParams struct { type serverState struct { name string transport string - client *mcpclient.Client // direct ref for health checks (single-goroutine access) + client *mcpclient.Client // direct ref for health checks (single-goroutine access) clientPtr atomic.Pointer[mcpclient.Client] // shared atomic ref for BridgeTools (multi-goroutine safe) connected atomic.Bool toolNames []string // registered tool names in the registry @@ -70,10 +70,10 @@ type serverState struct { cancel context.CancelFunc conn connParams // connection params for reconnect - mu sync.Mutex - reconnAttempts int - healthFailures int // consecutive ping failures (resets on success) - lastErr string + mu sync.Mutex + reconnAttempts int + healthFailures int // consecutive ping failures (resets on success) + lastErr string // reconnPending is set when a BridgeTool detects the server reset its // session lifecycle (FastMCP-style "tools/call invalid during session @@ -111,13 +111,13 @@ type Manager struct { // Shared connection pool (nil = config-only mode) pool *Pool - poolServers map[string]struct{} // server names acquired from pool (for cleanup) - poolToolNames map[string][]string // per-agent tool names for pool-backed servers + poolServers map[string]struct{} // server names acquired from pool (for cleanup) + poolToolNames map[string][]string // per-agent tool names for pool-backed servers poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release // Search mode: deferred tools not registered in registry deferredTools map[string]*BridgeTool // registeredName → BridgeTool - activatedTools map[string]struct{} // tracks activated tool names for group:mcp + activatedTools map[string]struct{} // tracks activated tool names for group:mcp searchMode bool // User-credential servers: servers requiring per-user credentials, stored during @@ -224,13 +224,23 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc return nil } - // Skip server if it requires per-user credentials and user has none + var contextCreds *store.MCPContextCredentials + if contextStore, ok := m.store.(store.MCPContextAdminStore); ok { + for _, scope := range store.ChannelContextScopeChainFromContext(ctx) { + if creds, _ := contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID); creds != nil { + contextCreds = creds + } + } + } + + // Skip server if it requires scoped/user credentials and none are present. if requireUserCreds(srv.Settings) { if userID == "" { return nil } uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID) - if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) { + hasContextCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0) + if !hasContextCreds && (uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0)) { slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID) return nil } @@ -252,6 +262,27 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc headers["Authorization"] = "Bearer " + srv.APIKey } + if contextCreds != nil { + if contextCreds.APIKey != "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Authorization"] = "Bearer " + contextCreds.APIKey + } + for k, v := range contextCreds.Headers { + if headers == nil { + headers = make(map[string]string) + } + headers[k] = v + } + for k, v := range contextCreds.Env { + if env == nil { + env = make(map[string]string) + } + env[k] = v + } + } + // Merge per-user credentials (user overrides server defaults) if userID != "" && m.store != nil { if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil { @@ -278,12 +309,10 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc // Per-user credentials change connection params → can't share pool connection. // Fall back to per-agent mode when user has custom credentials. - hasUserCreds := userID != "" && m.store != nil - if hasUserCreds { + hasUserCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0) + if userID != "" && m.store != nil { if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) { hasUserCreds = true - } else { - hasUserCreds = false } } diff --git a/internal/store/channel_context_store.go b/internal/store/channel_context_store.go new file mode 100644 index 00000000..7737b2b1 --- /dev/null +++ b/internal/store/channel_context_store.go @@ -0,0 +1,169 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +const ( + ChannelScopeTypeChannel = "channel" + ChannelScopeTypeGroup = "group" + ChannelScopeTypeUser = "user" + ChannelScopeTypeRole = "role" +) + +// ChannelContextScope identifies the channel-level context used for scoped +// grants and credentials. ChannelInstanceName lets runtime code resolve scope +// without a channel_instances lookup at the call site. +type ChannelContextScope struct { + ChannelInstanceID uuid.UUID `json:"channel_instance_id,omitempty"` + ChannelInstanceName string `json:"channel_instance_name,omitempty"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` +} + +func (s ChannelContextScope) Valid() bool { + if s.ScopeType == "" { + return false + } + if s.ChannelInstanceID == uuid.Nil && s.ChannelInstanceName == "" { + return false + } + return s.ScopeType == ChannelScopeTypeChannel || s.ScopeKey != "" +} + +func WithChannelContextScope(ctx context.Context, scope ChannelContextScope) context.Context { + return context.WithValue(ctx, ChannelContextScopeKey, scope) +} + +func ChannelContextScopeFromContext(ctx context.Context) (ChannelContextScope, bool) { + if v, ok := ctx.Value(ChannelContextScopeKey).(ChannelContextScope); ok && v.Valid() { + return v, true + } + if rc := RunContextFromCtx(ctx); rc != nil && rc.ChannelContextScope.Valid() { + return rc.ChannelContextScope, true + } + return ChannelContextScope{}, false +} + +// ChannelContextScopeChainFromContext returns context scopes ordered from +// broadest to most specific. Applying the chain in order gives channel +// defaults first, then group/user/role overrides. +func ChannelContextScopeChainFromContext(ctx context.Context) []ChannelContextScope { + scope, ok := ChannelContextScopeFromContext(ctx) + if !ok { + return nil + } + if scope.ScopeType == ChannelScopeTypeChannel { + return []ChannelContextScope{scope} + } + channelKey := scope.ChannelInstanceName + if channelKey == "" && scope.ScopeType == ChannelScopeTypeChannel { + channelKey = scope.ScopeKey + } + if channelKey == "" { + return []ChannelContextScope{scope} + } + channelScope := ChannelContextScope{ + ChannelInstanceID: scope.ChannelInstanceID, + ChannelInstanceName: scope.ChannelInstanceName, + ScopeType: ChannelScopeTypeChannel, + ScopeKey: channelKey, + } + return []ChannelContextScope{channelScope, scope} +} + +type MCPContextGrant struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + ServerID uuid.UUID `json:"server_id" db:"server_id"` + Enabled bool `json:"enabled" db:"enabled"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty" db:"tool_allow"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty" db:"tool_deny"` + ConfigOverrides json.RawMessage `json:"config_overrides,omitempty" db:"config_overrides"` + GrantedBy string `json:"granted_by" db:"granted_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type MCPContextCredentials struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + ServerID uuid.UUID `json:"server_id" db:"server_id"` + APIKey string `json:"api_key,omitempty" db:"-"` + Headers map[string]string `json:"headers,omitempty" db:"-"` + Env map[string]string `json:"env,omitempty" db:"-"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type SecureCLIContextGrant struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + BinaryID uuid.UUID `json:"binary_id" db:"binary_id"` + Enabled bool `json:"enabled" db:"enabled"` + DenyArgs *json.RawMessage `json:"deny_args,omitempty" db:"deny_args"` + DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty" db:"deny_verbose"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty" db:"timeout_seconds"` + Tips *string `json:"tips,omitempty" db:"tips"` + EncryptedEnv []byte `json:"-" db:"encrypted_env"` + EnvKeys []string `json:"env_keys,omitempty" db:"-"` + Env map[string]SecureCLIEnvResponseEntry `json:"env,omitempty" db:"-"` + EnvSet bool `json:"env_set" db:"-"` + GrantedBy string `json:"granted_by" db:"granted_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type SecureCLIContextCredentials struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + BinaryID uuid.UUID `json:"binary_id" db:"binary_id"` + EncryptedEnv []byte `json:"-" db:"encrypted_env"` + Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"` + CredentialType *string `json:"credential_type,omitempty" db:"credential_type"` + HostScope *string `json:"host_scope,omitempty" db:"host_scope"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type MCPContextAdminStore interface { + UpsertContextGrant(ctx context.Context, grant *MCPContextGrant) error + DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error + ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextGrant, error) + ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextGrant, error) + + SetContextCredentials(ctx context.Context, creds *MCPContextCredentials) error + GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*MCPContextCredentials, error) + GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, serverID uuid.UUID) (*MCPContextCredentials, error) + DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error + ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextCredentials, error) + ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextCredentials, error) +} + +type SecureCLIContextAdminStore interface { + UpsertContextGrant(ctx context.Context, grant *SecureCLIContextGrant) error + DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error + ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextGrant, error) + ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextGrant, error) + + SetContextCredentials(ctx context.Context, creds *SecureCLIContextCredentials) error + GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*SecureCLIContextCredentials, error) + GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, binaryID uuid.UUID) (*SecureCLIContextCredentials, error) + DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error + ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextCredentials, error) + ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextCredentials, error) +} diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go index 62b6ff5b..584d3d13 100644 --- a/internal/store/contact_store.go +++ b/internal/store/contact_store.go @@ -29,12 +29,13 @@ type ChannelContact struct { // ContactListOpts holds pagination and filter options for listing contacts. type ContactListOpts struct { - Search string // ILIKE on display_name, username, sender_id - ChannelType string // filter by platform (telegram, discord, etc.) - PeerKind string // "direct" or "group" - ContactType string // "user" or "group" - Limit int - Offset int + Search string // ILIKE on display_name, username, sender_id + ChannelType string // filter by platform (telegram, discord, etc.) + ChannelInstance string // filter by channel instance name + PeerKind string // "direct" or "group" + ContactType string // "user" or "group" + Limit int + Offset int } // ContactStore manages channel contacts (auto-collected user info). diff --git a/internal/store/context.go b/internal/store/context.go index 4b0f3a78..50111216 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -50,6 +50,8 @@ const ( CredentialUserIDKey contextKey = "goclaw_credential_user_id" // SenderNameKey is the display name from channel metadata (for bootstrap auto-contact). SenderNameKey contextKey = "goclaw_sender_name" + // ChannelContextScopeKey carries the channel/group/user scope for runtime grants and credentials. + ChannelContextScopeKey contextKey = "goclaw_channel_context_scope" // AgentAudioKey carries the immutable agent audio snapshot for TTS tool dispatch. AgentAudioKey contextKey = "goclaw_agent_audio" ) diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go index 2563b090..92788091 100644 --- a/internal/store/pg/channel_contacts.go +++ b/internal/store/pg/channel_contacts.go @@ -11,7 +11,6 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) - // PGContactStore implements store.ContactStore backed by Postgres. type PGContactStore struct { db *sql.DB @@ -64,6 +63,11 @@ func contactWhereClause(ctx context.Context, opts store.ContactListOpts) (string args = append(args, opts.ChannelType) argIdx++ } + if opts.ChannelInstance != "" { + conditions = append(conditions, fmt.Sprintf("channel_instance = $%d", argIdx)) + args = append(args, opts.ChannelInstance) + argIdx++ + } if opts.PeerKind != "" { conditions = append(conditions, fmt.Sprintf("peer_kind = $%d", argIdx)) args = append(args, opts.PeerKind) diff --git a/internal/store/pg/channel_context_capabilities.go b/internal/store/pg/channel_context_capabilities.go new file mode 100644 index 00000000..91fb3be4 --- /dev/null +++ b/internal/store/pg/channel_context_capabilities.go @@ -0,0 +1,516 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) { + if scope.ChannelInstanceID != uuid.Nil { + return scope.ChannelInstanceID, nil + } + if scope.ChannelInstanceName == "" { + return uuid.Nil, sql.ErrNoRows + } + var id uuid.UUID + err := db.QueryRowContext(ctx, + `SELECT id FROM channel_instances WHERE tenant_id = $1 AND name = $2`, + tenantIDForInsert(ctx), scope.ChannelInstanceName, + ).Scan(&id) + return id, err +} + +func validateChannelScope(scopeType, scopeKey string) error { + switch scopeType { + case store.ChannelScopeTypeChannel: + return nil + case store.ChannelScopeTypeGroup, store.ChannelScopeTypeUser, store.ChannelScopeTypeRole: + if scopeKey == "" { + return fmt.Errorf("scope_key required for %s scope", scopeType) + } + return nil + default: + return fmt.Errorf("invalid scope_type: %s", scopeType) + } +} + +func rawPtrOrNull(raw *json.RawMessage) any { + if raw == nil || len(*raw) == 0 { + return nil + } + return *raw +} + +func jsonOrEmptyObject(raw json.RawMessage) any { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + return raw +} + +func (s *PGMCPServerStore) UpsertContextGrant(ctx context.Context, g *store.MCPContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + now := time.Now() + g.CreatedAt = now + g.UpdatedAt = now + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, enabled, + tool_allow, tool_deny, config_overrides, granted_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_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, + updated_at = EXCLUDED.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.ServerID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), + g.GrantedBy, now, now, + ) + return err +} + +func (s *PGMCPServerStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *PGMCPServerStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, enabled, + COALESCE(tool_allow, 'null'::jsonb), COALESCE(tool_deny, 'null'::jsonb), + COALESCE(config_overrides, 'null'::jsonb), granted_by, created_at, updated_at + FROM mcp_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextGrant, 0) + for rows.Next() { + var g store.MCPContextGrant + if err := rows.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.ServerID, &g.Enabled, + &g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &g.CreatedAt, &g.UpdatedAt); err != nil { + return nil, err + } + result = append(result, g) + } + return result, rows.Err() +} + +func (s *PGMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +func (s *PGMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var apiKey sql.NullString + if creds.APIKey != "" && s.encKey != "" { + enc, err := crypto.Encrypt(creds.APIKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt mcp context api_key: %w", err) + } + apiKey = sql.NullString{String: enc, Valid: true} + } else if creds.APIKey != "" { + apiKey = sql.NullString{String: creds.APIKey, Valid: true} + } + var headersEnc, envEnc []byte + if len(creds.Headers) > 0 { + raw, _ := json.Marshal(creds.Headers) + headersEnc = s.encryptJSONB(raw) + } + if len(creds.Env) > 0 { + raw, _ := json.Marshal(creds.Env) + envEnc = s.encryptJSONB(raw) + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, + api_key, headers, env, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id) + DO UPDATE SET api_key = EXCLUDED.api_key, + headers = EXCLUDED.headers, + env = EXCLUDED.env, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.ServerID, + apiKey, headersEnc, envEnc, creds.CreatedBy, now, now, + ) + return err +} + +func (s *PGMCPServerStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + creds, err := s.scanContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *PGMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID) +} + +func (s *PGMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *PGMCPServerStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextCredentials, 0) + for rows.Next() { + creds, err := s.scanContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +func (s *PGMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +type contextCredentialScanner interface { + Scan(dest ...any) error +} + +func (s *PGMCPServerStore) scanContextCredentials(row contextCredentialScanner) (*store.MCPContextCredentials, error) { + var creds store.MCPContextCredentials + var apiKey sql.NullString + var headersEnc, envEnc []byte + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.ServerID, + &apiKey, &headersEnc, &envEnc, &creds.CreatedBy, &creds.CreatedAt, &creds.UpdatedAt); err != nil { + return nil, err + } + if apiKey.Valid && apiKey.String != "" && s.encKey != "" { + if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil { + creds.APIKey = dec + } + } else if apiKey.Valid { + creds.APIKey = apiKey.String + } + if len(headersEnc) > 0 { + _ = json.Unmarshal(s.decryptJSONB(headersEnc), &creds.Headers) + } + if len(envEnc) > 0 { + _ = json.Unmarshal(s.decryptJSONB(envEnc), &creds.Env) + } + return &creds, nil +} + +func (s *PGSecureCLIStore) UpsertContextGrant(ctx context.Context, g *store.SecureCLIContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + var envBytes []byte + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(g.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context grant env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = g.EncryptedEnv + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, enabled, + granted_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET deny_args = EXCLUDED.deny_args, + deny_verbose = EXCLUDED.deny_verbose, + timeout_seconds = EXCLUDED.timeout_seconds, + tips = EXCLUDED.tips, + encrypted_env = EXCLUDED.encrypted_env, + enabled = EXCLUDED.enabled, + granted_by = EXCLUDED.granted_by, + updated_at = EXCLUDED.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.BinaryID, + rawPtrOrNull(g.DenyArgs), rawPtrOrNull(g.DenyVerbose), g.TimeoutSeconds, g.Tips, envBytes, g.Enabled, + g.GrantedBy, now, now, + ) + return err +} + +func (s *PGSecureCLIStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *PGSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, + enabled, granted_by, created_at, updated_at + FROM secure_cli_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextGrant, 0) + for rows.Next() { + g, err := s.scanSecureCLIContextGrant(rows) + if err != nil { + return nil, err + } + result = append(result, *g) + } + return result, rows.Err() +} + +func (s *PGSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +func (s *PGSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { + var g store.SecureCLIContextGrant + var denyArgs, denyVerbose *[]byte + if err := row.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.BinaryID, + &denyArgs, &denyVerbose, &g.TimeoutSeconds, &g.Tips, &g.EncryptedEnv, &g.Enabled, + &g.GrantedBy, &g.CreatedAt, &g.UpdatedAt); err != nil { + return nil, err + } + if denyArgs != nil { + raw := json.RawMessage(*denyArgs) + g.DenyArgs = &raw + } + if denyVerbose != nil { + raw := json.RawMessage(*denyVerbose) + g.DenyVerbose = &raw + } + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(g.EncryptedEnv), s.encKey); err == nil { + g.EncryptedEnv = []byte(decrypted) + } + } + g.EnvSet = len(g.EncryptedEnv) > 0 + return &g, nil +} + +func (s *PGSecureCLIStore) SetContextCredentials(ctx context.Context, creds *store.SecureCLIContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var envBytes []byte + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(creds.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context credential env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = creds.EncryptedEnv + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + encrypted_env, metadata, credential_type, host_scope, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET encrypted_env = EXCLUDED.encrypted_env, + metadata = EXCLUDED.metadata, + credential_type = EXCLUDED.credential_type, + host_scope = EXCLUDED.host_scope, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.BinaryID, + envBytes, jsonOrEmptyObject(creds.Metadata), creds.CredentialType, creds.HostScope, creds.CreatedBy, now, now, + ) + return err +} + +func (s *PGSecureCLIStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'::jsonb), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + creds, err := s.scanSecureCLIContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *PGSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID) +} + +func (s *PGSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *PGSecureCLIStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'::jsonb), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextCredentials, 0) + for rows.Next() { + creds, err := s.scanSecureCLIContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +func (s *PGSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +func (s *PGSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { + var creds store.SecureCLIContextCredentials + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID, + &creds.EncryptedEnv, &creds.Metadata, &creds.CredentialType, &creds.HostScope, + &creds.CreatedBy, &creds.CreatedAt, &creds.UpdatedAt); err != nil { + return nil, err + } + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(creds.EncryptedEnv), s.encKey); err == nil { + creds.EncryptedEnv = []byte(decrypted) + } + } + return &creds, nil +} diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index 9fd3f93e..bea07579 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -173,7 +173,11 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) } tClause, tArgs, _, err = scopeClauseAlias(ctx, 3, "ms") if err != nil { @@ -192,7 +196,68 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) +} + +func (s *PGMCPServerStore) 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 diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index b0b7eacf..02cb90fe 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -410,7 +410,11 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string query += ` LIMIT 1` row := s.db.QueryRowContext(ctx, query, args...) - return s.scanRowWithGrantAndUserEnv(row) + b, err := s.scanRowWithGrantAndUserEnv(row) + if err != nil || b == nil { + return b, err + } + return s.applyContextSecureCLI(ctx, b) } // scanRowWithGrantAndUserEnv scans a row that includes grant override columns and user_env. @@ -498,6 +502,60 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu return &b, nil } +func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 || b == nil { + return b, nil + } + hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + disabledByContext := false + for _, scope := range scopes { + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + disabledByContext = true + break + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + disabledByContext = false + break + } + } + if disabledByContext { + return nil, nil + } + for _, scope := range scopes { + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds == nil || len(creds.EncryptedEnv) == 0 { + continue + } + if !hasUserCredential { + b.UserEnv = creds.EncryptedEnv + b.UserCredentialType = creds.CredentialType + b.UserHostScope = creds.HostScope + } else { + b.EncryptedEnv = creds.EncryptedEnv + } + } + return b, nil +} + func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = true` var qArgs []any diff --git a/internal/store/run_context.go b/internal/store/run_context.go index 2e05a356..2c825571 100644 --- a/internal/store/run_context.go +++ b/internal/store/run_context.go @@ -40,6 +40,7 @@ type RunContext struct { // Tool configuration BuiltinToolSettings map[string][]byte ChannelType string + ChannelContextScope ChannelContextScope SubagentsCfg *config.SubagentsConfig ParentModel string ParentProvider string diff --git a/internal/store/sqlitestore/channel-context-capabilities.go b/internal/store/sqlitestore/channel-context-capabilities.go new file mode 100644 index 00000000..84efefb8 --- /dev/null +++ b/internal/store/sqlitestore/channel-context-capabilities.go @@ -0,0 +1,528 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) { + if scope.ChannelInstanceID != uuid.Nil { + return scope.ChannelInstanceID, nil + } + if scope.ChannelInstanceName == "" { + return uuid.Nil, sql.ErrNoRows + } + var id uuid.UUID + err := db.QueryRowContext(ctx, + `SELECT id FROM channel_instances WHERE tenant_id = ? AND name = ?`, + tenantIDForInsert(ctx), scope.ChannelInstanceName, + ).Scan(&id) + return id, err +} + +func validateChannelScope(scopeType, scopeKey string) error { + switch scopeType { + case store.ChannelScopeTypeChannel: + return nil + case store.ChannelScopeTypeGroup, store.ChannelScopeTypeUser, store.ChannelScopeTypeRole: + if scopeKey == "" { + return fmt.Errorf("scope_key required for %s scope", scopeType) + } + return nil + default: + return fmt.Errorf("invalid scope_type: %s", scopeType) + } +} + +func rawPtrOrNull(raw *json.RawMessage) any { + if raw == nil || len(*raw) == 0 { + return nil + } + return string(*raw) +} + +func jsonOrEmptyObject(raw json.RawMessage) any { + if len(raw) == 0 { + return "{}" + } + return string(raw) +} + +func (s *SQLiteMCPServerStore) UpsertContextGrant(ctx context.Context, g *store.MCPContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, enabled, + tool_allow, tool_deny, config_overrides, granted_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_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, + updated_at = excluded.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.ServerID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), + g.GrantedBy, now, now, + ) + return err +} + +func (s *SQLiteMCPServerStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *SQLiteMCPServerStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, enabled, + COALESCE(tool_allow, 'null'), COALESCE(tool_deny, 'null'), + COALESCE(config_overrides, 'null'), granted_by, created_at, updated_at + FROM mcp_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextGrant, 0) + for rows.Next() { + var g store.MCPContextGrant + var createdAt, updatedAt sqliteTime + if err := rows.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.ServerID, &g.Enabled, + &g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + g.CreatedAt = createdAt.Time + g.UpdatedAt = updatedAt.Time + result = append(result, g) + } + return result, rows.Err() +} + +func (s *SQLiteMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +func (s *SQLiteMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var apiKey sql.NullString + if creds.APIKey != "" && s.encKey != "" { + enc, err := crypto.Encrypt(creds.APIKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt mcp context api_key: %w", err) + } + apiKey = sql.NullString{String: enc, Valid: true} + } else if creds.APIKey != "" { + apiKey = sql.NullString{String: creds.APIKey, Valid: true} + } + var headersEnc, envEnc []byte + if len(creds.Headers) > 0 { + raw, _ := json.Marshal(creds.Headers) + headersEnc = s.encryptJSON(raw) + } + if len(creds.Env) > 0 { + raw, _ := json.Marshal(creds.Env) + envEnc = s.encryptJSON(raw) + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, + api_key, headers, env, created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id) + DO UPDATE SET api_key = excluded.api_key, + headers = excluded.headers, + env = excluded.env, + created_by = excluded.created_by, + updated_at = excluded.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.ServerID, + apiKey, headersEnc, envEnc, creds.CreatedBy, now, now, + ) + return err +} + +func (s *SQLiteMCPServerStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + creds, err := s.scanContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *SQLiteMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID) +} + +func (s *SQLiteMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *SQLiteMCPServerStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextCredentials, 0) + for rows.Next() { + creds, err := s.scanContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +func (s *SQLiteMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +type contextCredentialScanner interface { + Scan(dest ...any) error +} + +func (s *SQLiteMCPServerStore) scanContextCredentials(row contextCredentialScanner) (*store.MCPContextCredentials, error) { + var creds store.MCPContextCredentials + var apiKey sql.NullString + var headersEnc, envEnc []byte + var createdAt, updatedAt sqliteTime + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.ServerID, + &apiKey, &headersEnc, &envEnc, &creds.CreatedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + creds.CreatedAt = createdAt.Time + creds.UpdatedAt = updatedAt.Time + if apiKey.Valid && apiKey.String != "" && s.encKey != "" { + if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil { + creds.APIKey = dec + } + } else if apiKey.Valid { + creds.APIKey = apiKey.String + } + if len(headersEnc) > 0 { + _ = json.Unmarshal(s.decryptJSON(headersEnc), &creds.Headers) + } + if len(envEnc) > 0 { + _ = json.Unmarshal(s.decryptJSON(envEnc), &creds.Env) + } + return &creds, nil +} + +func (s *SQLiteSecureCLIStore) UpsertContextGrant(ctx context.Context, g *store.SecureCLIContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + var envBytes []byte + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(g.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context grant env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = g.EncryptedEnv + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, enabled, + granted_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET deny_args = excluded.deny_args, + deny_verbose = excluded.deny_verbose, + timeout_seconds = excluded.timeout_seconds, + tips = excluded.tips, + encrypted_env = excluded.encrypted_env, + enabled = excluded.enabled, + granted_by = excluded.granted_by, + updated_at = excluded.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.BinaryID, + rawPtrOrNull(g.DenyArgs), rawPtrOrNull(g.DenyVerbose), g.TimeoutSeconds, g.Tips, envBytes, g.Enabled, + g.GrantedBy, now, now, + ) + return err +} + +func (s *SQLiteSecureCLIStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *SQLiteSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, + enabled, granted_by, created_at, updated_at + FROM secure_cli_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextGrant, 0) + for rows.Next() { + g, err := s.scanSecureCLIContextGrant(rows) + if err != nil { + return nil, err + } + result = append(result, *g) + } + return result, rows.Err() +} + +func (s *SQLiteSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +func (s *SQLiteSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { + var g store.SecureCLIContextGrant + var denyArgs, denyVerbose []byte + var createdAt, updatedAt sqliteTime + if err := row.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.BinaryID, + &denyArgs, &denyVerbose, &g.TimeoutSeconds, &g.Tips, &g.EncryptedEnv, &g.Enabled, + &g.GrantedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + g.CreatedAt = createdAt.Time + g.UpdatedAt = updatedAt.Time + if len(denyArgs) > 0 { + raw := json.RawMessage(denyArgs) + g.DenyArgs = &raw + } + if len(denyVerbose) > 0 { + raw := json.RawMessage(denyVerbose) + g.DenyVerbose = &raw + } + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(g.EncryptedEnv), s.encKey); err == nil { + g.EncryptedEnv = []byte(decrypted) + } + } + g.EnvSet = len(g.EncryptedEnv) > 0 + return &g, nil +} + +func (s *SQLiteSecureCLIStore) SetContextCredentials(ctx context.Context, creds *store.SecureCLIContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var envBytes []byte + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(creds.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context credential env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = creds.EncryptedEnv + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + encrypted_env, metadata, credential_type, host_scope, created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET encrypted_env = excluded.encrypted_env, + metadata = excluded.metadata, + credential_type = excluded.credential_type, + host_scope = excluded.host_scope, + created_by = excluded.created_by, + updated_at = excluded.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.BinaryID, + envBytes, jsonOrEmptyObject(creds.Metadata), creds.CredentialType, creds.HostScope, creds.CreatedBy, now, now, + ) + return err +} + +func (s *SQLiteSecureCLIStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + creds, err := s.scanSecureCLIContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *SQLiteSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID) +} + +func (s *SQLiteSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *SQLiteSecureCLIStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextCredentials, 0) + for rows.Next() { + creds, err := s.scanSecureCLIContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +func (s *SQLiteSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + +func (s *SQLiteSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { + var creds store.SecureCLIContextCredentials + var createdAt, updatedAt sqliteTime + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID, + &creds.EncryptedEnv, &creds.Metadata, &creds.CredentialType, &creds.HostScope, + &creds.CreatedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + creds.CreatedAt = createdAt.Time + creds.UpdatedAt = updatedAt.Time + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(creds.EncryptedEnv), s.encKey); err == nil { + creds.EncryptedEnv = []byte(decrypted) + } + } + return &creds, nil +} diff --git a/internal/store/sqlitestore/contacts.go b/internal/store/sqlitestore/contacts.go index a48aabe7..f0db1857 100644 --- a/internal/store/sqlitestore/contacts.go +++ b/internal/store/sqlitestore/contacts.go @@ -71,6 +71,10 @@ func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string conditions = append(conditions, "channel_type = ?") args = append(args, opts.ChannelType) } + if opts.ChannelInstance != "" { + conditions = append(conditions, "channel_instance = ?") + args = append(args, opts.ChannelInstance) + } if opts.PeerKind != "" { conditions = append(conditions, "peer_kind = ?") args = append(args, opts.PeerKind) diff --git a/internal/store/sqlitestore/mcp_servers_access.go b/internal/store/sqlitestore/mcp_servers_access.go index 01a169d2..5fbc52c8 100644 --- a/internal/store/sqlitestore/mcp_servers_access.go +++ b/internal/store/sqlitestore/mcp_servers_access.go @@ -211,7 +211,11 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + 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, @@ -226,7 +230,68 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + 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 diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index 8a60d4f0..b0583def 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 43 +const SchemaVersion = 44 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -803,8 +803,88 @@ CREATE INDEX IF NOT EXISTS idx_run_timeline_session_time CREATE INDEX IF NOT EXISTS idx_run_timeline_trace ON run_timeline_items (tenant_id, trace_id) WHERE trace_id IS NOT NULL;`, + // Version 43 → 44: channel-context MCP and Secure CLI grants/credentials. + 43: addChannelContextCapabilityTables, } +const addChannelContextCapabilityTables = ` +CREATE TABLE IF NOT EXISTS mcp_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 1, + tool_allow TEXT, + tool_deny TEXT, + config_overrides TEXT, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_server ON mcp_context_grants(server_id); + +CREATE TABLE IF NOT EXISTS mcp_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + api_key TEXT, + headers BLOB, + env BLOB, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_server ON mcp_context_credentials(server_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + deny_args TEXT, + deny_verbose TEXT, + timeout_seconds INTEGER, + tips TEXT, + encrypted_env BLOB, + enabled BOOLEAN NOT NULL DEFAULT 1, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + encrypted_env BLOB NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id);` + // addHooksTables is the SQLite incremental migration for schema v19 → v20. // Mirrors PG migrations 000052–000055 (consolidated — desktop never shipped // with intermediate agent_hooks / agent_hook_agents names). diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index af889780..a577d7a8 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -811,6 +811,91 @@ CREATE INDEX IF NOT EXISTS idx_channel_instances_type ON channel_instances(chann CREATE INDEX IF NOT EXISTS idx_channel_instances_agent ON channel_instances(agent_id); CREATE INDEX IF NOT EXISTS idx_channel_instances_tenant ON channel_instances(tenant_id); +-- ============================================================ +-- Tables: channel-context MCP and Secure CLI capabilities +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mcp_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 1, + tool_allow TEXT, + tool_deny TEXT, + config_overrides TEXT, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_server ON mcp_context_grants(server_id); + +CREATE TABLE IF NOT EXISTS mcp_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + api_key TEXT, + headers BLOB, + env BLOB, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_server ON mcp_context_credentials(server_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + deny_args TEXT, + deny_verbose TEXT, + timeout_seconds INTEGER, + tips TEXT, + encrypted_env BLOB, + enabled BOOLEAN NOT NULL DEFAULT 1, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + encrypted_env BLOB NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id); + -- ============================================================ -- Table: config_secrets -- PK changed to (key, tenant_id) in migration 27 Phase I diff --git a/internal/store/sqlitestore/secure-cli.go b/internal/store/sqlitestore/secure-cli.go index 49084010..a3fe20dc 100644 --- a/internal/store/sqlitestore/secure-cli.go +++ b/internal/store/sqlitestore/secure-cli.go @@ -437,7 +437,11 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st query += ` LIMIT 1` row := s.db.QueryRowContext(ctx, query, args...) - return s.scanRowWithGrantAndUserEnv(row) + b, err := s.scanRowWithGrantAndUserEnv(row) + if err != nil || b == nil { + return b, err + } + return s.applyContextSecureCLI(ctx, b) } func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) { @@ -524,6 +528,60 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. return &b, nil } +func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 || b == nil { + return b, nil + } + hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + disabledByContext := false + for _, scope := range scopes { + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + disabledByContext = true + break + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + disabledByContext = false + break + } + } + if disabledByContext { + return nil, nil + } + for _, scope := range scopes { + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds == nil || len(creds.EncryptedEnv) == 0 { + continue + } + if !hasUserCredential { + b.UserEnv = creds.EncryptedEnv + b.UserCredentialType = creds.CredentialType + b.UserHostScope = creds.HostScope + } else { + b.EncryptedEnv = creds.EncryptedEnv + } + } + return b, nil +} + // ListEnabled returns all enabled configs. func (s *SQLiteSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = 1` diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 279d5bfd..7e689893 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 74 +const RequiredSchemaVersion uint = 75 diff --git a/migrations/000075_channel_context_capabilities.down.sql b/migrations/000075_channel_context_capabilities.down.sql new file mode 100644 index 00000000..49631359 --- /dev/null +++ b/migrations/000075_channel_context_capabilities.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS secure_cli_context_credentials; +DROP TABLE IF EXISTS secure_cli_context_grants; +DROP TABLE IF EXISTS mcp_context_credentials; +DROP TABLE IF EXISTS mcp_context_grants; diff --git a/migrations/000075_channel_context_capabilities.up.sql b/migrations/000075_channel_context_capabilities.up.sql new file mode 100644 index 00000000..670b6b7d --- /dev/null +++ b/migrations/000075_channel_context_capabilities.up.sql @@ -0,0 +1,80 @@ +CREATE TABLE mcp_context_grants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT true, + tool_allow JSONB, + tool_deny JSONB, + config_overrides JSONB, + granted_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_mcp_context_grants_server ON mcp_context_grants(server_id); + +CREATE TABLE mcp_context_credentials ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + api_key TEXT, + headers BYTEA, + env BYTEA, + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_mcp_context_credentials_server ON mcp_context_credentials(server_id); + +CREATE TABLE secure_cli_context_grants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + deny_args JSONB, + deny_verbose JSONB, + timeout_seconds INTEGER, + tips TEXT, + encrypted_env BYTEA, + enabled BOOLEAN NOT NULL DEFAULT true, + granted_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id); + +CREATE TABLE secure_cli_context_credentials ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + encrypted_env BYTEA NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id); diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md b/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md new file mode 100644 index 00000000..e4ef343e --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md @@ -0,0 +1,99 @@ +--- +phase: 1 +title: "Research and Contracts" +status: complete +effort: "M" +--- + +# Phase 1: Research and Contracts + +## Context Links + +- Issue: https://github.com/digitopvn/goclaw/issues/66 +- Current page: `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx` +- Current groups tab: `ui/web/src/pages/channels/channel-detail/channel-groups-tab.tsx` +- Current credentials tab: `ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx` +- Channel routes: `internal/http/channel_instances.go` +- MCP store/routes: `internal/store/mcp_store.go`, `internal/http/mcp_grants.go`, `internal/http/mcp_user_credentials.go` +- Secure CLI store/routes: `internal/store/secure_cli_store.go`, `internal/http/secure_cli_agent_grants.go`, `internal/http/secure_cli_user_credentials.go` +- Docs: `docs/05-channels-messaging.md`, `docs/23-ai-agent-permission-matrix.md`, `docs/09-security.md` + +## Overview + +Lock the API contracts and threat model before implementation. This phase should produce failing tests or contract snapshots for the expected DTOs, plus a concise scope note that separates channel login credentials from MCP/CLI scoped credentials. + +## Key Insights + +- Existing `groups` tab is Telegram config overrides, not a general group/member view. +- Existing channel credentials are encrypted channel-login data; they must not become MCP/CLI runtime credentials. +- MCP access currently resolves server defaults + per-user credentials only. +- Secure CLI access currently resolves global binary + agent grant + user credential only. +- `agent_config_permissions` is for config/file-writer grants and should not be stretched into MCP/CLI grant storage. +- Discord currently captures guild/channel metadata but does not expose full live members/roles without extra intent work. + +## Requirements + +- Define canonical context scope model for `channel`, `group`, and `member` rows. +- Define masked credential DTOs that never include raw secret values. +- Define source labels for effective capability and credential rows: global, agent, channel, group, user. +- Define permission requirements for read vs write endpoints. +- Document resolver precedence: user > group/member-role > channel > agent > global. + +## Architecture + +- Add HTTP DTOs near existing channel/admin route files; keep store structs separate from UI response structs. +- Keep MCP and Secure CLI resolver changes behind explicit scope inputs instead of reading mutable process-global state. +- Represent channel context with stable fields: `channelInstanceID`, `channelType`, `scopeType`, `scopeKey`, `displayName`, `source`, `lastSeenAt`. +- Treat Discord role/member support as a provider capability flag until verified. + +## Related Code Files + +- Modify later: `internal/http/channel_instances.go` +- Modify later: `internal/store/channel_contact_store.go` +- Modify later: `internal/store/mcp_store.go` +- Modify later: `internal/store/secure_cli_store.go` +- Modify later: `ui/web/src/types/channel.ts` +- Modify later: `ui/web/src/pages/channels/channel-detail/*` +- Create later only if needed: focused DTO/test files with kebab-case or existing Go naming convention. + +## Implementation Steps + +1. Write failing backend tests for channel context DTO masking and tenant-scoped route authorization. +2. Write failing resolver contract tests that encode credential precedence without creating new tables yet. +3. Add a short issue-scope note in this plan directory if implementation discovers a contract gap. +4. Verify all planned endpoint names against existing route structure before coding handlers. +5. Verify current UI route/i18n conventions before adding visible strings. + +## Todo List + +- [ ] Add backend contract tests for context list shape and permission checks. +- [ ] Add resolver precedence characterization tests for MCP and Secure CLI. +- [ ] Confirm UI i18n key placement for new channel detail tabs. +- [ ] Confirm Discord provider capability and permissions needed for live member/role sync. + +## Success Criteria + +- [ ] Tests fail for the intended missing behavior before implementation. +- [ ] Contract fields and precedence rules are explicit and cited in later phase code. +- [ ] No plan step relies on raw credential reveal. +- [ ] No plan step reuses `channel_instances.credentials` for MCP/CLI secrets. + +## Risk Assessment + +- Risk: resolver scope leaks across tenants. Mitigation: tenant ID on every new table/query and route-level tenant checks. +- Risk: UI implies Discord support that backend cannot provide. Mitigation: capability flags and documented fallback. +- Risk: scope strings become ad hoc. Mitigation: typed scope model plus tests. + +## Security Considerations + +- Never serialize raw secrets. +- Audit all write operations in later phases. +- Use existing encrypted storage patterns and parameterized SQL only. + +## Next Steps + +- Proceed to Phase 2 after route/DTO tests are committed or queued in the implementation branch. + +## Unresolved Questions + +- None for planning; Discord depth remains a phase gate. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md b/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md new file mode 100644 index 00000000..045d8099 --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md @@ -0,0 +1,91 @@ +--- +phase: 2 +title: "Read-only Context Surface" +status: complete +effort: "L" +--- + +# Phase 2: Read-only Context Surface + +## Context Links + +- UI shell: `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx` +- Current hooks: `ui/web/src/pages/channels/hooks/use-channel-detail.ts` +- Channel contacts: `internal/store/channel_contact_store.go`, `internal/store/pg/channel_contacts.go` +- Live members abstraction: `internal/channels/channel.go`, `internal/channels/manager.go` +- Feishu member implementation: `internal/channels/feishu/feishu.go` +- Discord metadata source: `internal/channels/discord/handler.go` + +## Overview + +Add a read-only admin view for channel contexts and members/users. This should make the channel detail page useful before introducing new grant or credential mutations. + +## Requirements + +- Show channel contexts/groups discovered from contacts and supported provider APIs. +- Show members/users for a selected context when available. +- Show source and freshness: stored contact, live provider, config writer scope, or unsupported. +- Preserve existing Telegram override editing until it is intentionally replaced or renamed. +- Enforce tenant isolation and admin/operator read permissions. + +## Architecture + +- Add a channel context service that aggregates stored contacts plus provider-supported group/member APIs. +- Add HTTP endpoints under existing channel instance routes: + - `GET /v1/channels/instances/{id}/contexts` + - `GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members` +- Extend channel contact store filters to support channel instance and context filters. +- UI adds a general `Contexts` tab for all channel types and keeps Telegram overrides as a separate advanced section if still needed. + +## Related Code Files + +- `internal/http/channel_instances.go` +- `internal/store/channel_contact_store.go` +- `internal/store/pg/channel_contacts.go` +- `internal/store/sqlitestore/*channel*` +- `internal/channels/manager.go` +- `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx` +- `ui/web/src/pages/channels/hooks/use-channel-detail.ts` +- `ui/web/src/types/channel.ts` + +## Implementation Steps + +1. TDD: add backend tests for context listing, member listing, tenant mismatch, and unsupported provider responses. +2. Add channel-instance filtering to contact store interfaces and PG/SQLite implementations. +3. Implement read-only context/member HTTP handlers using existing auth middleware and masked DTOs. +4. Add UI query hooks and type definitions for contexts and members. +5. Add a general channel detail tab with loading, empty, error, and unsupported states. +6. Add i18n keys to all web locale files before rendering new user-facing strings. +7. Keep Telegram override editing intact and avoid changing channel config behavior in this phase. + +## Todo List + +- [ ] Backend tests fail first for `/contexts` and `/members`. +- [ ] Store filters support `channel_instance` without full scans. +- [ ] UI tests cover tab visibility, empty state, and unsupported provider state. +- [ ] Existing channel detail behavior remains unchanged for general/credentials/managers tabs. + +## Success Criteria + +- [ ] Admin can see groups/contexts and members/users where data exists. +- [ ] Unsupported live member sync is explicit in the UI instead of silently blank. +- [ ] Tenant isolation tests pass for both PG and SQLite paths where applicable. +- [ ] No mutation endpoints are added in this phase. + +## Risk Assessment + +- Risk: `channel_contacts` has incomplete rows for some providers. Mitigation: label source/freshness and use provider capability flags. +- Risk: adding filters breaks contact search. Mitigation: preserve existing default list/search behavior with regression tests. + +## Security Considerations + +- Read endpoints must scope by authenticated tenant and selected channel instance. +- Do not include provider tokens or channel credentials in responses. + +## Next Steps + +- Phase 3 can consume the selected context model to display effective MCP/CLI capability rows. + +## Unresolved Questions + +- None. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md b/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md new file mode 100644 index 00000000..8bede47d --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md @@ -0,0 +1,87 @@ +--- +phase: 3 +title: "Effective Capability Matrix" +status: complete +effort: "L" +--- + +# Phase 3: Effective Capability Matrix + +## Context Links + +- MCP grants API: `internal/http/mcp_grants.go` +- MCP store: `internal/store/mcp_store.go`, `internal/store/pg/mcp_servers.go`, `internal/store/pg/mcp_servers_access.go` +- Secure CLI grants API: `internal/http/secure_cli_agent_grants.go` +- Secure CLI store: `internal/store/secure_cli_store.go`, `internal/store/pg/secure_cli.go` +- Channel detail UI: `ui/web/src/pages/channels/channel-detail/` + +## Overview + +Show the effective MCP servers/tools and Secure CLI packages for a channel context before allowing edits. This phase is read-only and should expose why access exists, not just whether access exists. + +## Requirements + +- Display MCP servers with tool allow/deny summaries and source labels. +- Display Secure CLI packages/binaries with source labels and masked env availability. +- Show future channel/group override slots as "not configured" until Phase 4/5 tables exist. +- Keep read endpoint tenant-scoped and usable by admins/operators with appropriate channel access. + +## Architecture + +- Add an effective capability aggregation service that composes existing MCP and Secure CLI store calls. +- Add endpoint under channel instance routes: + - `GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities` +- Response groups rows by capability type: `mcp_server`, `mcp_tool`, `secure_cli`. +- Avoid resolver mutation in this phase; this is visibility over existing agent/user/global grants plus planned context placeholder metadata. + +## Related Code Files + +- `internal/http/channel_instances.go` +- `internal/http/mcp_grants.go` +- `internal/http/secure_cli_agent_grants.go` +- `internal/store/mcp_store.go` +- `internal/store/secure_cli_store.go` +- `ui/web/src/pages/channels/channel-detail/` +- `ui/web/src/types/channel.ts` + +## Implementation Steps + +1. TDD: add backend tests for effective capability response shape using existing agent/user grants. +2. TDD: add UI tests for capability matrix source labels and masked secret indicators. +3. Implement a read-only aggregator that calls existing MCP and Secure CLI stores. +4. Add handler under channel instance route group with tenant/channel authorization. +5. Add UI matrix with filters for MCP, CLI, and source. +6. Add i18n keys in all web locale files. +7. Document that edit controls remain disabled until Phase 4/5. + +## Todo List + +- [ ] Backend tests prove source labels for global, agent, and user-derived access. +- [ ] UI tests cover empty, partial, and mixed MCP/CLI rows. +- [ ] No new schema is introduced in this phase. +- [ ] No raw credential values appear in capability responses. + +## Success Criteria + +- [ ] Admin can see granted MCP servers/tools for a selected channel context. +- [ ] Admin can see granted Secure CLI packages/binaries for a selected channel context. +- [ ] Source labels are visible and consistent with resolver precedence. +- [ ] Read-only endpoint has tenant isolation tests. + +## Risk Assessment + +- Risk: read model diverges from actual runtime resolver. Mitigation: use existing store access methods where possible and add characterization tests before later resolver edits. +- Risk: UI becomes noisy. Mitigation: default grouped display and simple filters. + +## Security Considerations + +- Mask all environment and credential indicators. +- Return only metadata required to administer access. + +## Next Steps + +- Phase 4 can add mutation tables and update the matrix source labels to include channel/group grants. + +## Unresolved Questions + +- None. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md b/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md new file mode 100644 index 00000000..c93941c2 --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md @@ -0,0 +1,101 @@ +--- +phase: 4 +title: "Context-scoped Grants" +status: complete +effort: "XL" +--- + +# Phase 4: Context-scoped Grants + +## Context Links + +- MCP grant schema: `migrations/000001_init_schema.up.sql`, later MCP migrations, `internal/store/pg/mcp_servers_access.go` +- Secure CLI grant schema: `migrations/000058_secure_cli_binaries.up.sql`, `migrations/000073_secure_cli_typed_credentials.up.sql` +- SQLite schema: `internal/store/sqlitestore/schema.sql`, `internal/store/sqlitestore/schema.go` +- Upgrade version: `internal/upgrade/version.go` +- Grant APIs: `internal/http/mcp_grants.go`, `internal/http/secure_cli_agent_grants.go` + +## Overview + +Add channel/group scoped grants for MCP and Secure CLI. This is the first write phase and requires dual database migrations, resolver changes, audit logs, and UI mutation controls. + +## Requirements + +- Add dedicated context grant tables; do not overload `agent_config_permissions`. +- Scope every row by tenant, channel instance, scope type, and scope key. +- Support grant sources: channel and group/member-role. +- Preserve existing agent/user grant behavior. +- Audit create/update/delete operations. + +## Architecture + +- Proposed tables: + - `mcp_context_grants` + - `secure_cli_context_grants` +- Common columns: `id`, `tenant_id`, `channel_instance_id`, `scope_type`, `scope_key`, resource ID, allow/deny or config overrides, `granted_by`, timestamps. +- Add PG migrations and SQLite full schema + incremental migrations in the same implementation PR. +- Extend resolver input with channel context. Prefer explicit params where call sites already carry channel metadata; use context values only when explicit threading would create broad churn. +- Runtime grant precedence: user > group/member-role > channel > agent > global. + +## Related Code Files + +- `migrations/*` +- `internal/upgrade/version.go` +- `internal/store/sqlitestore/schema.sql` +- `internal/store/sqlitestore/schema.go` +- `internal/store/mcp_store.go` +- `internal/store/secure_cli_store.go` +- `internal/store/pg/mcp_servers_access.go` +- `internal/store/pg/secure_cli.go` +- `internal/http/mcp_grants.go` +- `internal/http/secure_cli_agent_grants.go` +- `ui/web/src/pages/channels/channel-detail/` + +## Implementation Steps + +1. TDD: add migration tests or store tests that fail until context grant tables exist in PG and SQLite. +2. TDD: add resolver tests proving group grant overrides channel grant, and user grant overrides both. +3. Add PG up/down migrations and bump `RequiredSchemaVersion`. +4. Add SQLite schema and incremental migration map updates with `SchemaVersion` bump. +5. Add store interfaces and PG/SQLite implementations for context grants. +6. Extend MCP and Secure CLI resolvers to include context grants without breaking existing call sites. +7. Add admin-only HTTP endpoints for create/update/delete/list context grants. +8. Add UI controls to grant/revoke MCP/CLI rows for selected channel context. +9. Emit security/audit logs on every write. +10. Run dual DB compile and focused store/resolver tests. + +## Todo List + +- [ ] Add failing PG and SQLite tests for context grant persistence. +- [ ] Add failing resolver precedence tests. +- [ ] Add dual DB migrations and version bumps. +- [ ] Add audited admin endpoints. +- [ ] Add UI mutation controls with disabled/loading/error states. + +## Success Criteria + +- [ ] Channel/group scoped grants can be created, listed, updated, and revoked. +- [ ] Effective capability matrix shows channel/group grant sources. +- [ ] Existing agent/user MCP and CLI flows keep passing. +- [ ] PG and SQLite schema versions are both updated. +- [ ] Audit logs include actor, tenant, channel instance, scope, resource, and action. + +## Risk Assessment + +- Risk: resolver signature churn touches many runtime paths. Mitigation: enumerate all callers before editing and add compile-time failures as guide. +- Risk: partial migration coverage breaks desktop. Mitigation: SQLite schema update is mandatory in the same phase. +- Risk: broad grants accidentally cross tenant. Mitigation: tenant in primary queries and route authorization tests. + +## Security Considerations + +- Admin writes must use tenant admin or master-scope checks according to table ownership. +- SQL must be parameterized and indexed on tenant/channel/scope/resource columns. +- Never log secret material from overrides. + +## Next Steps + +- Phase 5 adds scoped credential storage after grant resolver behavior is stable. + +## Unresolved Questions + +- Decide exact DB constraint names during implementation after checking existing migration naming style. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md b/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md new file mode 100644 index 00000000..b80696d9 --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md @@ -0,0 +1,101 @@ +--- +phase: 5 +title: "Context-scoped Credentials" +status: complete +effort: "XL" +--- + +# Phase 5: Context-scoped Credentials + +## Context Links + +- MCP credentials: `internal/http/mcp_user_credentials.go`, `internal/store/pg/mcp_user_credentials.go`, `internal/mcp/manager.go` +- Secure CLI credentials: `internal/http/secure_cli_user_credentials.go`, `internal/store/pg/secure_cli.go`, `internal/tools/secure_cli*` +- Encryption patterns: `internal/crypto/`, existing encrypted store columns +- Security docs: `docs/09-security.md`, `docs/20-api-keys-auth.md` + +## Overview + +Add channel/group scoped credential overrides for MCP and Secure CLI. This phase must be stricter than grants because it stores encrypted secrets and changes runtime credential resolution. + +## Requirements + +- Store MCP and CLI context credentials in dedicated encrypted tables. +- Never return raw secret values through list/detail endpoints. +- Support create/update/delete with audit logs. +- Apply precedence: user > group/member-role > channel > agent > global. +- Clearly label whether a capability is using global, agent, channel, group, or user credentials. + +## Architecture + +- Proposed tables: + - `mcp_context_credentials` + - `secure_cli_context_credentials` +- Scope columns match Phase 4 grant tables. +- MCP credential payload follows current per-user model: API key, headers, env. +- Secure CLI credential payload follows current typed credential model and encrypted env behavior. +- Runtime resolvers should merge defaults first, then progressively apply agent/channel/group/user overrides. +- UI uses masked fields, "configured" flags, and explicit reset/delete actions. + +## Related Code Files + +- `migrations/*` +- `internal/upgrade/version.go` +- `internal/store/sqlitestore/schema.sql` +- `internal/store/sqlitestore/schema.go` +- `internal/store/mcp_store.go` +- `internal/store/secure_cli_store.go` +- `internal/mcp/manager.go` +- `internal/http/mcp_user_credentials.go` +- `internal/http/secure_cli_user_credentials.go` +- `ui/web/src/pages/channels/channel-detail/` + +## Implementation Steps + +1. TDD: add credential masking tests for HTTP list/detail responses. +2. TDD: add resolver precedence tests for global, agent, channel, group, and user credentials. +3. TDD: add audit tests or log assertions for credential create/update/delete where existing test patterns allow. +4. Add PG and SQLite encrypted credential tables and version bumps. +5. Add store interfaces and implementations using existing encryption helpers. +6. Add admin endpoints for scoped MCP credentials and scoped Secure CLI credentials. +7. Update MCP manager credential resolution with context scope input. +8. Update Secure CLI lookup/credential resolution with context scope input. +9. Add UI credential forms with masked values, configured flags, and delete/reset flows. +10. Update docs to document precedence and masking behavior. + +## Todo List + +- [ ] Add failing tests proving no raw secrets are serialized. +- [ ] Add failing runtime resolver precedence tests. +- [ ] Add PG and SQLite schema changes together. +- [ ] Add audited credential write endpoints. +- [ ] Add UI forms with masked values only. +- [ ] Update security/API docs. + +## Success Criteria + +- [ ] Admin can configure channel/group scoped MCP credentials. +- [ ] Admin can configure channel/group scoped Secure CLI credentials. +- [ ] Runtime uses scoped credentials according to documented precedence. +- [ ] UI labels credential source and override state. +- [ ] All list/detail endpoints return masked metadata only. + +## Risk Assessment + +- Risk: raw secret leakage in UI/API/logs. Mitigation: masking tests and no reveal endpoint in this phase. +- Risk: agent/global config accidentally overwritten by channel edits. Mitigation: dedicated tables and explicit scope keys. +- Risk: resolver behavior differs between MCP and CLI. Mitigation: shared precedence test cases. + +## Security Considerations + +- Encrypt all stored secret payloads using existing crypto patterns. +- Audit create/update/delete, but never audit payload values. +- Rate-limit or omit reveal flows; default is no raw reveal. + +## Next Steps + +- Phase 6 verifies Discord-specific behavior and closes docs gaps. + +## Unresolved Questions + +- Whether to support one-time reveal for newly-created secrets is intentionally out of scope unless explicitly approved. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md b/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md new file mode 100644 index 00000000..f3b4a910 --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md @@ -0,0 +1,91 @@ +--- +phase: 6 +title: "Discord Enrichment and Verification" +status: complete +effort: "M" +--- + +# Phase 6: Discord Enrichment and Verification + +## Context Links + +- Discord channel: `internal/channels/discord/discord.go`, `internal/channels/discord/handler.go` +- Channel docs: `docs/05-channels-messaging.md` +- Permission matrix docs: `docs/23-ai-agent-permission-matrix.md` +- Web channel detail UI: `ui/web/src/pages/channels/channel-detail/` + +## Overview + +Validate Discord-specific group/member behavior after the generic channel context surface works. Ship either real Discord enrichment or a documented follow-up, depending on verified Discord intents and permission requirements. + +## Requirements + +- Support Discord guild/channel concepts in the channel detail page. +- If live member/role listing is available, implement it behind capability checks. +- If required intents are not available, show stored contacts and document the limitation. +- Run final backend, SQLite, and web validation. + +## Architecture + +- Reuse the Phase 2 context/member endpoints. +- Add Discord provider capability flags rather than hardcoding UI assumptions. +- Use stored contact metadata for baseline Discord user rows. +- Only add live role/member sync after verifying gateway intents, bot permissions, and API rate limits. + +## Related Code Files + +- `internal/channels/discord/discord.go` +- `internal/channels/discord/handler.go` +- `internal/channels/channel.go` +- `internal/channels/manager.go` +- `ui/web/src/pages/channels/channel-detail/` +- `docs/05-channels-messaging.md` +- `docs/23-ai-agent-permission-matrix.md` + +## Implementation Steps + +1. TDD: add Discord capability tests for stored-contact fallback and unsupported live members. +2. Verify required Discord intents and permissions against the current channel setup. +3. If safe, implement Discord `GroupMemberProvider` support for guild/channel/role views. +4. If not safe, keep fallback UI and add a follow-up issue/note with exact missing permissions. +5. Add docs for channel scoped grant and credential precedence. +6. Run focused tests: + - `go test ./internal/http ./internal/channels ./internal/store/pg` + - `go test -tags sqliteonly ./internal/store/sqlitestore` + - `go build ./...` + - `go build -tags sqliteonly ./...` + - relevant `pnpm` checks in `ui/web/` +7. Perform manual UI smoke for channel detail tabs. + +## Todo List + +- [ ] Add Discord fallback tests before behavior change. +- [ ] Verify intents/permissions before live sync. +- [ ] Implement or document Discord live member/role support. +- [ ] Update docs for final shipped behavior. +- [ ] Run backend, SQLite, and web validation. + +## Success Criteria + +- [ ] Discord channel details show guild/channel/user context without cross-tenant leakage. +- [ ] Live Discord member/role support is either implemented with verified permissions or documented as a precise follow-up. +- [ ] Final docs describe precedence: user > group/member-role > channel > agent > global. +- [ ] Final verification commands pass or failures are documented with root cause. + +## Risk Assessment + +- Risk: Discord member intent requires privileged configuration. Mitigation: capability gate and documented follow-up. +- Risk: Discord API rate limits from live member fetch. Mitigation: cache/stored contacts first; live fetch only where safe. + +## Security Considerations + +- Do not expose Discord tokens or raw provider credentials. +- Keep all context/member rows tenant and channel-instance scoped. + +## Next Steps + +- Open implementation PR after all phases pass and issue acceptance criteria are checked. + +## Unresolved Questions + +- Discord live role/member support depends on verified bot intents and permissions. diff --git a/plans/260528-issue-66-channel-context-admin-surface/plan.md b/plans/260528-issue-66-channel-context-admin-surface/plan.md new file mode 100644 index 00000000..2c70616b --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/plan.md @@ -0,0 +1,50 @@ +--- +title: "Issue 66 Channel Context Admin Surface" +description: "Add channel details admin views for contexts, members, granted MCP/CLI capabilities, and scoped credential overrides." +status: complete +priority: P2 +issue: 66 +branch: "codex-issue-66-channel-details-admin-design" +tags: [channels, web-ui, security, tdd, issue-66] +blockedBy: [] +blocks: [] +created: "2026-05-28T11:49:55.757Z" +createdBy: "ck:plan" +source: skill +--- + +# Issue 66 Channel Context Admin Surface + +## Overview + +Implement issue #66 as an incremental channel-context admin surface, not a one-shot RBAC rewrite. + +The first usable milestone is read-only: channel groups/contexts, members/users, and effective MCP/CLI capability visibility. Mutations come after contracts and tests prove tenant isolation, masking, audit logging, and resolver precedence. + +Credential precedence is: user > group/member-role > channel > agent > global. This keeps user credentials most specific and avoids shared channel secrets overriding a user's explicit credential. + +Out of scope for the first implementation pass: raw credential reveal, storing MCP/CLI secrets in `channel_instances.credentials`, and full Discord role/member sync without validating required Discord intents and permissions. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Research and Contracts](./phase-01-research-and-contracts.md) | Complete | +| 2 | [Read-only Context Surface](./phase-02-read-only-context-surface.md) | Complete | +| 3 | [Effective Capability Matrix](./phase-03-effective-capability-matrix.md) | Complete | +| 4 | [Context-scoped Grants](./phase-04-context-scoped-grants.md) | Complete | +| 5 | [Context-scoped Credentials](./phase-05-context-scoped-credentials.md) | Complete | +| 6 | [Discord Enrichment and Verification](./phase-06-discord-enrichment-and-verification.md) | Complete: live role/member enrichment remains gated behind Discord intent verification | + +## Dependencies + +- GitHub issue: https://github.com/digitopvn/goclaw/issues/66 +- Existing web UI: `ui/web/src/pages/channels/channel-detail/` +- Existing HTTP routes: `internal/http/channel_instances.go`, `internal/http/mcp_grants.go`, `internal/http/secure_cli_agent_grants.go` +- Existing stores: `internal/store/pg/`, `internal/store/sqlitestore/`, `internal/store/*mcp*`, `internal/store/*secure_cli*` +- Migration rule: PostgreSQL migrations and SQLite schema/migration map must stay in sync. +- Verification baseline: focused Go tests first, then `go build ./...`, `go build -tags sqliteonly ./...`, and relevant web checks with `pnpm` in `ui/web/`. + +## Unresolved Questions + +None. Discord live role/member enrichment is intentionally not enabled until required intents and permissions are verified. diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index d1286ccc..091011ad 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -119,6 +119,7 @@ "config": "Config", "credentials": "Credentials", "groups": "Groups", + "contexts": "Contexts", "managers": "Managers" }, "general": { @@ -157,6 +158,46 @@ "saveGroups": "Save Groups", "saving": "Saving..." }, + "contexts": { + "description": "Review channel contexts, stored members, effective MCP tools, Secure CLI tools, and scoped credentials.", + "emptyTitle": "No contexts", + "emptyHint": "Contexts appear after this channel receives messages or group metadata.", + "loading": "Loading context details...", + "capabilities": "Capabilities", + "members": "Members", + "noCapabilities": "No capabilities available in this context.", + "noMembers": "No stored members for this context.", + "none": "None", + "allTools": "All tools", + "scopedCount": "{{count}} scoped override", + "scopedCount_plural": "{{count}} scoped overrides", + "live": "Live", + "grant": "Grant", + "revoke": "Revoke", + "manageCapability": "Manage capability", + "scopedCredential": "Scoped credential", + "apiKeyPlaceholder": "API key (MCP only)", + "envPlaceholder": "Environment JSON, for example {\"TOKEN\":\"...\"}", + "secretHint": "Values are encrypted and never returned after save.", + "saveCredential": "Save credential", + "deleteCredential": "Delete credential", + "grantSaved": "Context grant saved", + "grantDeleted": "Context grant deleted", + "credentialSaved": "Context credential saved", + "credentialDeleted": "Context credential deleted", + "invalidEnv": "Environment must be a JSON object with string values.", + "envRequired": "Secure CLI credentials require environment JSON.", + "credentialRequired": "Enter an API key or environment JSON.", + "actionFailed": "Action failed", + "columns": { + "name": "Name", + "type": "Type", + "source": "Source", + "credentials": "Credentials", + "tools": "Tools", + "actions": "Actions" + } + }, "timeline": { "title": "Timeline", "firstFailed": "First failed", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index f79169f9..40feea90 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -118,6 +118,7 @@ "config": "Cấu hình", "credentials": "Thông tin xác thực", "groups": "Nhóm", + "contexts": "Ngữ cảnh", "managers": "Quản lý" }, "general": { @@ -156,6 +157,46 @@ "saveGroups": "Lưu nhóm", "saving": "Đang lưu..." }, + "contexts": { + "description": "Xem ngữ cảnh channel, thành viên đã lưu, MCP tools, Secure CLI tools và thông tin xác thực theo scope.", + "emptyTitle": "Không có ngữ cảnh", + "emptyHint": "Ngữ cảnh xuất hiện sau khi channel nhận tin nhắn hoặc metadata nhóm.", + "loading": "Đang tải chi tiết ngữ cảnh...", + "capabilities": "Quyền năng", + "members": "Thành viên", + "noCapabilities": "Không có quyền năng trong ngữ cảnh này.", + "noMembers": "Không có thành viên đã lưu trong ngữ cảnh này.", + "none": "Không có", + "allTools": "Tất cả tools", + "scopedCount": "{{count}} override theo scope", + "scopedCount_plural": "{{count}} override theo scope", + "live": "Live", + "grant": "Grant", + "revoke": "Revoke", + "manageCapability": "Quản lý quyền năng", + "scopedCredential": "Xác thực theo scope", + "apiKeyPlaceholder": "API key (chỉ MCP)", + "envPlaceholder": "JSON môi trường, ví dụ {\"TOKEN\":\"...\"}", + "secretHint": "Giá trị được mã hóa và không bao giờ trả về sau khi lưu.", + "saveCredential": "Lưu xác thực", + "deleteCredential": "Xóa xác thực", + "grantSaved": "Đã lưu grant theo ngữ cảnh", + "grantDeleted": "Đã xóa grant theo ngữ cảnh", + "credentialSaved": "Đã lưu xác thực theo ngữ cảnh", + "credentialDeleted": "Đã xóa xác thực theo ngữ cảnh", + "invalidEnv": "Môi trường phải là JSON object với giá trị dạng chuỗi.", + "envRequired": "Secure CLI credential cần JSON môi trường.", + "credentialRequired": "Nhập API key hoặc JSON môi trường.", + "actionFailed": "Thao tác thất bại", + "columns": { + "name": "Tên", + "type": "Loại", + "source": "Nguồn", + "credentials": "Xác thực", + "tools": "Tools", + "actions": "Thao tác" + } + }, "timeline": { "title": "Dòng thời gian", "firstFailed": "Lỗi đầu tiên", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index d390f7cf..357cdd21 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -118,6 +118,7 @@ "config": "配置", "credentials": "凭据", "groups": "群组", + "contexts": "上下文", "managers": "管理员" }, "general": { @@ -156,6 +157,46 @@ "saveGroups": "保存群组", "saving": "保存中..." }, + "contexts": { + "description": "查看 Channel 上下文、已存成员、MCP 工具、Secure CLI 工具和作用域凭据。", + "emptyTitle": "暂无上下文", + "emptyHint": "Channel 收到消息或群组元数据后会显示上下文。", + "loading": "正在加载上下文详情...", + "capabilities": "能力", + "members": "成员", + "noCapabilities": "此上下文没有可用能力。", + "noMembers": "此上下文没有已存成员。", + "none": "无", + "allTools": "全部工具", + "scopedCount": "{{count}} 个作用域覆盖", + "scopedCount_plural": "{{count}} 个作用域覆盖", + "live": "实时", + "grant": "授权", + "revoke": "撤销", + "manageCapability": "管理能力", + "scopedCredential": "作用域凭据", + "apiKeyPlaceholder": "API key(仅 MCP)", + "envPlaceholder": "环境变量 JSON,例如 {\"TOKEN\":\"...\"}", + "secretHint": "值会加密保存,保存后不会返回。", + "saveCredential": "保存凭据", + "deleteCredential": "删除凭据", + "grantSaved": "上下文授权已保存", + "grantDeleted": "上下文授权已删除", + "credentialSaved": "上下文凭据已保存", + "credentialDeleted": "上下文凭据已删除", + "invalidEnv": "环境变量必须是字符串值的 JSON 对象。", + "envRequired": "Secure CLI 凭据需要环境变量 JSON。", + "credentialRequired": "请输入 API key 或环境变量 JSON。", + "actionFailed": "操作失败", + "columns": { + "name": "名称", + "type": "类型", + "source": "来源", + "credentials": "凭据", + "tools": "工具", + "actions": "操作" + } + }, "timeline": { "title": "时间线", "firstFailed": "首次失败", diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 268323bf..00c0b0cb 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -39,6 +39,11 @@ export const queryKeys = { all: ["channels"] as const, list: (params: Record) => ["channels", params] as const, detail: (id: string) => ["channels", "detail", id] as const, + contexts: (id: string) => ["channels", "detail", id, "contexts"] as const, + contextMembers: (id: string, scopeType: string, scopeKey: string) => + ["channels", "detail", id, "contexts", scopeType, scopeKey, "members"] as const, + contextCapabilities: (id: string, scopeType: string, scopeKey: string) => + ["channels", "detail", id, "contexts", scopeType, scopeKey, "capabilities"] as const, }, contacts: { all: ["contacts"] as const, diff --git a/ui/web/src/pages/channels/channel-detail/channel-context-capability-admin-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-context-capability-admin-dialog.tsx new file mode 100644 index 00000000..c5373a4d --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-context-capability-admin-dialog.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { KeyRound, Loader2, ShieldCheck, ShieldX, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { ChannelCapability, ChannelContextData } from "@/types/channel"; +import type { ChannelCredentialPayload } from "../hooks/use-channel-detail"; + +export interface ChannelContextCapabilityTarget { + context: ChannelContextData; + capability: ChannelCapability; +} + +interface ChannelContextCapabilityAdminDialogProps { + target: ChannelContextCapabilityTarget | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onGrant: (target: ChannelContextCapabilityTarget) => Promise; + onRevoke: (target: ChannelContextCapabilityTarget) => Promise; + onSaveCredentials: (target: ChannelContextCapabilityTarget, payload: ChannelCredentialPayload) => Promise; + onDeleteCredentials: (target: ChannelContextCapabilityTarget) => Promise; +} + +function parseEnvJSON(value: string): Record | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = JSON.parse(trimmed) as Record; + const env: Record = {}; + for (const [key, raw] of Object.entries(parsed)) { + if (typeof raw === "string") { + env[key] = raw; + } + } + return env; +} + +export function ChannelContextCapabilityAdminDialog({ + target, + open, + onOpenChange, + onGrant, + onRevoke, + onSaveCredentials, + onDeleteCredentials, +}: ChannelContextCapabilityAdminDialogProps) { + const { t } = useTranslation("channels"); + const [apiKey, setApiKey] = useState(""); + const [envText, setEnvText] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState<"grant" | "revoke" | "credentials" | "deleteCredentials" | null>(null); + + useEffect(() => { + if (open) { + setApiKey(""); + setEnvText(""); + setError(""); + setSaving(null); + } + }, [open, target]); + + if (!target) return null; + + const { capability } = target; + const isMCP = capability.type === "mcp_server"; + + const run = async ( + action: "grant" | "revoke" | "credentials" | "deleteCredentials", + task: () => Promise, + ) => { + setSaving(action); + setError(""); + try { + await task(); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : t("detail.contexts.actionFailed")); + } finally { + setSaving(null); + } + }; + + const saveCredentials = async () => { + let env: Record | undefined; + try { + env = parseEnvJSON(envText); + } catch { + setError(t("detail.contexts.invalidEnv")); + return; + } + if (!isMCP && !env) { + setError(t("detail.contexts.envRequired")); + return; + } + if (isMCP && !env && !apiKey.trim()) { + setError(t("detail.contexts.credentialRequired")); + return; + } + await run("credentials", () => onSaveCredentials(target, { apiKey: apiKey.trim() || undefined, env })); + }; + + return ( + { if (!saving) onOpenChange(next); }}> + + + {t("detail.contexts.manageCapability")} + + +
+
+
{capability.display_name || capability.name}
+
{capability.name}
+
+ +
+ + +
+ +
+
{t("detail.contexts.scopedCredential")}
+ {isMCP && ( + setApiKey(event.target.value)} + placeholder={t("detail.contexts.apiKeyPlaceholder")} + /> + )} +