mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
fix(channels): harden context capability admin
This commit is contained in:
1 parent
697aa266e0
commit
c7aac69ca0
20 files changed
+641
-171
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user