fix(channels): harden context capability admin

This commit is contained in:
Goon committed 2026-05-29 20:04:33 +07:00
1 parent 697aa266e0
commit c7aac69ca0
20 files changed
+641 -171

No files matched your search

+51 -32
View File
@@ -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,
@@ -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)
@@ -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
+1 -1
View File
@@ -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,
@@ -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)
}
}
+4 -4
View File
@@ -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))
}
+4 -2
View File
@@ -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
}
}
}
+27
View File
@@ -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"`
+37 -35
View File
@@ -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
}
+43 -25
View File
@@ -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
}
@@ -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
}
+43 -25
View File
@@ -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
}
+20 -1
View File
@@ -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": {
+20 -1
View File
@@ -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": {
+20 -1
View File
@@ -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": {
@@ -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<void>;
onRevoke: (target: ChannelContextCapabilityTarget) => Promise<void>;
onSaveCredentials: (target: ChannelContextCapabilityTarget, payload: ChannelCredentialPayload) => Promise<void>;
onDeleteCredentials: (target: ChannelContextCapabilityTarget) => Promise<void>;
}
function parseEnvJSON(value: string): Record<string, string> | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const env: Record<string, string> = {};
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<void>,
) => {
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<string, string> | 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 (
<Dialog open={open} onOpenChange={(next) => { if (!saving) onOpenChange(next); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("detail.contexts.manageCapability")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="text-sm font-medium">{capability.display_name || capability.name}</div>
<div className="font-mono text-xs text-muted-foreground">{capability.name}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={() => run("grant", () => onGrant(target))}
disabled={!!saving}
>
{saving === "grant" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
{t("detail.contexts.grant")}
</Button>
<Button
variant="outline"
onClick={() => run("revoke", () => onRevoke(target))}
disabled={!!saving || !capability.context_grant_configured}
>
{saving === "revoke" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldX className="h-4 w-4" />}
{t("detail.contexts.revoke")}
</Button>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">{t("detail.contexts.scopedCredential")}</div>
{isMCP && (
<Input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={t("detail.contexts.apiKeyPlaceholder")}
/>
)}
<Textarea
value={envText}
onChange={(event) => setEnvText(event.target.value)}
placeholder={t("detail.contexts.envPlaceholder")}
size="lg"
/>
<p className="text-xs text-muted-foreground">{t("detail.contexts.secretHint")}</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
variant="destructive"
onClick={() => run("deleteCredentials", () => onDeleteCredentials(target))}
disabled={!!saving || !capability.context_credentials_configured}
>
{saving === "deleteCredentials" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
{t("detail.contexts.deleteCredential")}
</Button>
<Button onClick={saveCredentials} disabled={!!saving}>
{saving === "credentials" ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
{t("detail.contexts.saveCredential")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,15 +1,24 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, Loader2, RefreshCw, ShieldCheck, Users } from "lucide-react";
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Settings2, ShieldCheck, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/shared/empty-state";
import type { ChannelCapability, ChannelContextData, ChannelContextMember } from "@/types/channel";
import {
ChannelContextCapabilityAdminDialog,
type ChannelContextCapabilityTarget,
} from "./channel-context-capability-admin-dialog";
import type { ChannelCredentialPayload } from "../hooks/use-channel-detail";
interface ChannelContextsTabProps {
listContexts: () => Promise<ChannelContextData[]>;
listContextMembers: (scopeType: string, scopeKey: string) => Promise<ChannelContextMember[]>;
listContextCapabilities: (scopeType: string, scopeKey: string) => Promise<ChannelCapability[]>;
onGrantCapability: (target: ChannelContextCapabilityTarget) => Promise<void>;
onRevokeCapability: (target: ChannelContextCapabilityTarget) => Promise<void>;
onSaveCredentials: (target: ChannelContextCapabilityTarget, payload: ChannelCredentialPayload) => Promise<void>;
onDeleteCredentials: (target: ChannelContextCapabilityTarget) => Promise<void>;
}
function contextKey(ctx: ChannelContextData) {
@@ -24,6 +33,10 @@ export function ChannelContextsTab({
listContexts,
listContextMembers,
listContextCapabilities,
onGrantCapability,
onRevokeCapability,
onSaveCredentials,
onDeleteCredentials,
}: ChannelContextsTabProps) {
const { t } = useTranslation("channels");
const [contexts, setContexts] = useState<ChannelContextData[]>([]);
@@ -32,6 +45,7 @@ export function ChannelContextsTab({
const [members, setMembers] = useState<Record<string, ChannelContextMember[]>>({});
const [capabilities, setCapabilities] = useState<Record<string, ChannelCapability[]>>({});
const [loadingRows, setLoadingRows] = useState<Record<string, boolean>>({});
const [adminTarget, setAdminTarget] = useState<ChannelContextCapabilityTarget | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
@@ -118,9 +132,10 @@ export function ChannelContextsTab({
}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{ctx.label || ctx.scope_key}</span>
<span className="truncate text-sm font-medium">{ctx.display_name || ctx.scope_key}</span>
<Badge variant="outline">{ctx.scope_type}</Badge>
{ctx.source && <Badge variant="secondary">{ctx.source}</Badge>}
{ctx.live_members_supported && <Badge variant="success">{t("detail.contexts.live")}</Badge>}
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">{ctx.scope_key}</div>
</div>
@@ -154,6 +169,7 @@ export function ChannelContextsTab({
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.source")}</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.credentials")}</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.tools")}</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.actions")}</th>
</tr>
</thead>
<tbody>
@@ -173,6 +189,17 @@ export function ChannelContextsTab({
<td className="px-3 py-2 text-xs text-muted-foreground">
{(cap.tool_allow?.length ?? 0) > 0 ? cap.tool_allow?.join(", ") : t("detail.contexts.allTools")}
</td>
<td className="px-3 py-2 text-right">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setAdminTarget({ context: ctx, capability: cap })}
aria-label={t("detail.contexts.manageCapability")}
>
<Settings2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
@@ -213,6 +240,27 @@ export function ChannelContextsTab({
})}
</div>
)}
<ChannelContextCapabilityAdminDialog
target={adminTarget}
open={!!adminTarget}
onOpenChange={(open) => { if (!open) setAdminTarget(null); }}
onGrant={async (target) => {
await onGrantCapability(target);
await loadContextDetail(target.context);
}}
onRevoke={async (target) => {
await onRevokeCapability(target);
await loadContextDetail(target.context);
}}
onSaveCredentials={async (target, payload) => {
await onSaveCredentials(target, payload);
await loadContextDetail(target.context);
}}
onDeleteCredentials={async (target) => {
await onDeleteCredentials(target);
await loadContextDetail(target.context);
}}
/>
</div>
);
}
@@ -64,6 +64,10 @@ export function ChannelDetailPage({
listContexts,
listContextMembers,
listContextCapabilities,
upsertContextGrant,
deleteContextGrant,
setContextCredentials,
deleteContextCredentials,
} = useChannelDetail(instanceId);
const { agents } = useAgents();
const { channels } = useChannels();
@@ -240,6 +244,26 @@ export function ChannelDetailPage({
listContexts={listContexts}
listContextMembers={listContextMembers}
listContextCapabilities={listContextCapabilities}
onGrantCapability={(target) => upsertContextGrant({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
})}
onRevokeCapability={(target) => deleteContextGrant({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
})}
onSaveCredentials={(target, payload) => setContextCredentials({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
}, payload)}
onDeleteCredentials={(target) => deleteContextCredentials({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
})}
/>
</TabsContent>
@@ -14,6 +14,16 @@ import type {
import type { ChannelContact } from "@/types/contact";
export type { ChannelContact };
export type ChannelCapabilityMutation = {
scopeType: string;
scopeKey: string;
capability: ChannelCapability;
};
export interface ChannelCredentialPayload {
apiKey?: string;
env?: Record<string, string>;
}
export interface GroupManagerGroupInfo {
group_id: string;
@@ -131,6 +141,53 @@ export function useChannelDetail(instanceId: string | undefined) {
[instanceId, http],
);
const contextPath = useCallback((scopeType: string, scopeKey: string) => {
return `/v1/channels/instances/${instanceId}/contexts/${encodeURIComponent(scopeType)}/${encodeURIComponent(scopeKey)}`;
}, [instanceId]);
const upsertContextGrant = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-grants" : "cli-grants";
await http.put(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`, { enabled: true });
toast.success(i18next.t("channels:detail.contexts.grantSaved"));
},
[contextPath, http, instanceId],
);
const deleteContextGrant = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-grants" : "cli-grants";
await http.delete(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`);
toast.success(i18next.t("channels:detail.contexts.grantDeleted"));
},
[contextPath, http, instanceId],
);
const setContextCredentials = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation, payload: ChannelCredentialPayload) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-credentials" : "cli-credentials";
const body = capability.type === "mcp_server"
? { api_key: payload.apiKey, env: payload.env }
: { env_vars: payload.env };
await http.put(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`, body);
toast.success(i18next.t("channels:detail.contexts.credentialSaved"));
},
[contextPath, http, instanceId],
);
const deleteContextCredentials = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-credentials" : "cli-credentials";
await http.delete(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`);
toast.success(i18next.t("channels:detail.contexts.credentialDeleted"));
},
[contextPath, http, instanceId],
);
const listContacts = useCallback(
async (search: string, channelType?: string): Promise<ChannelContact[]> => {
const params: Record<string, string> = {};
@@ -154,6 +211,10 @@ export function useChannelDetail(instanceId: string | undefined) {
listContexts,
listContextMembers,
listContextCapabilities,
upsertContextGrant,
deleteContextGrant,
setContextCredentials,
deleteContextCredentials,
listContacts,
refresh: invalidate,
};
+2 -2
View File
@@ -54,9 +54,9 @@ export interface ChannelInstanceInput {
export interface ChannelContextData {
scope_type: "channel" | "group" | "user" | "role";
scope_key: string;
label: string;
display_name: string;
source: string;
live_members: boolean;
live_members_supported: boolean;
member_count?: number;
last_seen_at?: string;
}