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