feat(channels): add Shangwutong connector

This commit is contained in:
2026-08-03 10:13:40 +08:00
parent 96f5c796a6
commit 897b4e018f
126 changed files with 20207 additions and 272 deletions
@@ -31,6 +31,14 @@ import (
// router.Use(AuthRequired(jwtSvc), AccountScope())
func AccountScope() gin.HandlerFunc {
return func(c *gin.Context) {
if IsConnectorService(c) {
if authorized, _ := c.Get("connector_account_authorized"); authorized != true {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Connector does not have access to this account")
return
}
c.Next()
return
}
// Step 1: Get user_id from JWT claims (set by AuthRequired)
userID, exists := c.Get("user_id")
if !exists {
@@ -111,6 +119,14 @@ func AccountScope() gin.HandlerFunc {
// router.Use(AuthRequired(jwtSvc), AccountScopeWithService(rbacSvc))
func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc {
return func(c *gin.Context) {
if IsConnectorService(c) {
if authorized, _ := c.Get("connector_account_authorized"); authorized != true {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Connector does not have access to this account")
return
}
c.Next()
return
}
userID, exists := c.Get("user_id")
if !exists {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized,
@@ -0,0 +1,144 @@
package middleware
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
const ConnectorPlatformAppIDKey = "connector_platform_app_id"
func ConnectorServiceAuth(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
setConnectorRequestID(c)
app, accessToken, ok := authenticateShangwutongConnector(c, db)
if !ok {
connectorAuthError(c)
return
}
setConnectorPrincipal(c, db, app, accessToken)
c.Next()
}
}
func AuthMiddlewareWithConnectorAllowlist(jwtService *auth.JWTService, db *gorm.DB) gin.HandlerFunc {
userAuth := AuthMiddlewareWithServiceAndDB(jwtService, db)
return func(c *gin.Context) {
if connectorApplicationRoute(c) {
setConnectorRequestID(c)
if app, accessToken, ok := authenticateShangwutongConnector(c, db); ok {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil || accountID == 0 {
connectorPermissionError(c)
return
}
var count int64
if err := db.WithContext(c.Request.Context()).Model(&model.Permissible{}).Where(
"platform_app_id = ? AND permissible_type = ? AND permissible_id = ?", app.ID, model.PermissibleTypeAccount, uint(accountID),
).Count(&count).Error; err != nil || count == 0 {
connectorPermissionError(c)
return
}
setConnectorPrincipal(c, db, app, accessToken)
c.Set("connector_account_authorized", true)
c.Set("account_id", uint(accountID))
c.Next()
return
}
}
userAuth(c)
}
}
func connectorApplicationRoute(c *gin.Context) bool {
switch c.FullPath() {
case "/api/v1/accounts/:account_id/conversations/:conversation_id/messages":
return c.Request.Method == http.MethodPost
case "/api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes",
"/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_status":
return c.Request.Method == http.MethodPost
case "/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id":
return c.Request.Method == http.MethodDelete
default:
return false
}
}
func authenticateShangwutongConnector(c *gin.Context, db *gorm.DB) (*model.PlatformApp, *model.AccessToken, bool) {
parts := strings.Fields(c.GetHeader("Authorization"))
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" {
return nil, nil, false
}
hash := sha256.Sum256([]byte(parts[1]))
var accessToken model.AccessToken
if err := db.WithContext(c.Request.Context()).Where(
"token = ? AND owner_type = ?", hex.EncodeToString(hash[:]), model.AccessTokenOwnerTypePlatformApp,
).First(&accessToken).Error; err != nil {
return nil, nil, false
}
var app model.PlatformApp
if err := db.WithContext(c.Request.Context()).First(&app, accessToken.OwnerID).Error; err != nil || !app.IsActive() || !isShangwutongConnectorApp(&app) {
return nil, nil, false
}
return &app, &accessToken, true
}
func setConnectorPrincipal(c *gin.Context, db *gorm.DB, app *model.PlatformApp, accessToken *model.AccessToken) {
c.Set(ConnectorPlatformAppIDKey, app.ID)
c.Set("connector_service_principal", *app)
_ = db.WithContext(c.Request.Context()).Model(&model.AccessToken{}).Where("id = ?", accessToken.ID).Update("last_used_at", gorm.Expr("CURRENT_TIMESTAMP")).Error
}
func setConnectorRequestID(c *gin.Context) {
requestID := strings.TrimSpace(c.GetHeader("X-Request-ID"))
if requestID == "" {
requestID = uuid.NewString()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
}
func ConnectorPlatformAppID(c *gin.Context) uint {
value, _ := c.Get(ConnectorPlatformAppIDKey)
id, _ := value.(uint)
return id
}
func IsConnectorService(c *gin.Context) bool {
return ConnectorPlatformAppID(c) != 0
}
func isShangwutongConnectorApp(app *model.PlatformApp) bool {
if app == nil || app.Type != "integration" {
return false
}
config := map[string]any{}
if len(app.Config) == 0 || json.Unmarshal(app.Config, &config) != nil {
return false
}
connector, _ := config["connector"].(string)
return connector == "shangwutong"
}
func connectorAuthError(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{
"code": "unauthorized", "message": "connector authentication failed", "retryable": false,
"request_id": c.GetString("request_id"),
}})
}
func connectorPermissionError(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": gin.H{
"code": "forbidden", "message": "connector account access denied", "retryable": false,
"request_id": c.GetString("request_id"),
}})
}
@@ -0,0 +1,57 @@
package middleware
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/model"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestConnectorServiceTokenIsLimitedToApplicationAllowlistAndAccountGrant(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.PlatformApp{}, &model.AccessToken{}, &model.Permissible{}))
active := true
app := &model.PlatformApp{Name: "SWT", Type: "integration", Status: "active", Active: &active, Config: json.RawMessage(`{"connector":"shangwutong"}`)}
require.NoError(t, db.Create(app).Error)
token := "gochat_pa_connector_allowlist"
digest := sha256.Sum256([]byte(token))
require.NoError(t, db.Create(&model.AccessToken{
OwnerType: model.AccessTokenOwnerTypePlatformApp, OwnerID: app.ID,
Token: hex.EncodeToString(digest[:]), TokenPrefix: token[:8], Name: "connector",
}).Error)
require.NoError(t, db.Create(&model.Permissible{
PlatformAppID: app.ID, PermissibleType: model.PermissibleTypeAccount, PermissibleID: 1,
}).Error)
router := gin.New()
api := router.Group("/api/v1")
api.Use(AuthMiddlewareWithConnectorAllowlist(auth.NewJWTService(makeJWTConfig()), db))
accounts := api.Group("/accounts")
accounts.Use(AccountScope())
accounts.POST("/:account_id/conversations/:conversation_id/messages", func(c *gin.Context) { c.Status(http.StatusOK) })
accounts.GET("/:account_id/conversations/:conversation_id/messages", func(c *gin.Context) { c.Status(http.StatusOK) })
accounts.POST("/:account_id/inboxes", func(c *gin.Context) { c.Status(http.StatusOK) })
request := func(method, path string) int {
response := httptest.NewRecorder()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(response, req)
return response.Code
}
require.Equal(t, http.StatusOK, request(http.MethodPost, "/api/v1/accounts/1/conversations/2/messages"))
require.Equal(t, http.StatusForbidden, request(http.MethodPost, "/api/v1/accounts/2/conversations/2/messages"))
require.Equal(t, http.StatusUnauthorized, request(http.MethodGet, "/api/v1/accounts/1/conversations/2/messages"))
require.Equal(t, http.StatusUnauthorized, request(http.MethodPost, "/api/v1/accounts/1/inboxes"))
}