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:
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user