diff --git a/backend/internal/handler/api/v1/platform_account_handler.go b/backend/internal/handler/api/v1/platform_account_handler.go index 8c696bf4..8ca8be2b 100644 --- a/backend/internal/handler/api/v1/platform_account_handler.go +++ b/backend/internal/handler/api/v1/platform_account_handler.go @@ -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()) diff --git a/backend/internal/handler/api/v1/platform_e2e_test.go b/backend/internal/handler/api/v1/platform_e2e_test.go index 7280f55f..428a7feb 100644 --- a/backend/internal/handler/api/v1/platform_e2e_test.go +++ b/backend/internal/handler/api/v1/platform_e2e_test.go @@ -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) { diff --git a/backend/internal/handler/api/v1/platform_user_handler.go b/backend/internal/handler/api/v1/platform_user_handler.go index 93ac4e0b..f5a7697c 100644 --- a/backend/internal/handler/api/v1/platform_user_handler.go +++ b/backend/internal/handler/api/v1/platform_user_handler.go @@ -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()) diff --git a/backend/internal/middleware/platform_app_auth.go b/backend/internal/middleware/platform_app_auth.go index 8bda4041..56179688 100644 --- a/backend/internal/middleware/platform_app_auth.go +++ b/backend/internal/middleware/platform_app_auth.go @@ -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). // diff --git a/backend/internal/middleware/platform_app_auth_test.go b/backend/internal/middleware/platform_app_auth_test.go index 122de052..62bcd561 100644 --- a/backend/internal/middleware/platform_app_auth_test.go +++ b/backend/internal/middleware/platform_app_auth_test.go @@ -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 } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9531180f..a8127399 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index d80aed4f..957ac582 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -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)}) diff --git a/backend/internal/service/platform_user_service.go b/backend/internal/service/platform_user_service.go index aaee3c19..8a2fad15 100644 --- a/backend/internal/service/platform_user_service.go +++ b/backend/internal/service/platform_user_service.go @@ -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) +}