HH-548: allow Super Admin sessions to load platform lists (#125)

* fix(HH-548): authorize super admin platform lists

* fix(HH-548): limit dual auth to platform lists

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-23 20:21:05 +08:00
committed by GitHub
co-authored by rogee
parent eb83d241fe
commit 8d5d019bb5
8 changed files with 279 additions and 6 deletions
@@ -41,9 +41,18 @@ func NewPlatformAccountHandler(
// GET /platform/api/v1/accounts
// Reference: Chatwoot Platform::Api::V1::AccountsController#index
func (h *PlatformAccountHandler) List(c *gin.Context) {
platformAppID := getPlatformAppID(c)
page := pagination.Parse(c)
if c.GetBool("is_super_admin") {
accounts, total, err := h.accountRepo.FindAll(c.Request.Context(), page.Offset, page.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
return
}
response.OKWithMeta(c, accounts, page.Page, page.PerPage, total)
return
}
platformAppID := getPlatformAppID(c)
permissibles, err := h.permissibleRepo.FindByPlatformAppID(c.Request.Context(), platformAppID)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
@@ -294,6 +294,35 @@ func TestPlatformUserE2E_List(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
}
func TestPlatformSuperAdminListsAllAccountsAndUsers(t *testing.T) {
db := testutil.NewTestDBWithModels(t, &model.Account{}, &model.User{}, &model.Permissible{})
accountRepo := repository.NewAccountRepo(db)
userRepo := repository.NewUserRepo(db)
permissibleRepo := repository.NewPermissibleRepo(db)
accountHandler := v1.NewPlatformAccountHandler(accountRepo, permissibleRepo, service.NewAccountService(accountRepo))
userHandler := v1.NewPlatformUserHandler(service.NewPlatformUserService(userRepo, permissibleRepo))
require.NoError(t, accountRepo.Create(t.Context(), &model.Account{Name: "Global Account"}))
require.NoError(t, userRepo.Create(t.Context(), &model.User{Name: "Global User", Email: "global@example.com"}))
engine := gin.New()
engine.Use(func(c *gin.Context) { c.Set("is_super_admin", true); c.Next() })
engine.GET("/platform/api/v1/accounts", accountHandler.List)
engine.GET("/platform/api/v1/users", userHandler.List)
for _, path := range []string{"/platform/api/v1/accounts", "/platform/api/v1/users"} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
engine.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var envelope struct {
Data []json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
require.Len(t, envelope.Data, 1)
}
}
// --- Platform Account E2E Tests ---
func TestPlatformAccountE2E_Create(t *testing.T) {
@@ -275,9 +275,18 @@ func handlePlatformError(c *gin.Context, err error) {
// GET /platform/api/v1/users
// Reference: Chatwoot Platform::Api::V1::UsersController#index (lists permissibles)
func (h *PlatformUserHandler) List(c *gin.Context) {
platformAppID := getPlatformAppID(c)
page := pagination.Parse(c)
if c.GetBool("is_super_admin") {
users, total, err := h.svc.ListUsers(c.Request.Context(), page.Offset, page.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
return
}
response.OKWithMeta(c, users, page.Page, page.PerPage, total)
return
}
platformAppID := getPlatformAppID(c)
users, err := h.svc.ListPermissibleUsers(c.Request.Context(), platformAppID)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
@@ -16,15 +16,45 @@ import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/pkg/response"
"gorm.io/gorm"
)
// PlatformAPIIdentityAuth accepts either a PlatformApp credential or the
// signed dashboard session used by GoChat's Super Admin SPA. An explicitly
// supplied PlatformApp credential never falls back to a user session.
func PlatformAPIIdentityAuth(jwtSvc *auth.JWTService, db *gorm.DB) gin.HandlerFunc {
platformAppAuth := PlatformAppAuth(db)
userAuth := AuthMiddlewareWithServiceAndDB(jwtSvc, db)
return func(c *gin.Context) {
if strings.TrimSpace(c.GetHeader("api_access_token")) != "" || strings.TrimSpace(c.GetHeader("HTTP_API_ACCESS_TOKEN")) != "" {
platformAppAuth(c)
return
}
userAuth(c)
}
}
// PlatformAPIAuthorize limits user sessions to Super Admins while preserving
// the existing PlatformApp authentication and permissible checks.
func PlatformAPIAuthorize() gin.HandlerFunc {
superAdmin := SuperAdmin()
return func(c *gin.Context) {
if _, ok := c.Get("platform_app_id"); ok {
c.Next()
return
}
superAdmin(c)
}
}
// PlatformAppAuth creates a middleware that authenticates Platform API requests
// via the api_access_token header (Chatwoot-compatible).
//
@@ -12,6 +12,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/pkg/testutil"
"gorm.io/gorm"
@@ -21,6 +23,7 @@ import (
func newPlatformTestDB(t *testing.T) *gorm.DB {
t.Helper()
return testutil.NewTestDBWithModels(t,
&model.User{},
&model.PlatformApp{},
&model.AccessToken{},
&model.Permissible{},
@@ -271,4 +274,53 @@ func TestPlatformAppAuth_ContextValues(t *testing.T) {
assert.Equal(t, 200, w.Code)
}
func TestPlatformAPIAuthSupportsSuperAdminAndPlatformApp(t *testing.T) {
db := newPlatformTestDB(t)
pa := model.PlatformApp{Name: "TestApp", Active: ptrBool(true)}
require.NoError(t, db.Create(&pa).Error)
rawToken := "platform-api-token"
hash := sha256.Sum256([]byte(rawToken))
require.NoError(t, db.Create(&model.AccessToken{
OwnerType: model.AccessTokenOwnerTypePlatformApp,
OwnerID: pa.ID,
Token: hex.EncodeToString(hash[:]),
TokenPrefix: rawToken[:8],
}).Error)
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "platform-api-auth-test", ExpiryHours: 1})
superAdmin := model.User{Provider: "email", Email: "admin@example.com", Role: "super_admin", Active: true}
require.NoError(t, db.Create(&superAdmin).Error)
agent := model.User{Provider: "email", Email: "agent@example.com", Active: true}
require.NoError(t, db.Create(&agent).Error)
superAdminToken, err := jwtSvc.GenerateTokenPair(&superAdmin, 1, "administrator")
require.NoError(t, err)
agentToken, err := jwtSvc.GenerateTokenPair(&agent, 1, "agent")
require.NoError(t, err)
router := gin.New()
router.Use(PlatformAPIIdentityAuth(jwtSvc, db), PlatformAPIAuthorize())
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
for _, tc := range []struct {
name, header, token string
want int
}{
{name: "platform app", header: "api_access_token", token: rawToken, want: http.StatusOK},
{name: "super admin session", header: "access-token", token: superAdminToken.AccessToken, want: http.StatusOK},
{name: "regular session", header: "access-token", token: agentToken.AccessToken, want: http.StatusForbidden},
{name: "missing credentials", want: http.StatusUnauthorized},
} {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
if tc.header != "" {
req.Header.Set(tc.header, tc.token)
}
router.ServeHTTP(w, req)
assert.Equal(t, tc.want, w.Code)
})
}
}
func ptrBool(b bool) *bool { return &b }
+7 -4
View File
@@ -286,11 +286,16 @@ func RegisterRoutes(
enterpriseV1.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db))
registerEnterpriseRoutes(enterpriseV1, handlers)
// Platform administration uses signed super-admin identity; the Platform API
// uses a distinct PlatformApp access token, matching Chatwoot's controllers.
// Platform administration uses signed super-admin identity. GoChat's Super
// Admin SPA also reads the accounts/users Platform API, while external clients
// retain Chatwoot's distinct PlatformApp access-token authentication.
platformAdmin := engine.Group("/platform/api/v1")
platformAdmin.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db), middleware.SuperAdmin())
registerPlatformRoutes(platformAdmin, handlers)
platformLists := engine.Group("/platform/api/v1")
platformLists.Use(middleware.PlatformAPIIdentityAuth(jwtService, db), middleware.PlatformAPIAuthorize())
platformLists.GET("/users", handlers.PlatformUser.List)
platformLists.GET("/accounts", handlers.PlatformAccount.List)
platformToken := engine.Group("/platform/api/v1")
platformToken.Use(middleware.PlatformAppAuth(db))
registerPlatformTokenRoutes(platformToken, handlers)
@@ -2136,7 +2141,6 @@ func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) {
// Platform Users — AccessToken authenticated
// Reference: Chatwoot Platform::Api::V1::UsersController (AccessToken auth)
g.GET("/users", h.PlatformUser.List)
g.GET("/users/:id", h.PlatformUser.Show)
g.POST("/users", h.PlatformUser.Create)
g.GET("/users/:id/login", h.PlatformUser.Login)
@@ -2147,7 +2151,6 @@ func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) {
// Platform Accounts — AccessToken authenticated
// Reference: Chatwoot Platform::Api::V1::AccountsController (AccessToken auth)
g.GET("/accounts", h.PlatformAccount.List)
g.GET("/accounts/:account_id", h.PlatformAccount.Show)
g.POST("/accounts", h.PlatformAccount.Create)
g.PATCH("/accounts/:account_id", h.PlatformAccount.Update)
+136
View File
@@ -2,6 +2,8 @@ package router
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
@@ -173,6 +175,140 @@ func TestAccountManagementRoutesRejectUntrustedIdentityAndAgentAccess(t *testing
}
}
func TestPlatformListsAcceptSuperAdminOrPlatformAppButWritesRequirePlatformApp(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Account{}, &model.PlatformApp{}, &model.AccessToken{}, &model.Permissible{}); err != nil {
t.Fatalf("migrate: %v", err)
}
accountRepo := repository.NewAccountRepo(db)
userRepo := repository.NewUserRepo(db)
permissibleRepo := repository.NewPermissibleRepo(db)
accountHandler := v1.NewPlatformAccountHandler(accountRepo, permissibleRepo, service.NewAccountService(accountRepo))
userHandler := v1.NewPlatformUserHandler(service.NewPlatformUserService(userRepo, permissibleRepo))
accounts := []model.Account{{Name: "Permitted"}, {Name: "Hidden"}}
for i := range accounts {
requireRouterCreate(t, db, &accounts[i])
}
users := []model.User{
{Name: "Super Admin", Email: "admin@example.test", Provider: "email", Role: "super_admin", Active: true},
{Name: "Agent", Email: "agent@example.test", Provider: "email", Active: true},
{Name: "Permitted", Email: "permitted@example.test", Provider: "email", Active: true},
{Name: "Hidden", Email: "hidden@example.test", Provider: "email", Active: true},
}
for i := range users {
requireRouterCreate(t, db, &users[i])
}
active := true
platformApp := model.PlatformApp{Name: "Test App", Active: &active, Status: "active"}
requireRouterCreate(t, db, &platformApp)
rawPlatformToken := "platform-route-token"
hash := sha256.Sum256([]byte(rawPlatformToken))
requireRouterCreate(t, db, &model.AccessToken{
OwnerType: model.AccessTokenOwnerTypePlatformApp,
OwnerID: platformApp.ID,
Token: hex.EncodeToString(hash[:]),
TokenPrefix: rawPlatformToken[:8],
})
for _, permissible := range []model.Permissible{
{PlatformAppID: platformApp.ID, PermissibleType: model.PermissibleTypeAccount, PermissibleID: accounts[0].ID},
{PlatformAppID: platformApp.ID, PermissibleType: model.PermissibleTypeUser, PermissibleID: users[2].ID},
} {
requireRouterCreate(t, db, &permissible)
}
jwtCfg := &config.JWTConfig{Secret: "platform-route-secret", ExpiryHours: 1, RefreshExpiryHours: 24}
jwtSvc := auth.NewJWTService(jwtCfg)
superAdminToken, err := jwtSvc.GenerateTokenPair(&users[0], 1, "super_admin")
if err != nil {
t.Fatalf("generate super admin token: %v", err)
}
agentToken, err := jwtSvc.GenerateTokenPair(&users[1], 1, "agent")
if err != nil {
t.Fatalf("generate agent token: %v", err)
}
engine := gin.New()
RegisterRoutes(engine, jwtSvc, nil, nil, &Handlers{
PlatformAccount: accountHandler,
PlatformUser: userHandler,
}, nil, nil, jwtCfg, middleware.CORSConfig{}, db)
for _, path := range []string{"/platform/api/v1/accounts", "/platform/api/v1/users"} {
t.Run(path, func(t *testing.T) {
globalCount, permissibleID := len(users), users[2].ID
if path == "/platform/api/v1/accounts" {
globalCount, permissibleID = len(accounts), accounts[0].ID
}
for _, tc := range []struct {
name, header, token string
wantStatus int
wantCount int
wantID uint
}{
{name: "super admin", header: "access-token", token: superAdminToken.AccessToken, wantStatus: http.StatusOK, wantCount: globalCount},
{name: "platform app", header: "api_access_token", token: rawPlatformToken, wantStatus: http.StatusOK, wantCount: 1, wantID: permissibleID},
{name: "regular user", header: "access-token", token: agentToken.AccessToken, wantStatus: http.StatusForbidden},
{name: "no credentials", wantStatus: http.StatusUnauthorized},
} {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
if tc.header != "" {
req.Header.Set(tc.header, tc.token)
}
engine.ServeHTTP(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("expected %d, got %d: %s", tc.wantStatus, w.Code, w.Body.String())
}
if tc.wantStatus != http.StatusOK {
return
}
var body struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(body.Data) != tc.wantCount {
t.Fatalf("expected %d resources, got %d: %s", tc.wantCount, len(body.Data), w.Body.String())
}
if tc.wantID != 0 && body.Data[0].ID != tc.wantID {
t.Fatalf("expected permissible resource %d, got %d", tc.wantID, body.Data[0].ID)
}
})
}
})
}
var before int64
if err := db.Model(&model.Account{}).Count(&before).Error; err != nil {
t.Fatalf("count accounts: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/platform/api/v1/accounts", strings.NewReader(`{"name":"Must Not Exist"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("access-token", superAdminToken.AccessToken)
engine.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected Super Admin write to require PlatformApp auth, got %d: %s", w.Code, w.Body.String())
}
var after int64
if err := db.Model(&model.Account{}).Count(&after).Error; err != nil {
t.Fatalf("count accounts after rejected write: %v", err)
}
if after != before {
t.Fatalf("rejected Super Admin write created an account: before=%d after=%d", before, after)
}
}
func TestAPIV2LiveReportsRouterAuthAndAccountScope(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
@@ -279,3 +279,8 @@ func (s *PlatformUserService) ListPermissibleUsers(ctx context.Context, platform
}
return users, nil
}
// ListUsers returns the global user list for an authenticated Super Admin.
func (s *PlatformUserService) ListUsers(ctx context.Context, offset, limit int) ([]model.User, int64, error) {
return s.userRepo.List(ctx, offset, limit)
}