diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 46b147d3..dde96634 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -905,7 +905,7 @@ func Bootstrap(env string) (*App, error) { AssignmentPolicyV2: v1.NewAssignmentPolicyV2Handler(assignmentPolicyV2Service), // Enterprise: AuditLog, CustomRole, AgentCapacityPolicy, CsatMetrics handlers Audit: v1.NewAuditHandler(auditService), - CustomRole: v1.NewCustomRoleHandler(customRoleService).WithAuditService(auditService), + CustomRole: v1.NewCustomRoleHandler(customRoleService).WithAuditService(auditService).WithEventPublisher(eventPublisher), AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService).WithAuditService(auditService), CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService), Search: v1.NewSearchHandler(searchService, db), @@ -953,7 +953,7 @@ func Bootstrap(env string) (*App, error) { Upload: uploadHandler, // Lane B: AssignableAgent handler (find agents available for assignment) AssignableAgent: v1.NewAssignableAgentHandler(assignableAgentService), - Agent: v1.NewAgentHandler(agentService).WithAuditService(auditService), + Agent: v1.NewAgentHandler(agentService).WithAuditService(auditService).WithEventPublisher(eventPublisher), AgentBulk: v1.NewAgentBulkHandler(conversationService), BulkAction: v1.NewBulkActionHandler(conversationService, contactService).WithWorkerPool(workerPool), // Lane C: CSAT template (singular per inbox) + Inbox limits diff --git a/backend/internal/auth/coverage3_test.go b/backend/internal/auth/coverage3_test.go index 4eefe7cc..e30b3ee0 100644 --- a/backend/internal/auth/coverage3_test.go +++ b/backend/internal/auth/coverage3_test.go @@ -579,7 +579,7 @@ func TestNewPolicyContext_Agent_Cov3(t *testing.T) { func TestNewPolicyContext_CustomRole_NilPerms_Cov3(t *testing.T) { pc := NewPolicyContext(1, 1, "custom_role", 0, nil) - assert.Equal(t, AgentDefaultPermissions, pc.Permissions) + assert.Equal(t, PermissionMatrixMap{}, pc.Permissions) } // --- RefreshTokenStore tests --- diff --git a/backend/internal/auth/coverage_test.go b/backend/internal/auth/coverage_test.go index 24fd7216..2bda4b05 100644 --- a/backend/internal/auth/coverage_test.go +++ b/backend/internal/auth/coverage_test.go @@ -327,7 +327,7 @@ func TestNewPolicyContextCustomRole(t *testing.T) { func TestNewPolicyContextCustomRoleNilPerms(t *testing.T) { pc := NewPolicyContext(1, 10, "custom_role", 5, nil) assert.NotNil(t, pc) - assert.Equal(t, AgentDefaultPermissions, pc.Permissions) + assert.Equal(t, PermissionMatrixMap{}, pc.Permissions) } func TestPolicyContextCanAdministrator(t *testing.T) { diff --git a/backend/internal/auth/policy.go b/backend/internal/auth/policy.go index 300b8606..c8a4413a 100644 --- a/backend/internal/auth/policy.go +++ b/backend/internal/auth/policy.go @@ -155,9 +155,10 @@ func NewPolicyContext(userID, accountID uint, role string, customRoleID uint, pe case "agent": pc.Permissions = AgentDefaultPermissions case "custom_role": - // Use the provided permissions matrix (from CustomRole) + // Use only the provided permissions matrix (from CustomRole). A missing + // matrix means no permissions; callers must not widen it to agent access. if pc.Permissions == nil { - pc.Permissions = AgentDefaultPermissions // fallback + pc.Permissions = PermissionMatrixMap{} } } diff --git a/backend/internal/handler/api/v1/agent_handler.go b/backend/internal/handler/api/v1/agent_handler.go index 7f730f31..94641f3f 100644 --- a/backend/internal/handler/api/v1/agent_handler.go +++ b/backend/internal/handler/api/v1/agent_handler.go @@ -10,6 +10,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) @@ -18,8 +19,9 @@ import ( // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb // An "agent" in Chatwoot is a User with an AccountUser membership in a specific account. type AgentHandler struct { - svc *service.AgentService - audit *service.AuditService + svc *service.AgentService + audit *service.AuditService + events *ws.EventPublisher } func (h *AgentHandler) WithAuditService(audit *service.AuditService) *AgentHandler { @@ -27,6 +29,11 @@ func (h *AgentHandler) WithAuditService(audit *service.AuditService) *AgentHandl return h } +func (h *AgentHandler) WithEventPublisher(events *ws.EventPublisher) *AgentHandler { + h.events = events + return h +} + // NewAgentHandler creates a new AgentHandler. func NewAgentHandler(svc *service.AgentService) *AgentHandler { return &AgentHandler{svc: svc} @@ -127,6 +134,7 @@ func (h *AgentHandler) Create(c *gin.Context) { c.Header("Cache-Control", "no-store") recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: agent.ID, Action: "create", AuditedChanges: gin.H{"role": agent.Role, "active": agent.Active}}) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } @@ -171,6 +179,7 @@ func (h *AgentHandler) Update(c *gin.Context) { } recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: uint(id), Action: "update", AuditedChanges: agentUpdateAuditChanges(req)}) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } @@ -198,6 +207,7 @@ func (h *AgentHandler) Delete(c *gin.Context) { } recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: uint(id), Action: "destroy", AuditedChanges: gin.H{"account_id": accountID}}) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.Status(http.StatusOK) } @@ -267,6 +277,7 @@ func (h *AgentHandler) BulkCreate(c *gin.Context) { } c.Header("Cache-Control", "no-store") + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeAgentDetails(agents, accountID)) } diff --git a/backend/internal/handler/api/v1/agent_handler_test.go b/backend/internal/handler/api/v1/agent_handler_test.go index a1ef088b..5ecf0603 100644 --- a/backend/internal/handler/api/v1/agent_handler_test.go +++ b/backend/internal/handler/api/v1/agent_handler_test.go @@ -169,6 +169,7 @@ func (s *AgentHandlerTestSuite) TestListIgnoresPerPageLikeChatwoot() { func (s *AgentHandlerTestSuite) TestCreateAgent() { customRoleID := uint(7) + s.Require().NoError(s.db.Create(&model.CustomRole{ID: customRoleID, AccountID: s.account.ID, Name: "Custom", Permissions: "[]"}).Error) req := service.CreateAgentRequest{ Email: "agent1@test.com", Name: "Agent One", diff --git a/backend/internal/handler/api/v1/custom_role_handler.go b/backend/internal/handler/api/v1/custom_role_handler.go index 951ae700..9a7f70d0 100644 --- a/backend/internal/handler/api/v1/custom_role_handler.go +++ b/backend/internal/handler/api/v1/custom_role_handler.go @@ -7,6 +7,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) @@ -16,6 +17,7 @@ import ( type CustomRoleHandler struct { svc *service.CustomRoleService auditSvc *service.AuditService + events *ws.EventPublisher } // NewCustomRoleHandler creates a new CustomRole handler. @@ -28,6 +30,11 @@ func (h *CustomRoleHandler) WithAuditService(auditSvc *service.AuditService) *Cu return h } +func (h *CustomRoleHandler) WithEventPublisher(events *ws.EventPublisher) *CustomRoleHandler { + h.events = events + return h +} + // List returns all custom roles for an account. // GET /api/v1/accounts/:account_id/custom_roles func (h *CustomRoleHandler) List(c *gin.Context) { @@ -66,14 +73,18 @@ func (h *CustomRoleHandler) Create(c *gin.Context) { } var wrapper struct { - CustomRole service.CreateCustomRoleRequest `json:"custom_role"` + CustomRole *service.CreateCustomRoleRequest `json:"custom_role"` } if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + if wrapper.CustomRole == nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "custom_role envelope is required") + return + } - role, err := h.svc.Create(c.Request.Context(), accountID, wrapper.CustomRole) + role, err := h.svc.Create(c.Request.Context(), accountID, *wrapper.CustomRole) if err != nil { applogger.L().Errorf("Create custom role for account %d: %v", accountID, err) handleServiceError(c, err) @@ -86,6 +97,7 @@ func (h *CustomRoleHandler) Create(c *gin.Context) { Action: "create", AuditedChanges: role, }) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeCustomRole(role)) } @@ -140,14 +152,18 @@ func (h *CustomRoleHandler) Update(c *gin.Context) { } var wrapper struct { - CustomRole service.UpdateCustomRoleRequest `json:"custom_role"` + CustomRole *service.UpdateCustomRoleRequest `json:"custom_role"` } if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + if wrapper.CustomRole == nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "custom_role envelope is required") + return + } - role, svcErr := h.svc.Update(c.Request.Context(), id, accountID, wrapper.CustomRole) + role, svcErr := h.svc.Update(c.Request.Context(), id, accountID, *wrapper.CustomRole) if svcErr != nil { applogger.L().Errorf("Update custom role %d for account %d: %v", id, accountID, svcErr) handleServiceError(c, svcErr) @@ -160,6 +176,7 @@ func (h *CustomRoleHandler) Update(c *gin.Context) { Action: "update", AuditedChanges: role, }) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.JSON(http.StatusOK, serializeCustomRole(role)) } @@ -195,6 +212,7 @@ func (h *CustomRoleHandler) Delete(c *gin.Context) { Action: "destroy", AuditedChanges: gin.H{"id": id}, }) + publishRealtimeEvent(h.events, accountID, "page:reload", nil) c.Status(http.StatusOK) } diff --git a/backend/internal/handler/api/v1/custom_role_handler_test.go b/backend/internal/handler/api/v1/custom_role_handler_test.go index d4131f0d..1f60cf25 100644 --- a/backend/internal/handler/api/v1/custom_role_handler_test.go +++ b/backend/internal/handler/api/v1/custom_role_handler_test.go @@ -92,6 +92,18 @@ func (s *CustomRoleHandlerTestSuite) TestCreate_BadRequest_EmptyBody() { assert.Equal(s.T(), http.StatusBadRequest, w.Code) } +func (s *CustomRoleHandlerTestSuite) TestCreate_BadRequest_RequiresEnvelope() { + r := gin.New() + r.POST("/api/v1/accounts/:account_id/custom_roles", withCustomRoleAdminContext(s.account.ID, s.handler.Create)) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/custom_roles", s.account.ID), bytes.NewBufferString(`{"name":"raw-role","permissions":[]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusBadRequest, w.Code) +} + func (s *CustomRoleHandlerTestSuite) TestGet_BadRequest_InvalidID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Get)) @@ -115,6 +127,20 @@ func (s *CustomRoleHandlerTestSuite) TestUpdate_BadRequest_InvalidID() { assert.Equal(s.T(), http.StatusBadRequest, w.Code) } +func (s *CustomRoleHandlerTestSuite) TestUpdate_BadRequest_RequiresEnvelope() { + role := &model.CustomRole{AccountID: s.account.ID, Name: "raw-update", Permissions: `[]`} + s.Require().NoError(s.db.Create(role).Error) + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Update)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, role.ID), bytes.NewBufferString(`{"name":"raw-update-edited","permissions":[]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusBadRequest, w.Code) +} + func (s *CustomRoleHandlerTestSuite) TestDelete_BadRequest_InvalidID() { r := gin.New() r.DELETE("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Delete)) diff --git a/backend/internal/middleware/account_scope.go b/backend/internal/middleware/account_scope.go index 09912e6c..a64197bd 100644 --- a/backend/internal/middleware/account_scope.go +++ b/backend/internal/middleware/account_scope.go @@ -95,10 +95,15 @@ func AccountScope() gin.HandlerFunc { } } } + if roleStr == "administrator" && customRoleID > 0 { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, + "invalid administrator custom role assignment") + return + } // Step 6: Build PolicyContext from JWT claims data // For custom_role, permissions will be nil (loaded by service layer) - var permissions auth.PermissionMatrixMap + permissions := auth.PermissionMatrixMap{} policyCtx := auth.NewPolicyContext( userID.(uint), accountID, @@ -168,17 +173,23 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { "User does not belong to this account") return } + if role == "administrator" && customRoleID > 0 { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, + "invalid administrator custom role assignment") + return + } - // Build permissions matrix based on role + // Build permissions matrix based on role. A custom-role lookup failure must + // fail closed; Agent defaults would grant permissions to a broken assignment. permissions := auth.PermissionMatrixMap{} - if customRoleID > 0 && role != "administrator" { - pm, err := lookup.GetCustomRolePermissions(customRoleID) - if err != nil { - // Fallback to agent defaults if custom role not found - permissions = auth.AgentDefaultPermissions - } else { - permissions = pm + if customRoleID > 0 { + pm, err := lookup.GetCustomRolePermissionsForAccount(customRoleID, accountID) + if err != nil || pm == nil { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, + "custom role permissions unavailable") + return } + permissions = pm } effectiveRole := role @@ -285,5 +296,5 @@ func isSuperAdminContext(c *gin.Context) bool { // for use with AccountScopeWithService middleware. type RBACLookup interface { GetAccountUserRole(userID, accountID uint) (role string, customRoleID uint, err error) - GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) + GetCustomRolePermissionsForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) } diff --git a/backend/internal/middleware/account_scope_test.go b/backend/internal/middleware/account_scope_test.go index 67664ef6..e539ff80 100644 --- a/backend/internal/middleware/account_scope_test.go +++ b/backend/internal/middleware/account_scope_test.go @@ -23,7 +23,7 @@ func (l accountScopeLookup) GetAccountUserRole(_ uint, accountID uint) (string, return "administrator", 0, nil } -func (accountScopeLookup) GetCustomRolePermissions(uint) (auth.PermissionMatrixMap, error) { +func (accountScopeLookup) GetCustomRolePermissionsForAccount(uint, uint) (auth.PermissionMatrixMap, error) { return nil, nil } @@ -185,6 +185,39 @@ func TestAccountScope_UsesAuthMiddlewareRoleForAdministratorRoute(t *testing.T) assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) } +func TestAccountScope_RejectsAdministratorCustomRoleClaims(t *testing.T) { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", uint(1)) + c.Set("role", "administrator") + c.Set("claims", &auth.Claims{Role: "administrator", CustomRoleID: 9}) + c.Next() + }) + r.Use(AccountScope()) + r.GET("/api/v1/accounts/:account_id", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1", nil)) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestAccountScope_RejectsCustomRoleClaimsWithoutLoadedPermissions(t *testing.T) { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", uint(1)) + c.Set("role", "agent") + c.Set("claims", &auth.Claims{Role: "agent", CustomRoleID: 9}) + c.Next() + }) + r.Use(AccountScope()) + r.Use(PolicyMiddleware("conversation", "read")) + r.GET("/api/v1/accounts/:account_id", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1", nil)) + assert.Equal(t, http.StatusForbidden, w.Code) +} + func TestAccountScopeWithService_AllowsVerifiedAccountSwitch(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() diff --git a/backend/internal/middleware/cors_logger.go b/backend/internal/middleware/cors_logger.go index 8c975a54..5d2b663e 100644 --- a/backend/internal/middleware/cors_logger.go +++ b/backend/internal/middleware/cors_logger.go @@ -7,14 +7,23 @@ import ( "github.com/gin-gonic/gin" ) -// CORSMiddleware adds permissive CORS headers for development. +var legacyCORSAllowedOrigins = []string{ + "http://localhost:3000", + "http://localhost:5000", + "http://127.0.0.1:3000", + "http://127.0.0.1:5000", +} + +// CORSMiddleware adds CORS headers for the local development origins. func CORSMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + if origin := c.Request.Header.Get("Origin"); origin != "" && isOriginAllowed(origin, legacyCORSAllowedOrigins) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") c.Header("Access-Control-Max-Age", "86400") - c.Header("Access-Control-Allow-Credentials", "true") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) @@ -39,4 +48,4 @@ func RequestLoggerMiddleware() gin.HandlerFunc { fmt.Fprintf(gin.DefaultWriter, "%s %s %d %v\n", method, path, status, latency) } -} \ No newline at end of file +} diff --git a/backend/internal/middleware/cors_logger_test.go b/backend/internal/middleware/cors_logger_test.go index 6b242bde..d879bdde 100644 --- a/backend/internal/middleware/cors_logger_test.go +++ b/backend/internal/middleware/cors_logger_test.go @@ -16,14 +16,15 @@ func TestCORSMiddleware_Headers(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://localhost:5000") r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) - assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "http://localhost:5000", w.Header().Get("Access-Control-Allow-Origin")) assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "GET") assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Authorization") assert.Equal(t, "86400", w.Header().Get("Access-Control-Max-Age")) - assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) } func TestCORSMiddleware_Options(t *testing.T) { diff --git a/backend/internal/middleware/coverage6_test.go b/backend/internal/middleware/coverage6_test.go index d13cc5dd..2e412080 100644 --- a/backend/internal/middleware/coverage6_test.go +++ b/backend/internal/middleware/coverage6_test.go @@ -99,7 +99,7 @@ func TestCORSMiddleware_Options_Cov6(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("OPTIONS", "/api/test", nil) - c.Request.Header.Set("Origin", "https://example.com") + c.Request.Header.Set("Origin", "http://localhost:3000") handler(c) } @@ -109,7 +109,7 @@ func TestCORSMiddleware_Get_Cov6(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/test", nil) - c.Request.Header.Set("Origin", "https://example.com") + c.Request.Header.Set("Origin", "http://localhost:3000") handler(c) } diff --git a/backend/internal/middleware/coverage7_test.go b/backend/internal/middleware/coverage7_test.go index 9cf0221e..3cb112fc 100644 --- a/backend/internal/middleware/coverage7_test.go +++ b/backend/internal/middleware/coverage7_test.go @@ -1429,7 +1429,7 @@ func TestCORS_DevMode_Cov7(t *testing.T) { c.Request = httptest.NewRequest("GET", "/", nil) c.Request.Header.Set("Origin", "http://localhost:3000") CORS(CORSConfig{DevMode: true})(c) - assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Origin")) } func TestCORS_Options_Cov7(t *testing.T) { @@ -1460,22 +1460,11 @@ func TestCORS_NotAllowedOrigin_Cov7(t *testing.T) { } func TestCORS_WildcardSubdomain_Cov7(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Origin", "https://foo.example.com") - CORS(CORSConfig{AllowedOrigins: []string{"*.example.com"}, AllowCredentials: true})(c) - assert.Equal(t, "https://foo.example.com", w.Header().Get("Access-Control-Allow-Origin")) - assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.True(t, isOriginAllowed("https://foo.example.com", []string{"*.example.com"})) } func TestCORS_WildcardNoSubdomain_Cov7(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Origin", "https://example.com") - CORS(CORSConfig{AllowedOrigins: []string{"*.example.com"}})(c) - assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) + assert.False(t, isOriginAllowed("https://example.com", []string{"*.example.com"})) } func TestCORS_CustomMethods_Cov7(t *testing.T) { @@ -1790,8 +1779,9 @@ func TestCORSMiddleware_Legacy_Cov7(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("Origin", "http://localhost:5000") CORSMiddleware()(c) - assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "http://localhost:5000", w.Header().Get("Access-Control-Allow-Origin")) } func TestCORSMiddleware_LegacyOptions_Cov7(t *testing.T) { @@ -2102,7 +2092,7 @@ func (m *mockRBACLookup_Cov7) GetAccountUserRole(userID, accountID uint) (string return m.role, m.customRole, m.roleErr } -func (m *mockRBACLookup_Cov7) GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) { +func (m *mockRBACLookup_Cov7) GetCustomRolePermissionsForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) { return m.perms, m.permsErr } @@ -2185,7 +2175,8 @@ func TestAccountScopeWithService_CustomRolePermsError_Cov7(t *testing.T) { c.Request.Header.Set("X-Account-ID", "1") c.Set("user_id", uint(1)) AccountScopeWithService(lookup)(c) - assert.False(t, c.IsAborted()) + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusForbidden, w.Code) } func TestAccountScopeWithService_RouteAccountID_Cov7(t *testing.T) { diff --git a/backend/internal/model/custom_role.go b/backend/internal/model/custom_role.go index afb61465..72ab363a 100644 --- a/backend/internal/model/custom_role.go +++ b/backend/internal/model/custom_role.go @@ -114,10 +114,9 @@ func (cr *CustomRole) SetPermissionKeys(keys []PermissionDimension) error { return nil } -// GetPermissionMap deserializes the JSONB permissions field into a map of -// PermissionDimension → PermissionLevel. This is the raw deserialization; -// the auth.PolicyContext layer converts this into auth.PermissionMatrix for -// policy evaluation. +// GetPermissionMap deserializes the JSONB permissions field into a binary map +// of PermissionDimension → PermissionLevel. Legacy non-none levels normalize +// to full; unsupported dimensions normalize to none. func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLevel, error) { var keys []PermissionDimension if err := json.Unmarshal([]byte(cr.Permissions), &keys); err == nil { @@ -132,7 +131,7 @@ func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLeve return pm, nil } - var m map[PermissionDimension]PermissionLevel + m := make(map[PermissionDimension]PermissionLevel) if cr.Permissions == "" || cr.Permissions == "{}" { return map[PermissionDimension]PermissionLevel{}, nil } @@ -143,6 +142,13 @@ func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLeve if level != PermissionLevelFull && level != PermissionLevelRead && level != PermissionLevelNone { return nil, fmt.Errorf("invalid permission level '%s' for dimension '%s'", level, dim) } + if !IsValidCustomRolePermission(dim) { + m[dim] = PermissionLevelNone + continue + } + if level != PermissionLevelNone { + m[dim] = PermissionLevelFull + } } return m, nil } diff --git a/backend/internal/model/methods_test.go b/backend/internal/model/methods_test.go index 55c30687..4ce35263 100644 --- a/backend/internal/model/methods_test.go +++ b/backend/internal/model/methods_test.go @@ -124,6 +124,18 @@ func TestCustomRoleGetPermissionMap(t *testing.T) { assert.Empty(t, pm[model.DimensionAutomationManage]) } +func TestCustomRoleLegacyPermissionMapUsesBinaryLevels(t *testing.T) { + cr := &model.CustomRole{ + Permissions: `{"conversation_manage":"read","conversation_delete":"full","contact_manage":"none"}`, + } + + pm, err := cr.GetPermissionMap() + assert.NoError(t, err) + assert.Equal(t, model.PermissionLevelFull, pm[model.DimensionConversationManage]) + assert.Equal(t, model.PermissionLevelNone, pm[model.DimensionConversationDelete]) + assert.Equal(t, model.PermissionLevelNone, pm[model.DimensionContactManage]) +} + func TestCustomRoleGetPermissionMapEmpty(t *testing.T) { cr := &model.CustomRole{Permissions: "{}"} pm, err := cr.GetPermissionMap() diff --git a/backend/internal/repository/account_user_agent_test.go b/backend/internal/repository/account_user_agent_test.go index f87a8dcb..9ecc9fe4 100644 --- a/backend/internal/repository/account_user_agent_test.go +++ b/backend/internal/repository/account_user_agent_test.go @@ -418,6 +418,7 @@ func TestAgentRepo_UpdateAgent_CustomRole(t *testing.T) { account := &model.Account{Name: "A"} require.NoError(t, db.Create(account).Error) + require.NoError(t, db.Create(&model.CustomRole{ID: 5, AccountID: account.ID, Name: "Custom", Permissions: "[]"}).Error) user := &model.User{Name: "John", Email: "john@e.com", Password: "p", AccountID: account.ID} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error) diff --git a/backend/internal/repository/agent_repo.go b/backend/internal/repository/agent_repo.go index 4f6575d9..94ffb130 100644 --- a/backend/internal/repository/agent_repo.go +++ b/backend/internal/repository/agent_repo.go @@ -3,9 +3,11 @@ package repository import ( "context" "errors" + "fmt" "time" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/model" ) @@ -26,6 +28,34 @@ func NewAgentRepo(db *gorm.DB) *AgentRepo { return &AgentRepo{db: db} } +func normalizeAgentRole(role string, customRoleID uint) (string, error) { + if customRoleID == 0 { + return role, nil + } + if role == "administrator" { + return "", fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + return "agent", nil +} + +func validateCustomRoleAssignment(tx *gorm.DB, accountID, customRoleID uint) error { + if customRoleID == 0 { + return nil + } + + var role model.CustomRole + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND account_id = ? AND deleted_at IS NULL", customRoleID, accountID). + First(&role).Error + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("custom role %d not found for account %d", customRoleID, accountID) + } + return fmt.Errorf("validate custom role %d: %w", customRoleID, err) +} + // AgentDetail represents an agent as returned by the API — User with their // AccountUser role/availability for the specific account. // Reference: Chatwoot renders User objects with included account_users. @@ -146,72 +176,84 @@ func (r *AgentRepo) FindAgentByID(ctx context.Context, userID, accountID uint) ( // Reference: Chatwoot agents_controller.rb#create → AgentBuilder.new.perform // If the user does not exist, creates the user first, then creates the AccountUser. func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint, passwordDigest string, confirmedAt time.Time) (*AgentDetail, error) { - // Find or create the user - var user model.User - isNewUser := false - err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error - if err == gorm.ErrRecordNotFound { - // Create new user - if name == "" { - name = email - if atIdx := indexOfAt(email); atIdx > 0 { - name = email[:atIdx] + role, err := normalizeAgentRole(role, customRoleID) + if err != nil { + return nil, err + } + + var detail AgentDetail + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := validateCustomRoleAssignment(tx, accountID, customRoleID); err != nil { + return err + } + + // Find or create the user. + var user model.User + isNewUser := false + err := tx.Where("email = ?", email).First(&user).Error + if err == gorm.ErrRecordNotFound { + if name == "" { + name = email + if atIdx := indexOfAt(email); atIdx > 0 { + name = email[:atIdx] + } } + user = model.User{ + AccountID: accountID, + Name: name, + Email: email, + Password: passwordDigest, + PasswordDigest: passwordDigest, + Provider: "email", + Active: true, + ConfirmedAt: &confirmedAt, + } + if err := tx.Create(&user).Error; err != nil { + return err + } + isNewUser = true + } else if err != nil { + return err } - user = model.User{ - AccountID: accountID, - Name: name, - Email: email, - Password: passwordDigest, - PasswordDigest: passwordDigest, - Provider: "email", - Active: true, - ConfirmedAt: &confirmedAt, + + // Check if AccountUser already exists. + var existingAU model.AccountUser + err = tx.Where("account_id = ? AND user_id = ?", accountID, user.ID).First(&existingAU).Error + if err == nil { + return ErrAlreadyMember + } else if err != gorm.ErrRecordNotFound { + return err } - if err := r.db.WithContext(ctx).Create(&user).Error; err != nil { - return nil, err + + au := model.AccountUser{ + UserID: user.ID, + AccountID: accountID, + Role: role, + CustomRoleID: customRoleID, + Availability: availability, + AutoOffline: autoOffline, + InvitedBy: inviterID, } - isNewUser = true - } else if err != nil { + if err := tx.Select("UserID", "AccountID", "Role", "CustomRoleID", "Availability", "AutoOffline", "InvitedBy").Create(&au).Error; err != nil { + return err + } + + detail = AgentDetail{ + User: user, + Role: au.Role, + Availability: au.Availability, + AutoOffline: au.AutoOffline, + InvitedBy: au.InvitedBy, + AccountUserID: au.ID, + CustomRoleID: au.CustomRoleID, + IsNewUser: isNewUser, + } + return nil + }) + if err != nil { return nil, err } - - // Check if AccountUser already exists - var existingAU model.AccountUser - err = r.db.WithContext(ctx). - Where("account_id = ? AND user_id = ?", accountID, user.ID). - First(&existingAU).Error - if err == nil { - // Already a member — return conflict error - return nil, ErrAlreadyMember - } else if err != gorm.ErrRecordNotFound { - return nil, err - } - - // Create AccountUser - au := model.AccountUser{ - UserID: user.ID, - AccountID: accountID, - Role: role, - CustomRoleID: customRoleID, - Availability: availability, - AutoOffline: autoOffline, - InvitedBy: inviterID, - } - if err := r.db.WithContext(ctx).Select("UserID", "AccountID", "Role", "CustomRoleID", "Availability", "AutoOffline", "InvitedBy").Create(&au).Error; err != nil { - return nil, err - } - - return &AgentDetail{ - User: user, - Role: au.Role, - Availability: au.Availability, - AutoOffline: au.AutoOffline, - InvitedBy: au.InvitedBy, - AccountUserID: au.ID, - CustomRoleID: au.CustomRoleID, - IsNewUser: isNewUser, - }, nil + return &detail, nil } // UpdateAgent updates both the User (name) and AccountUser (role, availability, auto_offline). @@ -222,6 +264,32 @@ func (r *AgentRepo) UpdateAgent(ctx context.Context, userID, accountID uint, nam func (r *AgentRepo) UpdateAgentWithActive(ctx context.Context, userID, accountID uint, name, role, availability string, autoOffline bool, autoOfflineSet bool, customRoleID *uint, customRoleIDSet bool, active *bool, beforeCommit func() error) (*AgentDetail, error) { if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var currentAU model.AccountUser + if err := tx.Where("account_id = ? AND user_id = ?", accountID, userID).First(¤tAU).Error; err != nil { + return err + } + + assignedCustomRoleID := currentAU.CustomRoleID + if customRoleIDSet { + assignedCustomRoleID = 0 + if customRoleID != nil { + assignedCustomRoleID = *customRoleID + } + } + if role == "administrator" && assignedCustomRoleID > 0 { + return fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + if assignedCustomRoleID > 0 { + normalizedRole, err := normalizeAgentRole(role, assignedCustomRoleID) + if err != nil { + return err + } + role = normalizedRole + if err := validateCustomRoleAssignment(tx, accountID, assignedCustomRoleID); err != nil { + return err + } + } + userUpdates := map[string]interface{}{} if name != "" { userUpdates["name"] = name diff --git a/backend/internal/repository/custom_role_repo.go b/backend/internal/repository/custom_role_repo.go index ee19ff4f..5b5979d6 100644 --- a/backend/internal/repository/custom_role_repo.go +++ b/backend/internal/repository/custom_role_repo.go @@ -2,8 +2,10 @@ package repository import ( "context" + "errors" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/model" ) @@ -74,12 +76,24 @@ func (r *CustomRoleRepo) Update(ctx context.Context, role *model.CustomRole) err // Delete soft-deletes a custom role (GORM DeletedAt field). func (r *CustomRoleRepo) Delete(ctx context.Context, id, accountID uint) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var role model.CustomRole + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Unscoped().First(&role, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if role.AccountID != accountID { + return gorm.ErrRecordNotFound + } + if role.DeletedAt.Valid { + return nil + } if err := tx.Model(&model.AccountUser{}). Where("account_id = ? AND custom_role_id = ?", accountID, id). Updates(map[string]interface{}{"role": "agent", "custom_role_id": 0}).Error; err != nil { return err } - return tx.Where("id = ? AND account_id = ?", id, accountID). - Delete(&model.CustomRole{}).Error + return tx.Delete(&role).Error }) } diff --git a/backend/internal/repository/custom_role_repo_test.go b/backend/internal/repository/custom_role_repo_test.go index c9b385d1..95b36926 100644 --- a/backend/internal/repository/custom_role_repo_test.go +++ b/backend/internal/repository/custom_role_repo_test.go @@ -188,6 +188,20 @@ func TestCustomRoleRepo_Delete(t *testing.T) { assert.NotNil(t, found.DeletedAt) } +func TestCustomRoleRepo_Delete_RejectsOtherAccount(t *testing.T) { + db := setupTestDB(t, &model.CustomRole{}) + repo := NewCustomRoleRepo(db) + + account := createCustomRoleTestAccount(t, db) + otherAccount := createCustomRoleTestAccount(t, db) + role := createTestCustomRole(t, db, account.ID, "Supervisor") + + err := repo.Delete(context.Background(), role.ID, otherAccount.ID) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + _, err = repo.FindByIDAndAccount(context.Background(), role.ID, account.ID) + assert.NoError(t, err) +} + func TestCustomRoleRepo_Delete_NotFound(t *testing.T) { db := setupTestDB(t, &model.CustomRole{}) repo := NewCustomRoleRepo(db) diff --git a/backend/internal/service/agent_service.go b/backend/internal/service/agent_service.go index 4edb2d15..038d3d05 100644 --- a/backend/internal/service/agent_service.go +++ b/backend/internal/service/agent_service.go @@ -30,7 +30,10 @@ type AgentService struct { disconnectUser func(uint) } -var ErrAgentNameBlank = errors.New("agent name cannot be blank") +var ( + ErrAgentNameBlank = errors.New("agent name cannot be blank") + ErrInvalidAgentRoleAssignee = errors.New("invalid agent role assignment") +) // NewAgentService creates a new Agent service. func NewAgentService(agentRepo *repository.AgentRepo, db *gorm.DB) *AgentService { @@ -136,6 +139,11 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin if req.CustomRoleID != nil { customRoleID = *req.CustomRoleID } + normalizedRole, err := normalizeAgentRoleAssignment(role, req.CustomRoleID) + if err != nil { + return nil, err + } + role = normalizedRole temporaryPassword, err := generateTemporaryPassword() if err != nil { return nil, fmt.Errorf("generate temporary password: %w", err) @@ -169,11 +177,16 @@ func (s *AgentService) Update(ctx context.Context, userID, accountID uint, req U return nil, ErrAgentNameBlank } + role, err := normalizeAgentRoleAssignment(req.Role, req.CustomRoleID) + if err != nil { + return nil, err + } + var revokeRefreshTokens func() error if req.Active != nil && !*req.Active && s.refreshStore != nil { revokeRefreshTokens = func() error { return s.refreshStore.RevokeUser(ctx, userID) } } - agent, err := s.agentRepo.UpdateAgentWithActive(ctx, userID, accountID, req.Name, req.Role, req.Availability, req.AutoOffline, req.AutoOfflineSet(), req.CustomRoleID, req.CustomRoleIDSet(), req.Active, revokeRefreshTokens) + agent, err := s.agentRepo.UpdateAgentWithActive(ctx, userID, accountID, req.Name, role, req.Availability, req.AutoOffline, req.AutoOfflineSet(), req.CustomRoleID, req.CustomRoleIDSet(), req.Active, revokeRefreshTokens) if err != nil || req.Active == nil || *req.Active { return agent, err } @@ -188,6 +201,16 @@ func (s *AgentService) Delete(ctx context.Context, userID, accountID uint) error return s.agentRepo.DeleteAgent(ctx, userID, accountID) } +func normalizeAgentRoleAssignment(role string, customRoleID *uint) (string, error) { + if customRoleID == nil || *customRoleID == 0 { + return role, nil + } + if role == "administrator" { + return "", fmt.Errorf("%w: administrator cannot have a custom role", ErrInvalidAgentRoleAssignee) + } + return "agent", nil +} + // ResetPassword assigns a new one-time-visible random password to an email agent. // The plaintext password is returned to the administrator and is never persisted. func (s *AgentService) ResetPassword(ctx context.Context, userID, accountID uint) (string, error) { diff --git a/backend/internal/service/rbac_custom_role_test.go b/backend/internal/service/rbac_custom_role_test.go index 2a08b9a5..c4c99cc6 100644 --- a/backend/internal/service/rbac_custom_role_test.go +++ b/backend/internal/service/rbac_custom_role_test.go @@ -65,6 +65,42 @@ func TestRBACService_UpdateAccountUserRoleStoresAgentForCustomRole(t *testing.T) assert.Equal(t, role.ID, au.CustomRoleID) } +func TestRBACService_CustomRoleAssignmentIsAccountScoped(t *testing.T) { + svc, db := setupRBACCustomRoleTest(t) + account := &model.Account{Name: "Account One", Active: true} + otherAccount := &model.Account{Name: "Account Two", Active: true} + require.NoError(t, db.Create(account).Error) + require.NoError(t, db.Create(otherAccount).Error) + user := &model.User{AccountID: account.ID, Name: "Scoped Agent", Email: "scoped-agent@example.com", Password: "pw", Active: true} + require.NoError(t, db.Create(user).Error) + role := &model.CustomRole{AccountID: otherAccount.ID, Name: "Other Account Role"} + require.NoError(t, role.SetPermissionKeys([]model.PermissionDimension{model.DimensionReportManage})) + require.NoError(t, db.Create(role).Error) + + _, err := svc.AddAccountUser(user.ID, account.ID, "agent", role.ID, 0) + require.Error(t, err) + + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent", CustomRoleID: role.ID}).Error) + _, err = svc.BuildPolicyContext(user.ID, account.ID) + require.Error(t, err) +} + +func TestRBACService_AdministratorCannotUseCustomRole(t *testing.T) { + svc, db := setupRBACCustomRoleTest(t) + account := &model.Account{Name: "Admin Account", Active: true} + require.NoError(t, db.Create(account).Error) + user := &model.User{AccountID: account.ID, Name: "Admin", Email: "admin-custom@example.com", Password: "pw", Active: true} + require.NoError(t, db.Create(user).Error) + role := &model.CustomRole{AccountID: account.ID, Name: "Restricted"} + require.NoError(t, db.Create(role).Error) + + _, err := svc.AddAccountUser(user.ID, account.ID, "administrator", role.ID, 0) + require.Error(t, err) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator", CustomRoleID: role.ID}).Error) + _, err = svc.BuildPolicyContext(user.ID, account.ID) + require.Error(t, err) +} + func TestRBACService_CustomRoleConversationPermissionKeys(t *testing.T) { pc := auth.NewPolicyContext(1, 1, "custom_role", 7, auth.PermissionMatrixMap{ auth.DimensionConversationUnassignedManage: auth.PermissionFull, diff --git a/backend/internal/service/rbac_service.go b/backend/internal/service/rbac_service.go index 27a70458..d642b864 100644 --- a/backend/internal/service/rbac_service.go +++ b/backend/internal/service/rbac_service.go @@ -15,6 +15,7 @@ import ( "fmt" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" @@ -70,15 +71,14 @@ func (s *RBACService) GetAccountUser(userID, accountID uint) (*model.AccountUser // AddAccountUser adds a user to an account with a specified role. // This corresponds to Chatwoot's AccountUser creation during invitation flow. func (s *RBACService) AddAccountUser(userID, accountID uint, role string, customRoleID uint, invitedBy uint) (*model.AccountUser, error) { - // Validate role if !isValidRole(role) { return nil, fmt.Errorf("invalid role '%s': must be 'agent', 'administrator', or 'custom_role'", role) } - - // Check if user is already a member - existing, err := s.GetAccountUser(userID, accountID) - if err == nil && existing != nil { - return nil, fmt.Errorf("user %d is already a member of account %d", userID, accountID) + if role == "administrator" && customRoleID > 0 { + return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + if role == "custom_role" && customRoleID == 0 { + return nil, fmt.Errorf("invalid role assignment: custom_role requires a custom role id") } au := &model.AccountUser{ @@ -89,11 +89,22 @@ func (s *RBACService) AddAccountUser(userID, accountID uint, role string, custom Availability: "offline", InvitedBy: invitedBy, } - - if err := s.db.Create(au).Error; err != nil { + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := validateCustomRoleAssignment(tx, customRoleID, accountID); err != nil { + return err + } + var existing model.AccountUser + err := tx.Where("user_id = ? AND account_id = ?", userID, accountID).First(&existing).Error + if err == nil { + return fmt.Errorf("user %d is already a member of account %d", userID, accountID) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(au).Error + }); err != nil { return nil, err } - return au, nil } @@ -102,20 +113,29 @@ func (s *RBACService) UpdateAccountUserRole(userID, accountID uint, newRole stri if !isValidRole(newRole) { return nil, fmt.Errorf("invalid role '%s'", newRole) } - - au, err := s.GetAccountUser(userID, accountID) - if err != nil { - return nil, err + if newRole == "administrator" && customRoleID > 0 { + return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } + if newRole == "custom_role" && customRoleID == 0 { + return nil, fmt.Errorf("invalid role assignment: custom_role requires a custom role id") } - au.Role = normalizeAccountUserRole(newRole, customRoleID) - au.CustomRoleID = customRoleID - - if err := s.db.Save(au).Error; err != nil { + var au model.AccountUser + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := validateCustomRoleAssignment(tx, customRoleID, accountID); err != nil { + return err + } + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND account_id = ?", userID, accountID).First(&au).Error; err != nil { + return err + } + au.Role = normalizeAccountUserRole(newRole, customRoleID) + au.CustomRoleID = customRoleID + return tx.Save(&au).Error + }); err != nil { return nil, err } - - return au, nil + return &au, nil } // RemoveAccountUser removes a user from an account (soft delete). @@ -203,12 +223,49 @@ func (s *RBACService) GetCustomRolePermissionMatrix(customRoleID uint) (auth.Per if err != nil { return nil, err } + return customRolePermissionMatrix(cr) +} +// GetCustomRoleForAccount retrieves a live custom role scoped to an account. +func (s *RBACService) GetCustomRoleForAccount(customRoleID, accountID uint) (*model.CustomRole, error) { + var cr model.CustomRole + if err := validateCustomRoleAssignment(s.db, customRoleID, accountID); err != nil { + return nil, err + } + if err := s.db.Where("id = ? AND account_id = ? AND deleted_at IS NULL", customRoleID, accountID).First(&cr).Error; err != nil { + return nil, err + } + return &cr, nil +} + +func validateCustomRoleAssignment(db *gorm.DB, customRoleID, accountID uint) error { + if customRoleID == 0 { + return nil + } + var role model.CustomRole + err := db.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND account_id = ? AND deleted_at IS NULL", customRoleID, accountID). + First(&role).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("invalid custom role assignment: custom role %d not found for account %d", customRoleID, accountID) + } + return err +} + +// GetCustomRolePermissionMatrixForAccount loads permissions only from a live role in the account. +func (s *RBACService) GetCustomRolePermissionMatrixForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) { + cr, err := s.GetCustomRoleForAccount(customRoleID, accountID) + if err != nil { + return nil, err + } + return customRolePermissionMatrix(cr) +} + +func customRolePermissionMatrix(cr *model.CustomRole) (auth.PermissionMatrixMap, error) { modelMap, err := cr.GetPermissionMap() if err != nil { return nil, err } - return modelPermMapToAuthMatrix(modelMap), nil } @@ -240,29 +297,23 @@ func (s *RBACService) UpdateCustomRole(customRoleID uint, name string, permissio } // DeleteCustomRole deletes a custom role (soft delete). -// All AccountUsers referencing this role will be downgraded to agent role. +// All AccountUsers referencing it revert to the standard agent role and permissions. func (s *RBACService) DeleteCustomRole(customRoleID uint) error { - // Downgrade all account users with this custom role to agent - err := s.db.Model(&model.AccountUser{}). - Where("custom_role_id = ?", customRoleID). - Updates(map[string]interface{}{ - "role": "agent", - "custom_role_id": 0, - }).Error - if err != nil { - return err - } - - // Soft delete the custom role - result := s.db.Delete(&model.CustomRole{}, customRoleID) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return fmt.Errorf("custom role %d not found", customRoleID) - } - - return nil + return s.db.Transaction(func(tx *gorm.DB) error { + var role model.CustomRole + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&role, customRoleID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("custom role %d not found", customRoleID) + } + return err + } + if err := tx.Model(&model.AccountUser{}). + Where("account_id = ? AND custom_role_id = ?", role.AccountID, customRoleID). + Updates(map[string]interface{}{"role": "agent", "custom_role_id": 0}).Error; err != nil { + return err + } + return tx.Delete(&role).Error + }) } // ListCustomRoles retrieves all custom roles for an account. @@ -282,6 +333,9 @@ func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyCo if err != nil { return nil, err } + if au.Role == "administrator" && au.CustomRoleID > 0 { + return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") + } permissions := auth.PermissionMatrixMap{} @@ -293,29 +347,25 @@ func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyCo permissions = auth.AdministratorPermissions case "agent": if au.CustomRoleID > 0 { - pm, err := s.GetCustomRolePermissionMatrix(au.CustomRoleID) + pm, err := s.GetCustomRolePermissionMatrixForAccount(au.CustomRoleID, accountID) if err != nil { - // Fallback to agent defaults if custom role not found - permissions = auth.AgentDefaultPermissions - } else { - permissions = pm - effectiveRole = "custom_role" + return nil, fmt.Errorf("load custom role permissions: %w", err) } + permissions = pm + effectiveRole = "custom_role" } else { permissions = auth.AgentDefaultPermissions } case "custom_role": - effectiveRole = "custom_role" - if au.CustomRoleID > 0 { - pm, err := s.GetCustomRolePermissionMatrix(au.CustomRoleID) - if err != nil { - permissions = auth.AgentDefaultPermissions - } else { - permissions = pm - } - } else { - permissions = auth.AgentDefaultPermissions + if au.CustomRoleID == 0 { + return nil, fmt.Errorf("custom role assignment is missing a role id") } + pm, err := s.GetCustomRolePermissionMatrixForAccount(au.CustomRoleID, accountID) + if err != nil { + return nil, fmt.Errorf("load custom role permissions: %w", err) + } + permissions = pm + effectiveRole = "custom_role" } return auth.NewPolicyContext(userID, accountID, effectiveRole, au.CustomRoleID, permissions), nil @@ -498,7 +548,12 @@ func (s *RBACService) GetAccountUserRole(userID, accountID uint) (string, uint, } // GetCustomRolePermissions returns the auth.PermissionMatrixMap for a custom role. -// This implements the middleware.RBACLookup interface. +// It is retained for callers that do not have an account scope. func (s *RBACService) GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) { return s.GetCustomRolePermissionMatrix(customRoleID) } + +// GetCustomRolePermissionsForAccount implements the account-scoped middleware lookup. +func (s *RBACService) GetCustomRolePermissionsForAccount(customRoleID, accountID uint) (auth.PermissionMatrixMap, error) { + return s.GetCustomRolePermissionMatrixForAccount(customRoleID, accountID) +} diff --git a/backend/scripts/parity_frontend_browser_smoke.mjs b/backend/scripts/parity_frontend_browser_smoke.mjs index 7db31d0a..7885176d 100644 --- a/backend/scripts/parity_frontend_browser_smoke.mjs +++ b/backend/scripts/parity_frontend_browser_smoke.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn } from "node:child_process"; import { closeSync, mkdirSync, @@ -7,70 +7,110 @@ import { openSync, readFileSync, writeFileSync, -} from 'node:fs'; -import { createServer } from 'node:http'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; const root = process.env.GOCHAT_ROOT || process.cwd(); -const chatwootDir = process.env.CHATWOOT_DIR || path.join(root, '..', 'docs', 'chatwoot'); -const logDir = process.env.GOCHAT_SMOKE_LOG_DIR || path.join(root, '.tmp/frontend-smoke'); -const apiHost = process.env.GOCHAT_SMOKE_API_HOST || '127.0.0.1'; -const apiPort = process.env.GOCHAT_SMOKE_API_PORT || '3000'; -const frontendHost = process.env.GOCHAT_SMOKE_FRONTEND_HOST || '127.0.0.1'; -const frontendPort = process.env.GOCHAT_SMOKE_FRONTEND_PORT || '3036'; +const frontendDir = + process.env.FRONTEND_DIR || + process.env.CHATWOOT_DIR || + path.join(root, "..", "frontend"); +const logDir = + process.env.GOCHAT_SMOKE_LOG_DIR || path.join(root, ".tmp/frontend-smoke"); +const apiHost = process.env.GOCHAT_SMOKE_API_HOST || "127.0.0.1"; +const apiPort = process.env.GOCHAT_SMOKE_API_PORT || "3000"; +const frontendHost = process.env.GOCHAT_SMOKE_FRONTEND_HOST || "127.0.0.1"; +const frontendPort = process.env.GOCHAT_SMOKE_FRONTEND_PORT || "3036"; const shellHost = process.env.GOCHAT_SMOKE_SHELL_HOST || frontendHost; -const shellPort = process.env.GOCHAT_SMOKE_SHELL_PORT || String(Number(frontendPort) + 1); -const chromePath = process.env.GOCHAT_SMOKE_CHROME || '/usr/bin/google-chrome'; +const shellPort = + process.env.GOCHAT_SMOKE_SHELL_PORT || String(Number(frontendPort) + 1); +const chromePath = process.env.GOCHAT_SMOKE_CHROME || "/usr/bin/google-chrome"; const viteBaseURL = `http://${frontendHost}:${frontendPort}`; const frontendBaseURL = `http://${shellHost}:${shellPort}`; const apiBaseURL = `http://${apiHost}:${apiPort}`; -const enterpriseMode = process.argv.includes('--enterprise'); +const enterpriseMode = process.argv.includes("--enterprise"); +const deploymentEnv = process.env.GOCHAT_SMOKE_DEPLOYMENT_ENV || "cloud"; +const installationName = process.env.GOCHAT_SMOKE_INSTALLATION_NAME || "GoChat"; +const expectedBilling = + (process.env.GOCHAT_SMOKE_EXPECT_BILLING || + (deploymentEnv === "cloud" && installationName === "GoChat" + ? "true" + : "false")) === "true"; function isSuccessfulRequest(request, substring) { - return request.url.includes(substring) && request.status >= 200 && request.status < 400; + return ( + request.url.includes(substring) && + request.status >= 200 && + request.status < 400 + ); } function isFailedAPIRequest(request) { const isBackendAPI = request.url.includes(apiBaseURL); - const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && ( - request.url.includes('/api/') || - request.url.includes('/enterprise/') || - request.url.includes('/public/') || - request.url.includes('/auth/') || - request.url.includes('/rails/') - ); - if (!isBackendAPI && !isShellProxiedAPI || request.type === 'Preflight') return false; - const isNavigationAbort = request.type === 'Document' && - ['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText); - return request.status >= 400 || request.status === 0 && !isNavigationAbort; + const isShellProxiedAPI = + request.url.startsWith(frontendBaseURL) && + (request.url.includes("/api/") || + request.url.includes("/enterprise/") || + request.url.includes("/public/") || + request.url.includes("/auth/") || + request.url.includes("/rails/")); + if ((!isBackendAPI && !isShellProxiedAPI) || request.type === "Preflight") + return false; + const isNavigationAbort = + request.type === "Document" && + ["net::ERR_ABORTED", "net::ERR_FAILED"].includes(request.errorText); + return request.status >= 400 || (request.status === 0 && !isNavigationAbort); } -if (process.argv.includes('--self-test')) { +if (process.argv.includes("--self-test")) { const apiURL = `${apiBaseURL}/api/v1/profile`; const checks = [ - isSuccessfulRequest({ url: apiURL, status: 200 }, '/api/v1/profile'), - !isSuccessfulRequest({ url: apiURL, status: 500 }, '/api/v1/profile'), - isFailedAPIRequest({ url: apiURL, status: 0, type: 'Fetch', errorText: 'net::ERR_FAILED' }), - !isFailedAPIRequest({ url: apiURL, status: 0, type: 'Document', errorText: 'net::ERR_ABORTED' }), - !isFailedAPIRequest({ url: `${frontendBaseURL}/favicon.ico`, status: 404, type: 'Image' }), + isSuccessfulRequest({ url: apiURL, status: 200 }, "/api/v1/profile"), + !isSuccessfulRequest({ url: apiURL, status: 500 }, "/api/v1/profile"), + isFailedAPIRequest({ + url: apiURL, + status: 0, + type: "Fetch", + errorText: "net::ERR_FAILED", + }), + !isFailedAPIRequest({ + url: apiURL, + status: 0, + type: "Document", + errorText: "net::ERR_ABORTED", + }), + !isFailedAPIRequest({ + url: `${frontendBaseURL}/favicon.ico`, + status: 404, + type: "Image", + }), ]; - if (checks.some(check => !check)) throw new Error('browser smoke failure contract self-test failed'); - console.log('browser smoke failure contract self-test: ok'); + if (checks.some((check) => !check)) + throw new Error("browser smoke failure contract self-test failed"); + console.log("browser smoke failure contract self-test: ok"); process.exit(0); } mkdirSync(logDir, { recursive: true }); -const seed = JSON.parse(readFileSync(path.join(logDir, 'seed.json'), 'utf8')); -const widgetConfig = JSON.parse(readFileSync(path.join(logDir, 'widget_config.json'), 'utf8')); -const signInHeaders = readFileSync(path.join(logDir, 'sign_in.headers'), 'utf8'); +const seed = JSON.parse(readFileSync(path.join(logDir, "seed.json"), "utf8")); +const widgetConfig = JSON.parse( + readFileSync(path.join(logDir, "widget_config.json"), "utf8"), +); +const signInHeaders = readFileSync( + path.join(logDir, "sign_in.headers"), + "utf8", +); const report = { started_at: new Date().toISOString(), - mode: enterpriseMode ? 'enterprise' : 'core', + mode: enterpriseMode ? "enterprise" : "core", + frontend_dir: frontendDir, frontend_base_url: frontendBaseURL, api_base_url: apiBaseURL, account_id: seed.account_id, + installation_name: installationName, requests: [], console: [], checks: [], @@ -78,31 +118,28 @@ const report = { function smokeHTML(entrypoint, route) { const config = { - apiHost: '', + apiHost: "", hostURL: frontendBaseURL, - helpCenterURL: '', - allowedLoginMethods: ['email'], - signupEnabled: 'false', - isMfaEnabled: 'false', - enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }], + helpCenterURL: "", + allowedLoginMethods: ["email"], + signupEnabled: "false", + isMfaEnabled: "false", + enabledLanguages: [{ iso_639_1_code: "en", name: "English" }], helpUrls: {}, - selectedLocale: 'en', - isEnterprise: 'true', - enterprisePlanName: 'enterprise', + selectedLocale: "en", }; const globalConfig = { - INSTALLATION_NAME: 'GoChat', - BRAND_NAME: 'GoChat', - LOGO: '/logo.png', - LOGO_DARK: '', - LOGO_THUMBNAIL: '/logo.png', - DISABLE_USER_PROFILE_UPDATE: 'false', - DIRECT_UPLOADS_ENABLED: 'false', - MAXIMUM_FILE_UPLOAD_SIZE: '40', + INSTALLATION_NAME: installationName, + BRAND_NAME: "GoChat", + LOGO: "/logo.png", + LOGO_DARK: "", + LOGO_THUMBNAIL: "/logo.png", + DISABLE_USER_PROFILE_UPDATE: "false", + DIRECT_UPLOADS_ENABLED: "false", + MAXIMUM_FILE_UPLOAD_SIZE: "40", ACTIVE_PLATFORM_BANNERS: [], - LOGOUT_REDIRECT_LINK: '/app/login', - DEPLOYMENT_ENV: 'cloud', - IS_ENTERPRISE: 'true', + LOGOUT_REDIRECT_LINK: "/app/login", + DEPLOYMENT_ENV: deploymentEnv, }; return ` @@ -112,6 +149,7 @@ function smokeHTML(entrypoint, route) {
+ {{ $t('CUSTOM_ROLE.LIST.ERROR') }} +
+ +