feat(channels): add Shangwutong connector
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user