Changeset 0.38.1 (#234)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
37
server/database/migrations/022_ext_connections.sql
Normal file
37
server/database/migrations/022_ext_connections.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 022 Extension Connections
|
||||
-- ==========================================
|
||||
-- v0.38.1: Scoped credential management for extensions.
|
||||
-- Same scope/resolution pattern as provider_configs.
|
||||
-- ==========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ext_connections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type TEXT NOT NULL,
|
||||
package_id TEXT NOT NULL,
|
||||
scope VARCHAR(10) NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(type, scope, owner_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id) WHERE owner_id != '';
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_connections_active ON ext_connections(is_active) WHERE is_active = true;
|
||||
|
||||
DROP TRIGGER IF EXISTS ext_connections_updated_at ON ext_connections;
|
||||
CREATE TRIGGER ext_connections_updated_at BEFORE UPDATE ON ext_connections
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE ext_connections IS 'v0.38.1: Extension connection credentials (scoped, encrypted secrets in config JSON)';
|
||||
COMMENT ON COLUMN ext_connections.type IS 'Connection type identifier, shared across packages (e.g. "gitea", "github")';
|
||||
COMMENT ON COLUMN ext_connections.package_id IS 'Declaring package ID (UI attribution only, not access control)';
|
||||
COMMENT ON COLUMN ext_connections.scope IS 'global=admin-managed, team=team-scoped, personal=user-owned';
|
||||
COMMENT ON COLUMN ext_connections.owner_id IS 'Empty for global; team_id for team scope; user_id for personal scope';
|
||||
COMMENT ON COLUMN ext_connections.config IS 'JSON config blob; secret-type fields encrypted at rest by handler layer';
|
||||
24
server/database/migrations/sqlite/022_ext_connections.sql
Normal file
24
server/database/migrations/sqlite/022_ext_connections.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 022 Extension Connections
|
||||
-- ==========================================
|
||||
-- v0.38.1: Scoped credential management for extensions.
|
||||
-- SQLite version (no partial indexes, TEXT types).
|
||||
-- ==========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ext_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
package_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
UNIQUE(type, scope, owner_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id);
|
||||
112
server/handlers/admin_connections.go
Normal file
112
server/handlers/admin_connections.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
// v0.38.1: Extension connection handlers — admin/global scope.
|
||||
// Methods on AdminHandler, mirrors admin provider config pattern.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ListGlobalConnections returns all global-scope connections.
|
||||
func (h *AdminHandler) ListGlobalConnections(c *gin.Context) {
|
||||
conns, err := h.stores.Connections.ListGlobal(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
|
||||
}
|
||||
|
||||
// CreateGlobalConnection creates a global-scope connection.
|
||||
func (h *AdminHandler) CreateGlobalConnection(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Config models.JSONMap `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||
secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type)
|
||||
encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "")
|
||||
|
||||
conn := &models.ExtConnection{
|
||||
Type: req.Type,
|
||||
PackageID: req.PackageID,
|
||||
Scope: models.ScopeGlobal,
|
||||
OwnerID: "",
|
||||
Name: req.Name,
|
||||
Config: encConfig,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": conn.ID,
|
||||
"type": conn.Type,
|
||||
"name": conn.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateGlobalConnection updates a global-scope connection.
|
||||
func (h *AdminHandler) UpdateGlobalConnection(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||
if err != nil || existing.Scope != models.ScopeGlobal {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Config models.JSONMap `json:"config,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
patch := models.ExtConnectionPatch{Name: req.Name}
|
||||
if req.Config != nil {
|
||||
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||
secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type)
|
||||
patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "")
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
|
||||
}
|
||||
|
||||
// DeleteGlobalConnection deletes a global-scope connection.
|
||||
func (h *AdminHandler) DeleteGlobalConnection(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeGlobal, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
|
||||
}
|
||||
101
server/handlers/connection_resolver.go
Normal file
101
server/handlers/connection_resolver.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
// v0.38.1: ConnectionResolver bridges the sandbox connections module
|
||||
// to the store + vault layers. Implements sandbox.ConnectionResolver.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"chat-switchboard/crypto"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ConnectionResolverAdapter implements sandbox.ConnectionResolver.
|
||||
type ConnectionResolverAdapter struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewConnectionResolverAdapter(s store.Stores, vault *crypto.KeyResolver) *ConnectionResolverAdapter {
|
||||
return &ConnectionResolverAdapter{stores: s, vault: vault}
|
||||
}
|
||||
|
||||
// Resolve returns a single connection with secrets decrypted.
|
||||
func (r *ConnectionResolverAdapter) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
|
||||
conn, err := r.stores.Connections.Resolve(ctx, userID, connType, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.decryptInPlace(ctx, conn)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// ResolveAll returns all accessible connections with secrets decrypted.
|
||||
func (r *ConnectionResolverAdapter) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
|
||||
conns, err := r.stores.Connections.ResolveAll(ctx, userID, connType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range conns {
|
||||
r.decryptInPlace(ctx, &conns[i])
|
||||
}
|
||||
return conns, nil
|
||||
}
|
||||
|
||||
// decryptInPlace decrypts secret fields in the connection's config.
|
||||
func (r *ConnectionResolverAdapter) decryptInPlace(ctx context.Context, conn *models.ExtConnection) {
|
||||
if conn == nil || conn.Config == nil || r.vault == nil {
|
||||
return
|
||||
}
|
||||
secretFields := r.lookupSecretFields(ctx, conn.PackageID, conn.Type)
|
||||
for k, v := range conn.Config {
|
||||
if !secretFields[k] {
|
||||
continue
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
enc := toBytes(m["enc"])
|
||||
nonce := toBytes(m["nonce"])
|
||||
if len(enc) == 0 {
|
||||
continue
|
||||
}
|
||||
plaintext, err := r.vault.Decrypt(enc, nonce, conn.Scope, conn.OwnerID)
|
||||
if err == nil {
|
||||
conn.Config[k] = plaintext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lookupSecretFields reads the package manifest to identify secret fields.
|
||||
func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
pkg, err := r.stores.Packages.Get(ctx, packageID)
|
||||
if err != nil || pkg == nil {
|
||||
return result
|
||||
}
|
||||
connsRaw, ok := pkg.Manifest["connections"]
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
connsJSON, _ := json.Marshal(connsRaw)
|
||||
var conns []struct {
|
||||
Type string `json:"type"`
|
||||
Fields map[string]map[string]string `json:"fields"`
|
||||
}
|
||||
json.Unmarshal(connsJSON, &conns)
|
||||
for _, cd := range conns {
|
||||
if cd.Type == connType {
|
||||
for fieldName, fieldDef := range cd.Fields {
|
||||
if fieldDef["type"] == "secret" {
|
||||
result[fieldName] = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
299
server/handlers/connections.go
Normal file
299
server/handlers/connections.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package handlers
|
||||
|
||||
// v0.38.1: Extension connection handlers — personal scope + resolution.
|
||||
// Pattern follows apiconfigs.go (ProviderConfigHandler).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/crypto"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ConnectionHandler handles user-facing extension connection endpoints.
|
||||
type ConnectionHandler struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *ConnectionHandler {
|
||||
return &ConnectionHandler{stores: s, vault: vault}
|
||||
}
|
||||
|
||||
// ListConnections returns the user's personal connections.
|
||||
func (h *ConnectionHandler) ListConnections(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
|
||||
}
|
||||
|
||||
// CreateConnection creates a personal-scope connection.
|
||||
func (h *ConnectionHandler) CreateConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Config models.JSONMap `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
secretFields := h.lookupSecretFields(c, req.PackageID, req.Type)
|
||||
encConfig := h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
|
||||
|
||||
conn := &models.ExtConnection{
|
||||
Type: req.Type,
|
||||
PackageID: req.PackageID,
|
||||
Scope: models.ScopePersonal,
|
||||
OwnerID: userID,
|
||||
Name: req.Name,
|
||||
Config: encConfig,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": conn.ID,
|
||||
"type": conn.Type,
|
||||
"name": conn.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// GetConnection returns a single connection by ID.
|
||||
func (h *ConnectionHandler) GetConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
conn, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||
if err != nil || conn.Scope != models.ScopePersonal || conn.OwnerID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, maskSingleConnection(conn))
|
||||
}
|
||||
|
||||
// UpdateConnection updates a personal connection.
|
||||
func (h *ConnectionHandler) UpdateConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own connections"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Config models.JSONMap `json:"config,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
patch := models.ExtConnectionPatch{Name: req.Name}
|
||||
if req.Config != nil {
|
||||
secretFields := h.lookupSecretFields(c, existing.PackageID, existing.Type)
|
||||
patch.Config = h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
|
||||
}
|
||||
|
||||
// DeleteConnection deletes a personal connection.
|
||||
func (h *ConnectionHandler) DeleteConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopePersonal, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
|
||||
}
|
||||
|
||||
// ResolveConnection resolves a connection via the scope chain.
|
||||
// GET /connections/resolve?type=gitea&name=Work+Gitea
|
||||
func (h *ConnectionHandler) ResolveConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
connType := c.Query("type")
|
||||
if connType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "type query parameter required"})
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
|
||||
conn, err := h.stores.Connections.Resolve(c.Request.Context(), userID, connType, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no connection found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt secrets for the resolver response (used by frontend to test connections).
|
||||
secretFields := h.lookupSecretFields(c, conn.PackageID, conn.Type)
|
||||
conn.Config = h.decryptSecrets(conn.Config, secretFields, conn.Scope, conn.OwnerID)
|
||||
|
||||
c.JSON(http.StatusOK, conn)
|
||||
}
|
||||
|
||||
// ── Encryption helpers ─────────────────────────────
|
||||
|
||||
// lookupSecretFields reads the package manifest to identify which config
|
||||
// fields are of type "secret" for a given connection type.
|
||||
func (h *ConnectionHandler) lookupSecretFields(c *gin.Context, packageID, connType string) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), packageID)
|
||||
if err != nil || pkg == nil {
|
||||
return result
|
||||
}
|
||||
connsRaw, ok := pkg.Manifest["connections"]
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
connsJSON, _ := json.Marshal(connsRaw)
|
||||
var conns []struct {
|
||||
Type string `json:"type"`
|
||||
Fields map[string]map[string]string `json:"fields"`
|
||||
}
|
||||
json.Unmarshal(connsJSON, &conns)
|
||||
for _, cd := range conns {
|
||||
if cd.Type == connType {
|
||||
for fieldName, fieldDef := range cd.Fields {
|
||||
if fieldDef["type"] == "secret" {
|
||||
result[fieldName] = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// encryptSecrets encrypts secret-type fields within a config map.
|
||||
// Non-secret fields pass through unchanged.
|
||||
func (h *ConnectionHandler) encryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
|
||||
if config == nil || h.vault == nil {
|
||||
return config
|
||||
}
|
||||
out := make(models.JSONMap, len(config))
|
||||
for k, v := range config {
|
||||
if secretFields[k] {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
enc, nonce, err := h.vault.EncryptForScope(s, scope, ownerID)
|
||||
if err == nil {
|
||||
out[k] = map[string]interface{}{
|
||||
"enc": enc,
|
||||
"nonce": nonce,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decryptSecrets decrypts secret-type fields within a config map.
|
||||
func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
|
||||
if config == nil || h.vault == nil {
|
||||
return config
|
||||
}
|
||||
out := make(models.JSONMap, len(config))
|
||||
for k, v := range config {
|
||||
if secretFields[k] {
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
encRaw, _ := m["enc"]
|
||||
nonceRaw, _ := m["nonce"]
|
||||
// Type-assert to []byte or base64-decode from string
|
||||
enc := toBytes(encRaw)
|
||||
nonce := toBytes(nonceRaw)
|
||||
if len(enc) > 0 {
|
||||
plaintext, err := h.vault.Decrypt(enc, nonce, scope, ownerID)
|
||||
if err == nil {
|
||||
out[k] = plaintext
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toBytes coerces an interface{} to []byte. Handles both []byte (from Go)
|
||||
// and string (from JSON unmarshal of base64).
|
||||
func toBytes(v interface{}) []byte {
|
||||
switch t := v.(type) {
|
||||
case []byte:
|
||||
return t
|
||||
case string:
|
||||
return []byte(t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Response masking ───────────────────────────────
|
||||
|
||||
// maskConnectionSecrets returns safe connection summaries (no secret values).
|
||||
func maskConnectionSecrets(conns []models.ExtConnection) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, len(conns))
|
||||
for i, c := range conns {
|
||||
out[i] = map[string]interface{}{
|
||||
"id": c.ID,
|
||||
"type": c.Type,
|
||||
"package_id": c.PackageID,
|
||||
"scope": c.Scope,
|
||||
"name": c.Name,
|
||||
"is_active": c.IsActive,
|
||||
"has_config": len(c.Config) > 0,
|
||||
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maskSingleConnection(c *models.ExtConnection) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": c.ID,
|
||||
"type": c.Type,
|
||||
"package_id": c.PackageID,
|
||||
"scope": c.Scope,
|
||||
"name": c.Name,
|
||||
"is_active": c.IsActive,
|
||||
"has_config": len(c.Config) > 0,
|
||||
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
118
server/handlers/team_connections.go
Normal file
118
server/handlers/team_connections.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package handlers
|
||||
|
||||
// v0.38.1: Extension connection handlers — team scope.
|
||||
// Methods on TeamHandler, mirrors team_providers.go.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ListTeamConnections returns connections scoped to a team.
|
||||
func (h *TeamHandler) ListTeamConnections(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
conns, err := h.stores.Connections.ListForTeam(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team connections"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
|
||||
}
|
||||
|
||||
// CreateTeamConnection creates a connection scoped to a team.
|
||||
func (h *TeamHandler) CreateTeamConnection(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Config models.JSONMap `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Use ConnectionHandler helpers for encryption
|
||||
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||
secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type)
|
||||
encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID)
|
||||
|
||||
conn := &models.ExtConnection{
|
||||
Type: req.Type,
|
||||
PackageID: req.PackageID,
|
||||
Scope: models.ScopeTeam,
|
||||
OwnerID: teamID,
|
||||
Name: req.Name,
|
||||
Config: encConfig,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": conn.ID,
|
||||
"type": conn.Type,
|
||||
"name": conn.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTeamConnection updates a team-scoped connection.
|
||||
func (h *TeamHandler) UpdateTeamConnection(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||
if err != nil || existing.Scope != models.ScopeTeam || existing.OwnerID != teamID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Config models.JSONMap `json:"config,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
patch := models.ExtConnectionPatch{Name: req.Name}
|
||||
if req.Config != nil {
|
||||
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||
secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type)
|
||||
patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID)
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
|
||||
}
|
||||
|
||||
// DeleteTeamConnection deletes a team-scoped connection.
|
||||
func (h *TeamHandler) DeleteTeamConnection(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeTeam, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
|
||||
}
|
||||
@@ -408,6 +408,8 @@ func main() {
|
||||
)
|
||||
// v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig
|
||||
starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
|
||||
// v0.38.1: connections module — extension connection resolution
|
||||
starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver))
|
||||
// v0.29.2: db module — extension namespaced table access
|
||||
starlarkRunner.SetDB(database.DB, database.IsPostgres())
|
||||
// v0.38.0: disk-based script loading + load() support
|
||||
@@ -812,6 +814,15 @@ func main() {
|
||||
protected.GET("/api-configs/:id/models", provCfg.ListModels)
|
||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||
|
||||
// Extension Connections (personal scope — v0.38.1)
|
||||
connH := handlers.NewConnectionHandler(stores, keyResolver)
|
||||
protected.GET("/connections", connH.ListConnections)
|
||||
protected.POST("/connections", connH.CreateConnection)
|
||||
protected.GET("/connections/resolve", connH.ResolveConnection)
|
||||
protected.GET("/connections/:id", connH.GetConnection)
|
||||
protected.PUT("/connections/:id", connH.UpdateConnection)
|
||||
protected.DELETE("/connections/:id", connH.DeleteConnection)
|
||||
|
||||
// Models (unified resolver — replaces scattered endpoints)
|
||||
modelH := handlers.NewModelHandler(stores)
|
||||
if healthStore != nil {
|
||||
@@ -1044,6 +1055,12 @@ func main() {
|
||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||
|
||||
// Team connections (v0.38.1)
|
||||
teamScoped.GET("/connections", teams.ListTeamConnections)
|
||||
teamScoped.POST("/connections", teams.CreateTeamConnection)
|
||||
teamScoped.PUT("/connections/:id", teams.UpdateTeamConnection)
|
||||
teamScoped.DELETE("/connections/:id", teams.DeleteTeamConnection)
|
||||
|
||||
// Team package management (v0.30.0)
|
||||
teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
|
||||
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
||||
@@ -1164,6 +1181,12 @@ func main() {
|
||||
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
|
||||
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
||||
|
||||
// Global Connections (v0.38.1)
|
||||
admin.GET("/connections", adm.ListGlobalConnections)
|
||||
admin.POST("/connections", adm.CreateGlobalConnection)
|
||||
admin.PUT("/connections/:id", adm.UpdateGlobalConnection)
|
||||
admin.DELETE("/connections/:id", adm.DeleteGlobalConnection)
|
||||
|
||||
// Model Catalog
|
||||
admin.GET("/models", adm.ListModelConfigs)
|
||||
admin.POST("/models/fetch", adm.FetchModels)
|
||||
|
||||
24
server/models/ext_connection.go
Normal file
24
server/models/ext_connection.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
// ── Extension Connections (v0.38.1) ──────────────────
|
||||
|
||||
// ExtConnection represents a scoped credential/endpoint configuration
|
||||
// for extensions that integrate with external services. Same scope
|
||||
// hierarchy as ProviderConfig: global → team → personal.
|
||||
type ExtConnection struct {
|
||||
BaseModel
|
||||
Type string `json:"type" db:"type"`
|
||||
PackageID string `json:"package_id" db:"package_id"`
|
||||
Scope string `json:"scope" db:"scope"`
|
||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Config JSONMap `json:"config" db:"config"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
}
|
||||
|
||||
// ExtConnectionPatch carries partial updates. Nil fields are skipped.
|
||||
type ExtConnectionPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Config JSONMap `json:"config,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const (
|
||||
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
||||
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
|
||||
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
|
||||
ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution
|
||||
)
|
||||
|
||||
// ValidExtensionPermissions is the set of recognized permission keys.
|
||||
@@ -48,6 +49,7 @@ var ValidExtensionPermissions = map[string]bool{
|
||||
ExtPermProviderComplete: true,
|
||||
ExtPermFormValidate: true,
|
||||
ExtPermWorkflowAccess: true,
|
||||
ExtPermConnectionsRead: true,
|
||||
}
|
||||
|
||||
// ── Extension Permission Model ───────────────
|
||||
|
||||
107
server/sandbox/connections_module.go
Normal file
107
server/sandbox/connections_module.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package sandbox — connections_module.go
|
||||
//
|
||||
// v0.38.1: Connections module for Starlark extensions.
|
||||
// Requires permission: connections.read
|
||||
//
|
||||
// Starlark API:
|
||||
// conn = connections.get("gitea") → dict or None
|
||||
// conn = connections.get("gitea", name="Work Gitea") → dict or None
|
||||
// all = connections.list("gitea") → list of dicts
|
||||
//
|
||||
// Each returned dict contains the connection's config fields with
|
||||
// secrets decrypted. The module delegates to a ConnectionResolver
|
||||
// interface so the runner stays agnostic to the vault.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ConnectionResolver resolves extension connections with secret decryption.
|
||||
// Implemented by the handler layer which owns the vault.
|
||||
type ConnectionResolver interface {
|
||||
// Resolve returns a single connection's decrypted config via the
|
||||
// scope chain (personal → team → global). Empty name = first match.
|
||||
Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error)
|
||||
|
||||
// ResolveAll returns all accessible connections of a type with
|
||||
// decrypted configs.
|
||||
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
|
||||
}
|
||||
|
||||
// BuildConnectionsModule creates the "connections" module.
|
||||
func BuildConnectionsModule(ctx context.Context, resolver ConnectionResolver, userID string) *starlarkstruct.Module {
|
||||
return MakeModule("connections", starlark.StringDict{
|
||||
"get": starlark.NewBuiltin("connections.get", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var connType string
|
||||
var name string
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"type", &connType,
|
||||
"name?", &name,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := resolver.Resolve(ctx, userID, connType, name)
|
||||
if err != nil || conn == nil {
|
||||
return starlark.None, nil
|
||||
}
|
||||
return connectionToDict(conn), nil
|
||||
}),
|
||||
|
||||
"list": starlark.NewBuiltin("connections.list", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var connType string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &connType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conns, err := resolver.ResolveAll(ctx, userID, connType)
|
||||
if err != nil {
|
||||
return starlark.NewList(nil), nil
|
||||
}
|
||||
|
||||
elems := make([]starlark.Value, len(conns))
|
||||
for i := range conns {
|
||||
elems[i] = connectionToDict(&conns[i])
|
||||
}
|
||||
return starlark.NewList(elems), nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// connectionToDict converts an ExtConnection to a Starlark dict.
|
||||
// Config fields are flattened into the top-level dict alongside id/name/type.
|
||||
func connectionToDict(conn *models.ExtConnection) *starlark.Dict {
|
||||
d := starlark.NewDict(4 + len(conn.Config))
|
||||
d.SetKey(starlark.String("id"), starlark.String(conn.ID))
|
||||
d.SetKey(starlark.String("type"), starlark.String(conn.Type))
|
||||
d.SetKey(starlark.String("name"), starlark.String(conn.Name))
|
||||
d.SetKey(starlark.String("scope"), starlark.String(conn.Scope))
|
||||
|
||||
for k, v := range conn.Config {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
d.SetKey(starlark.String(k), starlark.String(val))
|
||||
case float64:
|
||||
d.SetKey(starlark.String(k), starlark.Float(val))
|
||||
case bool:
|
||||
d.SetKey(starlark.String(k), starlark.Bool(val))
|
||||
default:
|
||||
// Fallback: coerce to string
|
||||
d.SetKey(starlark.String(k), starlark.String(""))
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -50,9 +50,10 @@ type Runner struct {
|
||||
stores store.Stores
|
||||
packagesDir string // v0.38.0: disk path for load() support
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
@@ -73,6 +74,11 @@ func (r *Runner) SetProviderResolver(pr ProviderResolver) {
|
||||
r.resolver = pr
|
||||
}
|
||||
|
||||
// SetConnectionResolver attaches the connection resolver for the connections module (v0.38.1).
|
||||
func (r *Runner) SetConnectionResolver(cr ConnectionResolver) {
|
||||
r.connResolver = cr
|
||||
}
|
||||
|
||||
// SetDB attaches the raw database connection for the db module.
|
||||
// isPostgres controls whether $N or ? placeholders are used.
|
||||
// Call this at startup after database.Connect().
|
||||
@@ -300,6 +306,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
||||
|
||||
case models.ExtPermWorkflowAccess:
|
||||
modules["workflow"] = BuildWorkflowModule(ctx, r.stores)
|
||||
|
||||
case models.ExtPermConnectionsRead:
|
||||
if r.connResolver != nil && rc != nil && rc.UserID != "" {
|
||||
modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8820,6 +8820,213 @@ paths:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
# ── Extension Connections (v0.38.1) ─────────────────────────────────
|
||||
/api/v1/connections:
|
||||
get:
|
||||
tags: [Extensions]
|
||||
summary: List personal connections
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Personal connections (secrets masked)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExtConnection'
|
||||
post:
|
||||
tags: [Extensions]
|
||||
summary: Create personal connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [type, package_id, name]
|
||||
properties:
|
||||
type: { type: string }
|
||||
package_id: { type: string }
|
||||
name: { type: string }
|
||||
config: { type: object }
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/api/v1/connections/{id}:
|
||||
get:
|
||||
tags: [Extensions]
|
||||
summary: Get personal connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Connection (secrets masked)
|
||||
put:
|
||||
tags: [Extensions]
|
||||
summary: Update personal connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
config: { type: object }
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
delete:
|
||||
tags: [Extensions]
|
||||
summary: Delete personal connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted
|
||||
/api/v1/connections/resolve:
|
||||
get:
|
||||
tags: [Extensions]
|
||||
summary: Resolve connection via scope chain (personal > team > global)
|
||||
description: |
|
||||
Returns a single connection with decrypted config, resolved via
|
||||
the scope chain. Requires `type` query parameter. Optional `name`
|
||||
for named resolution.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: type
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: name
|
||||
in: query
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Resolved connection with decrypted config
|
||||
'404':
|
||||
description: No matching connection found
|
||||
/api/v1/teams/{teamId}/connections:
|
||||
get:
|
||||
tags: [Teams, Extensions]
|
||||
summary: List team connections
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TeamID'
|
||||
responses:
|
||||
'200':
|
||||
description: Team connections
|
||||
post:
|
||||
tags: [Teams, Extensions]
|
||||
summary: Create team connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TeamID'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [type, package_id, name]
|
||||
properties:
|
||||
type: { type: string }
|
||||
package_id: { type: string }
|
||||
name: { type: string }
|
||||
config: { type: object }
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/api/v1/teams/{teamId}/connections/{id}:
|
||||
put:
|
||||
tags: [Teams, Extensions]
|
||||
summary: Update team connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TeamID'
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
delete:
|
||||
tags: [Teams, Extensions]
|
||||
summary: Delete team connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TeamID'
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted
|
||||
/api/v1/admin/connections:
|
||||
get:
|
||||
tags: [Admin, Extensions]
|
||||
summary: List global connections
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Global connections
|
||||
post:
|
||||
tags: [Admin, Extensions]
|
||||
summary: Create global connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [type, package_id, name]
|
||||
properties:
|
||||
type: { type: string }
|
||||
package_id: { type: string }
|
||||
name: { type: string }
|
||||
config: { type: object }
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/api/v1/admin/connections/{id}:
|
||||
put:
|
||||
tags: [Admin, Extensions]
|
||||
summary: Update global connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
delete:
|
||||
tags: [Admin, Extensions]
|
||||
summary: Delete global connection
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted
|
||||
/api/v1/admin/workflows/{id}/export:
|
||||
get:
|
||||
tags:
|
||||
@@ -12059,6 +12266,36 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
ExtConnection:
|
||||
type: object
|
||||
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
description: Connection type identifier (e.g. "gitea", "github")
|
||||
package_id:
|
||||
type: string
|
||||
description: Declaring package ID (UI attribution)
|
||||
scope:
|
||||
type: string
|
||||
enum: [global, team, personal]
|
||||
name:
|
||||
type: string
|
||||
description: User label (e.g. "Work Gitea")
|
||||
is_active:
|
||||
type: boolean
|
||||
has_config:
|
||||
type: boolean
|
||||
description: Whether config fields are set (secrets never exposed in list)
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
Extension:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -65,6 +65,7 @@ type Stores struct {
|
||||
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod)
|
||||
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
|
||||
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
|
||||
Connections ConnectionStore // v0.38.1: Extension connection credentials
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -1366,6 +1367,35 @@ type ExportStore interface {
|
||||
|
||||
// =========================================
|
||||
// SHARED TYPES
|
||||
// =========================================
|
||||
// CONNECTION STORE (v0.38.1)
|
||||
// =========================================
|
||||
|
||||
type ConnectionStore interface {
|
||||
Create(ctx context.Context, conn *models.ExtConnection) error
|
||||
GetByID(ctx context.Context, id string) (*models.ExtConnection, error)
|
||||
Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Scoped queries
|
||||
ListGlobal(ctx context.Context) ([]models.ExtConnection, error)
|
||||
ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error)
|
||||
ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error)
|
||||
|
||||
// Resolution chain: personal → team → global.
|
||||
// Returns first active connection matching type (and optional name).
|
||||
// Empty name means "first match". Returns sql.ErrNoRows if not found.
|
||||
Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error)
|
||||
|
||||
// ResolveAll returns all active connections of the given type accessible
|
||||
// to the user (personal + team + global).
|
||||
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
|
||||
|
||||
// DeleteByIDAndScope deletes a connection only if it matches the given
|
||||
// scope and owner. Returns rows affected (0 or 1).
|
||||
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
// ListOptions provides standard pagination/sort for list queries.
|
||||
|
||||
182
server/store/postgres/ext_connection.go
Normal file
182
server/store/postgres/ext_connection.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── ConnectionStore (v0.38.1) ────────────────────────
|
||||
|
||||
type ConnectionStore struct{}
|
||||
|
||||
func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} }
|
||||
|
||||
const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at`
|
||||
|
||||
func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO ext_connections (type, package_id, scope, owner_id, name, config, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
conn.Type, conn.PackageID, conn.Scope, conn.OwnerID,
|
||||
conn.Name, ToJSON(conn.Config), conn.IsActive,
|
||||
).Scan(&conn.ID, &conn.CreatedAt, &conn.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = $1", connCols), id)
|
||||
return scanConnection(row)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error {
|
||||
b := NewUpdate("ext_connections")
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Config != nil {
|
||||
b.SetJSON("config", patch.Config)
|
||||
}
|
||||
if patch.IsActive != nil {
|
||||
b.Set("is_active", *patch.IsActive)
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
}
|
||||
b.Where("id", id)
|
||||
_, err := b.Exec(DB)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Scoped Queries ─────────────────────────────────
|
||||
|
||||
func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) {
|
||||
return s.listByScope(ctx, models.ScopeGlobal, "")
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) {
|
||||
return s.listByScope(ctx, models.ScopeTeam, teamID)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) {
|
||||
return s.listByScope(ctx, models.ScopePersonal, userID)
|
||||
}
|
||||
|
||||
// ── Resolution ─────────────────────────────────────
|
||||
|
||||
// Resolve returns the first active connection matching type (and optional name)
|
||||
// via the scope chain: personal → team → global.
|
||||
func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
|
||||
q := fmt.Sprintf(`
|
||||
SELECT %s FROM ext_connections
|
||||
WHERE type = $1 AND is_active = true
|
||||
AND (
|
||||
(scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $2
|
||||
))
|
||||
OR (scope = 'global')
|
||||
)`, connCols)
|
||||
args := []any{connType, userID}
|
||||
if name != "" {
|
||||
q += " AND name = $3"
|
||||
args = append(args, name)
|
||||
}
|
||||
// Priority: personal(0) → team(1) → global(2).
|
||||
q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1"
|
||||
row := DB.QueryRowContext(ctx, q, args...)
|
||||
return scanConnection(row)
|
||||
}
|
||||
|
||||
// ResolveAll returns all active connections of the given type accessible to the user.
|
||||
func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
|
||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM ext_connections
|
||||
WHERE type = $1 AND is_active = true
|
||||
AND (
|
||||
(scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $2
|
||||
))
|
||||
OR (scope = 'global')
|
||||
)
|
||||
ORDER BY scope ASC, name ASC`, connCols), connType, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
// DeleteByIDAndScope deletes a connection only if it matches the scope/owner.
|
||||
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM ext_connections WHERE id = $1 AND scope = $2 AND owner_id = $3`,
|
||||
id, scope, ownerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────
|
||||
|
||||
func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if scope == models.ScopeGlobal {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 ORDER BY type, name", connCols),
|
||||
scope)
|
||||
} else {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 AND owner_id = $2 ORDER BY type, name", connCols),
|
||||
scope, ownerID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
func scanConnection(row *sql.Row) (*models.ExtConnection, error) {
|
||||
var c models.ExtConnection
|
||||
var configJSON []byte
|
||||
err := row.Scan(
|
||||
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||
&c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(configJSON, &c.Config)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) {
|
||||
var result []models.ExtConnection
|
||||
for rows.Next() {
|
||||
var c models.ExtConnection
|
||||
var configJSON []byte
|
||||
err := rows.Scan(
|
||||
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||
&c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(configJSON, &c.Config)
|
||||
result = append(result, c)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Tickets: NewTicketStore(),
|
||||
RateLimits: NewRateLimitStore(),
|
||||
Export: NewExportStore(),
|
||||
Connections: NewConnectionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
186
server/store/sqlite/ext_connection.go
Normal file
186
server/store/sqlite/ext_connection.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── ConnectionStore (v0.38.1) ────────────────────────
|
||||
|
||||
type ConnectionStore struct{}
|
||||
|
||||
func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} }
|
||||
|
||||
const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at`
|
||||
|
||||
func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error {
|
||||
conn.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
conn.CreatedAt = now
|
||||
conn.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO ext_connections (id, type, package_id, scope, owner_id, name, config, is_active,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
conn.ID, conn.Type, conn.PackageID, conn.Scope, conn.OwnerID,
|
||||
conn.Name, ToJSON(conn.Config), conn.IsActive,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = ?", connCols), id)
|
||||
return scanConnection(row)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error {
|
||||
b := NewUpdate("ext_connections")
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Config != nil {
|
||||
b.SetJSON("config", patch.Config)
|
||||
}
|
||||
if patch.IsActive != nil {
|
||||
b.Set("is_active", *patch.IsActive)
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
}
|
||||
b.Where("id", id)
|
||||
_, err := b.Exec(DB)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Scoped Queries ─────────────────────────────────
|
||||
|
||||
func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) {
|
||||
return s.listByScope(ctx, models.ScopeGlobal, "")
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) {
|
||||
return s.listByScope(ctx, models.ScopeTeam, teamID)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) {
|
||||
return s.listByScope(ctx, models.ScopePersonal, userID)
|
||||
}
|
||||
|
||||
// ── Resolution ─────────────────────────────────────
|
||||
|
||||
func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
|
||||
q := fmt.Sprintf(`
|
||||
SELECT %s FROM ext_connections
|
||||
WHERE type = ? AND is_active = 1
|
||||
AND (
|
||||
(scope = 'personal' AND owner_id = ?)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = ?
|
||||
))
|
||||
OR (scope = 'global')
|
||||
)`, connCols)
|
||||
args := []any{connType, userID, userID}
|
||||
if name != "" {
|
||||
q += " AND name = ?"
|
||||
args = append(args, name)
|
||||
}
|
||||
// Priority: personal(0) → team(1) → global(2).
|
||||
q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1"
|
||||
row := DB.QueryRowContext(ctx, q, args...)
|
||||
return scanConnection(row)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
|
||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM ext_connections
|
||||
WHERE type = ? AND is_active = 1
|
||||
AND (
|
||||
(scope = 'personal' AND owner_id = ?)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = ?
|
||||
))
|
||||
OR (scope = 'global')
|
||||
)
|
||||
ORDER BY scope ASC, name ASC`, connCols), connType, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`,
|
||||
id, scope, ownerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────
|
||||
|
||||
func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if scope == models.ScopeGlobal {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? ORDER BY type, name", connCols),
|
||||
scope)
|
||||
} else {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? AND owner_id = ? ORDER BY type, name", connCols),
|
||||
scope, ownerID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
func scanConnection(row *sql.Row) (*models.ExtConnection, error) {
|
||||
var c models.ExtConnection
|
||||
var configJSON []byte
|
||||
err := row.Scan(
|
||||
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||
&c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(configJSON, &c.Config)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) {
|
||||
var result []models.ExtConnection
|
||||
for rows.Next() {
|
||||
var c models.ExtConnection
|
||||
var configJSON []byte
|
||||
err := rows.Scan(
|
||||
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||
&c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(configJSON, &c.Config)
|
||||
result = append(result, c)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Tickets: NewTicketStore(),
|
||||
RateLimits: NewRateLimitStore(),
|
||||
Export: NewExportStore(),
|
||||
Connections: NewConnectionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user