feat(custom-roles): align chatwoot permissions

This commit is contained in:
2026-06-05 11:14:38 +08:00
parent b5f2a47ef8
commit 8b3b532c65
20 changed files with 681 additions and 226 deletions
+3 -2
View File
@@ -18,7 +18,7 @@ type AccountUser struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"user_id"`
AccountID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"account_id"`
Role string `gorm:"size:50;not null;default:'agent'" json:"role"` // agent, administrator, custom_role
Role string `gorm:"size:50;not null;default:'agent'" json:"role"` // Chatwoot: agent or administrator; custom role is represented by CustomRoleID.
CustomRoleID uint `gorm:"default:0" json:"custom_role_id,omitempty"` // 0 means no custom role; >0 references CustomRole
AgentCapacityPolicyID *uint `gorm:"index" json:"agent_capacity_policy_id,omitempty"` // enterprise capacity policy assignment
Availability string `gorm:"size:50;default:'offline'" json:"availability"` // online/offline/busy
@@ -32,6 +32,7 @@ type AccountUser struct {
// Relations
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
CustomRole *CustomRole `gorm:"foreignKey:CustomRoleID" json:"custom_role,omitempty"`
AgentCapacityPolicy *AgentCapacityPolicy `gorm:"foreignKey:AgentCapacityPolicyID" json:"agent_capacity_policy,omitempty"`
}
@@ -49,7 +50,7 @@ func (au *AccountUser) IsAgent() bool {
// HasCustomRole returns true if the AccountUser has a custom enterprise role.
func (au *AccountUser) HasCustomRole() bool {
return au.Role == "custom_role" && au.CustomRoleID > 0
return au.CustomRoleID > 0
}
// IsOnline returns true if the agent availability is "online".
+3 -3
View File
@@ -243,7 +243,7 @@ func TestCustomRoleAccountUserAssociation(t *testing.T) {
// Create AccountUser with CustomRole
au := &model.AccountUser{
UserID: user.ID, AccountID: acc.ID,
Role: "custom_role", CustomRoleID: role.ID,
Role: "agent", CustomRoleID: role.ID,
Availability: "online",
}
assert.NoError(t, db.Create(au).Error)
@@ -251,7 +251,7 @@ func TestCustomRoleAccountUserAssociation(t *testing.T) {
var fetched model.AccountUser
assert.NoError(t, db.First(&fetched, au.ID).Error)
assert.Equal(t, role.ID, fetched.CustomRoleID)
assert.Equal(t, "custom_role", fetched.Role)
assert.Equal(t, "agent", fetched.Role)
}
// TestAttachmentMessageAssociation verifies Attachment -> Message association.
@@ -321,4 +321,4 @@ func TestCascadeSoftDelete(t *testing.T) {
var fetched model.User
assert.NoError(t, db.First(&fetched, user.ID).Error)
assert.Equal(t, acc.ID, fetched.AccountID)
}
}
+2 -2
View File
@@ -139,7 +139,7 @@ func TestCustomRoleDefaultValues(t *testing.T) {
var fetched model.CustomRole
assert.NoError(t, db.First(&fetched, role.ID).Error)
assert.Equal(t, "{}", fetched.Permissions, "Permissions should default to '{}'")
assert.Equal(t, "[]", fetched.Permissions, "Permissions should default to Chatwoot permission array")
}
// TestPlatformAppDefaultValues verifies PlatformApp model default field values.
@@ -321,4 +321,4 @@ func TestNotificationPreferenceUniqueConstraint(t *testing.T) {
}
err := db.Create(pref2).Error
assert.Error(t, err, "Duplicate notification preference should be rejected")
}
}
+126 -36
View File
@@ -1,17 +1,9 @@
package model
// CustomRole represents an enterprise custom role with fine-grained permissions.
// CustomRole represents an enterprise custom role with Chatwoot-compatible
// permission keys. Chatwoot stores permissions as a text array; GoChat stores the
// same string array as JSON for portability while keeping legacy map reads.
// Reference: Chatwoot enterprise/app/models/custom_role.rb
// P2E §2.1 — 6 permission dimensions: conversation_manage, conversation_delete,
// contact_manage, report_manage, knowledge_base_manage, automation_manage
//
// CustomRoles allow enterprise accounts to define roles between agent and administrator
// with specific permission levels (full/read/none) on each dimension.
//
// NOTE: PermissionMatrix type is intentionally NOT imported from internal/auth to avoid
// a circular dependency (auth -> model -> auth). Instead, the model stores permissions
// as raw JSONB string, and the auth/service layers handle deserialization into PermissionMatrix.
// See RBACService.GetPermissionMatrix(customRole) for the conversion.
import (
"encoding/json"
@@ -21,26 +13,31 @@ import (
"gorm.io/gorm"
)
// PermissionDimension constants — duplicated here to avoid circular import with auth package.
// These must be kept in sync with auth.PermissionDimension.
// PermissionDimension constants are duplicated here to avoid a circular import
// with auth. The first six constants are the current Chatwoot custom-role keys.
type PermissionDimension string
const (
DimensionConversationManage PermissionDimension = "conversation_manage"
DimensionConversationDelete PermissionDimension = "conversation_delete"
DimensionContactManage PermissionDimension = "contact_manage"
DimensionReportManage PermissionDimension = "report_manage"
DimensionKnowledgeBaseManage PermissionDimension = "knowledge_base_manage"
DimensionAutomationManage PermissionDimension = "automation_manage"
DimensionConversationManage PermissionDimension = "conversation_manage"
DimensionConversationUnassignedManage PermissionDimension = "conversation_unassigned_manage"
DimensionConversationParticipatingManage PermissionDimension = "conversation_participating_manage"
DimensionContactManage PermissionDimension = "contact_manage"
DimensionReportManage PermissionDimension = "report_manage"
DimensionKnowledgeBaseManage PermissionDimension = "knowledge_base_manage"
// Legacy local dimensions. These are accepted when reading older JSON map
// values but are not valid Chatwoot custom-role API keys.
DimensionConversationDelete PermissionDimension = "conversation_delete"
DimensionAutomationManage PermissionDimension = "automation_manage"
)
// PermissionLevel constants — duplicated here to avoid circular import with auth package.
type PermissionLevel string
const (
PermissionLevelFull PermissionLevel = "full"
PermissionLevelRead PermissionLevel = "read"
PermissionLevelNone PermissionLevel = "none"
PermissionLevelFull PermissionLevel = "full"
PermissionLevelRead PermissionLevel = "read"
PermissionLevelNone PermissionLevel = "none"
)
// CustomRole represents a custom enterprise role definition.
@@ -48,24 +45,93 @@ type CustomRole struct {
ID uint `gorm:"primaryKey" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
Name string `gorm:"size:255;not null" json:"name"`
Permissions string `gorm:"type:jsonb;not null;default:'{}'" json:"permissions"` // JSONB: {"conversation_manage":"read", ...}
Permissions string `gorm:"type:jsonb;not null;default:'[]'" json:"permissions"` // JSON array of Chatwoot permission keys.
Description string `gorm:"size:500" json:"description,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
// Relations
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
AccountUsers []AccountUser `gorm:"foreignKey:CustomRoleID" json:"account_users,omitempty"`
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
AccountUsers []AccountUser `gorm:"foreignKey:CustomRoleID" json:"account_users,omitempty"`
}
func (CustomRole) TableName() string { return "custom_roles" }
var validCustomRolePermissions = map[PermissionDimension]struct{}{
DimensionConversationManage: {},
DimensionConversationUnassignedManage: {},
DimensionConversationParticipatingManage: {},
DimensionContactManage: {},
DimensionReportManage: {},
DimensionKnowledgeBaseManage: {},
}
// IsValidCustomRolePermission reports whether key is one of Chatwoot's current
// CustomRole::PERMISSIONS values.
func IsValidCustomRolePermission(key PermissionDimension) bool {
_, ok := validCustomRolePermissions[key]
return ok
}
// GetPermissionKeys returns Chatwoot custom-role permission keys. It accepts the
// current JSON array shape and the legacy local JSON map shape for old rows.
func (cr *CustomRole) GetPermissionKeys() ([]PermissionDimension, error) {
if cr.Permissions == "" || cr.Permissions == "{}" || cr.Permissions == "[]" || cr.Permissions == "null" {
return []PermissionDimension{}, nil
}
var keys []PermissionDimension
if err := json.Unmarshal([]byte(cr.Permissions), &keys); err == nil {
return normalizePermissionKeys(keys)
}
legacyMap, err := cr.GetPermissionMap()
if err != nil {
return nil, err
}
keys = make([]PermissionDimension, 0, len(legacyMap))
for dim, level := range legacyMap {
if !IsValidCustomRolePermission(dim) || level == PermissionLevelNone {
continue
}
keys = append(keys, dim)
}
return normalizePermissionKeys(keys)
}
// SetPermissionKeys stores Chatwoot custom-role permission keys as a JSON array.
func (cr *CustomRole) SetPermissionKeys(keys []PermissionDimension) error {
normalized, err := normalizePermissionKeys(keys)
if err != nil {
return err
}
data, err := json.Marshal(normalized)
if err != nil {
return fmt.Errorf("failed to serialize custom role permissions: %w", err)
}
cr.Permissions = string(data)
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.
func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLevel, error) {
var keys []PermissionDimension
if err := json.Unmarshal([]byte(cr.Permissions), &keys); err == nil {
pm := map[PermissionDimension]PermissionLevel{}
normalized, err := normalizePermissionKeys(keys)
if err != nil {
return nil, err
}
for _, key := range normalized {
pm[key] = PermissionLevelFull
}
return pm, nil
}
var m map[PermissionDimension]PermissionLevel
if cr.Permissions == "" || cr.Permissions == "{}" {
return map[PermissionDimension]PermissionLevel{}, nil
@@ -73,17 +139,25 @@ func (cr *CustomRole) GetPermissionMap() (map[PermissionDimension]PermissionLeve
if err := json.Unmarshal([]byte(cr.Permissions), &m); err != nil {
return nil, fmt.Errorf("failed to parse custom role permissions JSON: %w", err)
}
for dim, level := range m {
if level != PermissionLevelFull && level != PermissionLevelRead && level != PermissionLevelNone {
return nil, fmt.Errorf("invalid permission level '%s' for dimension '%s'", level, dim)
}
}
return m, nil
}
// SetPermissionMap serializes a permission map into the JSONB permissions field.
// SetPermissionMap stores non-none valid Chatwoot permissions from a legacy
// permission matrix as the current JSON array shape.
func (cr *CustomRole) SetPermissionMap(m map[PermissionDimension]PermissionLevel) error {
data, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("failed to serialize custom role permissions: %w", err)
keys := make([]PermissionDimension, 0, len(m))
for dim, level := range m {
if level == PermissionLevelNone || !IsValidCustomRolePermission(dim) {
continue
}
keys = append(keys, dim)
}
cr.Permissions = string(data)
return nil
return cr.SetPermissionKeys(keys)
}
// Validate checks that the custom role has a valid name and permission dimensions.
@@ -92,20 +166,36 @@ func (cr *CustomRole) Validate() error {
return fmt.Errorf("custom role name is required")
}
pm, err := cr.GetPermissionMap()
keys, err := cr.GetPermissionKeys()
if err != nil {
return err
}
for dim, level := range pm {
if level != PermissionLevelFull && level != PermissionLevelRead && level != PermissionLevelNone {
return fmt.Errorf("invalid permission level '%s' for dimension '%s'", level, dim)
for _, key := range keys {
if !IsValidCustomRolePermission(key) {
return fmt.Errorf("invalid custom role permission '%s'", key)
}
}
return nil
}
func normalizePermissionKeys(keys []PermissionDimension) ([]PermissionDimension, error) {
seen := map[PermissionDimension]struct{}{}
normalized := make([]PermissionDimension, 0, len(keys))
for _, key := range keys {
if !IsValidCustomRolePermission(key) {
return nil, fmt.Errorf("invalid custom role permission '%s'", key)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, key)
}
return normalized, nil
}
// CustomRoleValidationError represents a validation error for a custom role.
type CustomRoleValidationError struct {
Dimension string
@@ -115,4 +205,4 @@ type CustomRoleValidationError struct {
func (e *CustomRoleValidationError) Error() string {
return "custom_role validation error: dimension '" + e.Dimension + "' has invalid level '" + e.Level + "' — " + e.Message
}
}
+43 -44
View File
@@ -89,11 +89,11 @@ func TestAccountUserIsAgent(t *testing.T) {
}
func TestAccountUserHasCustomRole(t *testing.T) {
au := &model.AccountUser{Role: "custom_role", CustomRoleID: 5}
au := &model.AccountUser{Role: "agent", CustomRoleID: 5}
assert.True(t, au.HasCustomRole())
au.Role = "agent"
assert.False(t, au.HasCustomRole())
assert.True(t, au.HasCustomRole())
au.Role = "custom_role"
au.CustomRoleID = 0
@@ -115,13 +115,13 @@ func TestAccountUserIsOnline(t *testing.T) {
func TestCustomRoleGetPermissionMap(t *testing.T) {
cr := &model.CustomRole{
Permissions: `{"conversation_manage":"full","contact_manage":"read","automation_manage":"none"}`,
Permissions: `["conversation_manage","contact_manage"]`,
}
pm, err := cr.GetPermissionMap()
assert.NoError(t, err)
assert.Equal(t, model.PermissionLevelFull, pm[model.DimensionConversationManage])
assert.Equal(t, model.PermissionLevelRead, pm[model.DimensionContactManage])
assert.Equal(t, model.PermissionLevelNone, pm[model.DimensionAutomationManage])
assert.Equal(t, model.PermissionLevelFull, pm[model.DimensionContactManage])
assert.Empty(t, pm[model.DimensionAutomationManage])
}
func TestCustomRoleGetPermissionMapEmpty(t *testing.T) {
@@ -152,20 +152,19 @@ func TestCustomRoleSetPermissionMap(t *testing.T) {
err := cr.SetPermissionMap(pm)
assert.NoError(t, err)
assert.Contains(t, cr.Permissions, "conversation_manage")
assert.Contains(t, cr.Permissions, "full")
assert.Contains(t, cr.Permissions, "contact_manage")
assert.Contains(t, cr.Permissions, "read")
// Verify round-trip
pm2, err := cr.GetPermissionMap()
assert.NoError(t, err)
assert.Equal(t, pm, pm2)
assert.Equal(t, model.PermissionLevelFull, pm2[model.DimensionConversationManage])
assert.Equal(t, model.PermissionLevelFull, pm2[model.DimensionContactManage])
}
func TestCustomRoleValidate(t *testing.T) {
cr := &model.CustomRole{
Name: "Valid Role",
Permissions: `{"conversation_manage":"full","contact_manage":"read"}`,
Name: "Valid Role",
Permissions: `["conversation_manage","contact_manage"]`,
}
assert.NoError(t, cr.Validate())
@@ -175,7 +174,7 @@ func TestCustomRoleValidate(t *testing.T) {
// Invalid permission level
cr.Name = "Invalid Role"
cr.Permissions = `{"conversation_manage":"invalid_level"}`
cr.Permissions = `["conversation_delete"]`
assert.Error(t, cr.Validate())
// Invalid JSON
@@ -275,13 +274,13 @@ func TestPermissionLevelConstants(t *testing.T) {
func TestAccountStructFields(t *testing.T) {
acc := model.Account{
Name: "Test",
Domain: "test.com",
Locale: "en",
Timezone: "UTC",
Active: true,
Status: "active",
FeatureFlags: `{"feature_x":true}`,
Name: "Test",
Domain: "test.com",
Locale: "en",
Timezone: "UTC",
Active: true,
Status: "active",
FeatureFlags: `{"feature_x":true}`,
AutoResolveDuration: 7,
}
assert.Equal(t, "Test", acc.Name)
@@ -296,20 +295,20 @@ func TestAccountStructFields(t *testing.T) {
func TestUserStructFields(t *testing.T) {
user := model.User{
AccountID: 1,
Name: "Agent",
Email: "agent@test.com",
Password: "secret",
Provider: "email",
UID: "ext123",
AvatarURL: "https://img.test.com/avatar.png",
Role: "agent",
Active: true,
Available: false,
TOTPSecret: "otp_secret",
TOTPEnabled: false,
AccountID: 1,
Name: "Agent",
Email: "agent@test.com",
Password: "secret",
Provider: "email",
UID: "ext123",
AvatarURL: "https://img.test.com/avatar.png",
Role: "agent",
Active: true,
Available: false,
TOTPSecret: "otp_secret",
TOTPEnabled: false,
CustomRoleID: nil,
SignInCount: 5,
SignInCount: 5,
}
assert.Equal(t, uint(1), user.AccountID)
assert.Equal(t, "Agent", user.Name)
@@ -359,12 +358,12 @@ func TestMessageStructFields(t *testing.T) {
func TestContactStructFields(t *testing.T) {
contact := model.Contact{
AccountID: 1,
Name: "Alice",
Email: "alice@test.com",
PhoneNumber: "+1234567890",
AvatarURL: "https://img.test.com/alice.png",
Identifier: "ext_id_123",
AccountID: 1,
Name: "Alice",
Email: "alice@test.com",
PhoneNumber: "+1234567890",
AvatarURL: "https://img.test.com/alice.png",
Identifier: "ext_id_123",
}
assert.Equal(t, uint(1), contact.AccountID)
assert.Equal(t, "Alice", contact.Name)
@@ -374,14 +373,14 @@ func TestContactStructFields(t *testing.T) {
func TestInboxStructFields(t *testing.T) {
inbox := model.Inbox{
AccountID: 1,
Name: "Support",
ChannelType: "web_widget",
ChannelID: 5,
AccountID: 1,
Name: "Support",
ChannelType: "web_widget",
ChannelID: 5,
EnableAutoAssignment: true,
AutoAssignmentLimit: 10,
Enabled: true,
ChannelConfig: `{"color":"#1f93ff"}`,
Enabled: true,
ChannelConfig: `{"color":"#1f93ff"}`,
}
assert.Equal(t, uint(1), inbox.AccountID)
assert.Equal(t, "Support", inbox.Name)
@@ -405,4 +404,4 @@ func TestCustomRoleValidationError(t *testing.T) {
assert.Contains(t, err.Error(), "conversation_manage")
assert.Contains(t, err.Error(), "invalid")
assert.Contains(t, err.Error(), "not a valid level")
}
}