Files
goclaw/internal/store/sqlitestore/channel-context-capabilities.go
Duy /zuey/ 2cd750ac44 feat(channels): add context capability admin surface
Squash merge PR #115 after resolving changelog and SQLite migration-map conflicts with current dev. Renumbered channel-context PostgreSQL migration to 000075 and bumped PG required schema to 75 plus SQLite schema to 44 so it follows the run timeline migration. Local checks passed: go test ./..., go build ./..., go build -tags sqliteonly ./..., go vet ./..., and pnpm -C ui/web build. PR CI run 26705617311 passed release-versioning, go, and web.
2026-05-31 13:50:31 +07:00

529 lines
19 KiB
Go

//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
}