diff --git a/internal/http/channel_instance_capabilities.go b/internal/http/channel_instance_capabilities.go index de772df7..493f7579 100644 --- a/internal/http/channel_instance_capabilities.go +++ b/internal/http/channel_instance_capabilities.go @@ -41,9 +41,12 @@ func (h *ChannelInstancesHandler) handleListContextCapabilities(w http.ResponseW 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 { @@ -79,22 +82,26 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor } contextGrants := map[uuidString]store.MCPContextGrant{} if h.mcpContextStore != nil { - grants, err := h.mcpContextStore.ListContextGrants(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, grant := range grants { - contextGrants[uuidString(grant.ServerID.String())] = grant + 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 { - creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, cred := range creds { - contextCreds[uuidString(cred.ServerID.String())] = cred + 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)) @@ -106,7 +113,7 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor hasCredential := info.Server.APIKey != "" || len(info.Server.Headers) > 0 || len(info.Server.Env) > 0 contextCredential := false if cred, ok := contextCreds[idKey]; ok { - credentialSource = scope.ScopeType + credentialSource = cred.ScopeType hasCredential = cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0 contextCredential = true } @@ -124,7 +131,7 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor toolDeny := info.ToolDeny contextGrant := false if grant, ok := contextGrants[idKey]; ok { - source = scope.ScopeType + source = grant.ScopeType enabled = grant.Enabled toolAllow = decodeStringList(grant.ToolAllow) toolDeny = decodeStringList(grant.ToolDeny) @@ -155,16 +162,20 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor 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: scope.ScopeType, + Source: grant.ScopeType, ToolAllow: decodeStringList(grant.ToolAllow), ToolDeny: decodeStringList(grant.ToolDeny), - CredentialSource: scope.ScopeType, + CredentialSource: credentialSource, HasCredential: hasCredRow && (cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0), ContextGrant: true, ContextCredentials: hasCredRow, @@ -183,22 +194,26 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor } contextGrants := map[uuidString]store.SecureCLIContextGrant{} if h.cliContextStore != nil { - grants, err := h.cliContextStore.ListContextGrants(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, grant := range grants { - contextGrants[uuidString(grant.BinaryID.String())] = grant + 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 { - creds, err := h.cliContextStore.ListContextCredentials(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, cred := range creds { - contextCreds[uuidString(cred.BinaryID.String())] = cred + 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)) @@ -214,13 +229,13 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor hasCredential := len(b.EncryptedEnv) > 0 contextGrant := false if grant, ok := contextGrants[idKey]; ok { - source = scope.ScopeType + source = grant.ScopeType contextGrant = true b.Enabled = b.Enabled && grant.Enabled } contextCredential := false if cred, ok := contextCreds[idKey]; ok { - credentialSource = scope.ScopeType + credentialSource = cred.ScopeType hasCredential = len(cred.EncryptedEnv) > 0 contextCredential = true } @@ -253,14 +268,18 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor 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: scope.ScopeType, - CredentialSource: scope.ScopeType, + Source: grant.ScopeType, + CredentialSource: credentialSource, HasCredential: hasCredRow, ContextGrant: true, ContextCredentials: hasCredRow, diff --git a/internal/http/channel_instance_capabilities_test.go b/internal/http/channel_instance_capabilities_test.go index c7ba31cf..bf719c87 100644 --- a/internal/http/channel_instance_capabilities_test.go +++ b/internal/http/channel_instance_capabilities_test.go @@ -138,7 +138,7 @@ func (s *fakeChannelCapabilityCLIStore) ListUserCredentials(context.Context, uui func TestChannelContextCapabilitiesExposeOnlyCredentialMetadata(t *testing.T) { token := "channel-capabilities-key" setupTestCache(t, map[string]*store.APIKeyData{ - crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "tenant-user-1"}, }) instID := uuid.New() @@ -182,7 +182,7 @@ func TestChannelContextCapabilitiesExposeOnlyCredentialMetadata(t *testing.T) { mux := http.NewServeMux() handler.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/channel/"+channelName+"/capabilities?user_id=tenant-user-1", nil) + 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) diff --git a/internal/http/channel_instance_context_capability_admin.go b/internal/http/channel_instance_context_capability_admin.go index f6bbee28..ac0e7c16 100644 --- a/internal/http/channel_instance_context_capability_admin.go +++ b/internal/http/channel_instance_context_capability_admin.go @@ -54,6 +54,9 @@ func (h *ChannelInstancesHandler) handleListContextMCPGrants(w http.ResponseWrit 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 @@ -171,6 +174,9 @@ func (h *ChannelInstancesHandler) handleListContextMCPCredentials(w http.Respons 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 @@ -267,6 +273,9 @@ func (h *ChannelInstancesHandler) handleListContextCLIGrants(w http.ResponseWrit 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 @@ -421,6 +430,9 @@ func (h *ChannelInstancesHandler) handleListContextCLICredentials(w http.Respons 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 diff --git a/internal/http/channel_instance_contexts.go b/internal/http/channel_instance_contexts.go index a122bf69..b34130b5 100644 --- a/internal/http/channel_instance_contexts.go +++ b/internal/http/channel_instance_contexts.go @@ -110,7 +110,7 @@ func (h *ChannelInstancesHandler) handleListContextMembers(w http.ResponseWriter } } - if h.contactStore != nil { + if h.contactStore != nil && scopeType != store.ChannelScopeTypeGroup && scopeType != "topic" { contacts, err := h.contactStore.ListContacts(r.Context(), store.ContactListOpts{ ChannelType: inst.ChannelType, ChannelInstance: inst.Name, diff --git a/internal/http/channel_instance_contexts_test.go b/internal/http/channel_instance_contexts_test.go index 6a9ae77b..1d5a91ac 100644 --- a/internal/http/channel_instance_contexts_test.go +++ b/internal/http/channel_instance_contexts_test.go @@ -133,7 +133,7 @@ func TestChannelContextsListUsesStoredGroupsAndMasksToContextShape(t *testing.T) } } -func TestChannelContextMembersUseStoredContactsWhenLiveProviderUnavailable(t *testing.T) { +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"}, @@ -181,9 +181,9 @@ func TestChannelContextMembersUseStoredContactsWhenLiveProviderUnavailable(t *te t.Fatal(err) } if body.LiveMembersSupported || body.Source != "stored_contacts" { - t.Fatalf("expected stored contact fallback, got %+v", body) + t.Fatalf("expected unsupported stored contact response, got %+v", body) } - if len(body.Members) != 1 || body.Members[0].UserID != userID || body.Members[0].PlatformID != "386246614" { + 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 3fb4e122..6f540a57 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -132,16 +132,16 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { 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.auth(h.handleListContextMCPGrants)) + 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.auth(h.handleListContextMCPCredentials)) + 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.auth(h.handleListContextCLIGrants)) + 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.auth(h.handleListContextCLICredentials)) + 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)) } diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 3208d75a..84abce6d 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -226,8 +226,10 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc var contextCreds *store.MCPContextCredentials if contextStore, ok := m.store.(store.MCPContextAdminStore); ok { - if scope, hasScope := store.ChannelContextScopeFromContext(ctx); hasScope { - contextCreds, _ = contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID) + for _, scope := range store.ChannelContextScopeChainFromContext(ctx) { + if creds, _ := contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID); creds != nil { + contextCreds = creds + } } } diff --git a/internal/store/channel_context_store.go b/internal/store/channel_context_store.go index acfbb8cc..7737b2b1 100644 --- a/internal/store/channel_context_store.go +++ b/internal/store/channel_context_store.go @@ -49,6 +49,33 @@ func ChannelContextScopeFromContext(ctx context.Context) (ChannelContextScope, b 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"` diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index b1b805a2..bea07579 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -204,47 +204,49 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID } func (s *PGMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 { return result, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err - } - if len(grants) == 0 { - return result, nil - } - 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 + 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 } - 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), + }) } - 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 } diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 22e79d3b..02cb90fe 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -503,37 +503,55 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu } func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok || b == nil { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 || b == nil { return b, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err + 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 + } } - for _, contextGrant := range grants { - if contextGrant.BinaryID != b.ID { + 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 !contextGrant.Enabled { - return nil, nil + if !hasUserCredential { + b.UserEnv = creds.EncryptedEnv + b.UserCredentialType = creds.CredentialType + b.UserHostScope = creds.HostScope + } else { + b.EncryptedEnv = creds.EncryptedEnv } - grant := &store.SecureCLIAgentGrant{ - DenyArgs: contextGrant.DenyArgs, - DenyVerbose: contextGrant.DenyVerbose, - TimeoutSeconds: contextGrant.TimeoutSeconds, - Tips: contextGrant.Tips, - EncryptedEnv: contextGrant.EncryptedEnv, - } - b.MergeGrantOverrides(grant) - break - } - creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) - if err != nil { - return nil, err - } - if creds != nil && len(creds.EncryptedEnv) > 0 { - b.EncryptedEnv = creds.EncryptedEnv } return b, nil } diff --git a/internal/store/sqlitestore/mcp_servers_access.go b/internal/store/sqlitestore/mcp_servers_access.go index 01448f33..5fbc52c8 100644 --- a/internal/store/sqlitestore/mcp_servers_access.go +++ b/internal/store/sqlitestore/mcp_servers_access.go @@ -238,47 +238,49 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. } func (s *SQLiteMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 { return result, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err - } - if len(grants) == 0 { - return result, nil - } - 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 + 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 } - 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), + }) } - 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 } diff --git a/internal/store/sqlitestore/secure-cli.go b/internal/store/sqlitestore/secure-cli.go index e669a71d..a3fe20dc 100644 --- a/internal/store/sqlitestore/secure-cli.go +++ b/internal/store/sqlitestore/secure-cli.go @@ -529,37 +529,55 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. } func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok || b == nil { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 || b == nil { return b, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err + 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 + } } - for _, contextGrant := range grants { - if contextGrant.BinaryID != b.ID { + 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 !contextGrant.Enabled { - return nil, nil + if !hasUserCredential { + b.UserEnv = creds.EncryptedEnv + b.UserCredentialType = creds.CredentialType + b.UserHostScope = creds.HostScope + } else { + b.EncryptedEnv = creds.EncryptedEnv } - grant := &store.SecureCLIAgentGrant{ - DenyArgs: contextGrant.DenyArgs, - DenyVerbose: contextGrant.DenyVerbose, - TimeoutSeconds: contextGrant.TimeoutSeconds, - Tips: contextGrant.Tips, - EncryptedEnv: contextGrant.EncryptedEnv, - } - b.MergeGrantOverrides(grant) - break - } - creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) - if err != nil { - return nil, err - } - if creds != nil && len(creds.EncryptedEnv) > 0 { - b.EncryptedEnv = creds.EncryptedEnv } return b, nil } diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 8849a0bd..091011ad 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -171,12 +171,31 @@ "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" + "tools": "Tools", + "actions": "Actions" } }, "timeline": { diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 9f22279a..40feea90 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -170,12 +170,31 @@ "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" + "tools": "Tools", + "actions": "Thao tác" } }, "timeline": { diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 92bf00b6..357cdd21 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -170,12 +170,31 @@ "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": "工具" + "tools": "工具", + "actions": "操作" } }, "timeline": { 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")} + /> + )} +