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
+72 -31
View File
@@ -13,27 +13,32 @@ import (
)
// --- Permission Dimensions (P2E §2.1) ---
// Six enterprise permission dimensions, each with levels: full, read, none.
// Chatwoot custom-role permissions are stored as string keys. The local policy
// matrix maps present keys to full access while retaining legacy level support.
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 retained for old rows/tests.
DimensionConversationDelete PermissionDimension = "conversation_delete"
DimensionAutomationManage PermissionDimension = "automation_manage"
)
// AllDimensions lists all 6 permission dimensions for iteration.
var AllDimensions = []PermissionDimension{
DimensionConversationManage,
DimensionConversationDelete,
DimensionConversationUnassignedManage,
DimensionConversationParticipatingManage,
DimensionContactManage,
DimensionReportManage,
DimensionKnowledgeBaseManage,
DimensionAutomationManage,
}
// PermissionLevel represents the access level for a permission dimension.
@@ -69,23 +74,23 @@ type PermissionMatrixMap map[PermissionDimension]PermissionLevel
// AgentDefaultPermissions defines the default permission matrix for agent role.
// Reference: P2E §2.2 — agent defaults
var AgentDefaultPermissions = PermissionMatrixMap{
DimensionConversationManage: PermissionRead,
DimensionConversationDelete: PermissionNone,
DimensionContactManage: PermissionRead,
DimensionReportManage: PermissionNone,
DimensionKnowledgeBaseManage: PermissionNone,
DimensionAutomationManage: PermissionNone,
DimensionConversationManage: PermissionRead,
DimensionConversationDelete: PermissionNone,
DimensionContactManage: PermissionRead,
DimensionReportManage: PermissionNone,
DimensionKnowledgeBaseManage: PermissionNone,
DimensionAutomationManage: PermissionNone,
}
// AdministratorPermissions defines the permission matrix for administrator role.
// All dimensions are set to "full".
var AdministratorPermissions = PermissionMatrixMap{
DimensionConversationManage: PermissionFull,
DimensionConversationDelete: PermissionFull,
DimensionContactManage: PermissionFull,
DimensionReportManage: PermissionFull,
DimensionKnowledgeBaseManage: PermissionFull,
DimensionAutomationManage: PermissionFull,
DimensionConversationManage: PermissionFull,
DimensionConversationDelete: PermissionFull,
DimensionContactManage: PermissionFull,
DimensionReportManage: PermissionFull,
DimensionKnowledgeBaseManage: PermissionFull,
DimensionAutomationManage: PermissionFull,
}
// ToJSON serializes the permission matrix to JSON bytes (for JSONB storage).
@@ -137,8 +142,12 @@ func NewPolicyContext(userID, accountID uint, role string, customRoleID uint, pe
Permissions: permissions,
}
if role == "agent" && customRoleID > 0 {
pc.Role = "custom_role"
}
// Apply role-based defaults
switch role {
switch pc.Role {
case "administrator":
pc.Permissions = AdministratorPermissions
case "agent":
@@ -160,12 +169,12 @@ func (pc *PolicyContext) IsAdministrator() bool {
// IsAgent returns true if the role is agent.
func (pc *PolicyContext) IsAgent() bool {
return pc.Role == "agent"
return pc.Role == "agent" && pc.CustomRoleID == 0
}
// IsCustomRole returns true if the role is a custom (enterprise) role.
func (pc *PolicyContext) IsCustomRole() bool {
return pc.Role == "custom_role"
return pc.Role == "custom_role" || (pc.Role == "agent" && pc.CustomRoleID > 0)
}
// Can checks whether the current user is authorized for an action on a resource.
@@ -203,11 +212,16 @@ func (pc *PolicyContext) Can(action, resource string) bool {
// Special rule: agents can create messages (reply) with read-level access
// This matches Chatwoot's behavior where agents can send replies to conversations
if resource == "message" && action == "create" {
level, ok := pc.Permissions[DimensionConversationManage]
if !ok {
return pc.canAccessConversationAction("read")
}
if resource == "conversation" || resource == "message" {
if pc.canAccessConversationAction(action) {
return true
}
if action != "delete" {
return false
}
return level.CanRead()
}
dimension := mapActionToDimension(action, resource)
@@ -224,6 +238,31 @@ func (pc *PolicyContext) Can(action, resource string) bool {
return matchesAction(level, action)
}
func (pc *PolicyContext) canAccessConversationAction(action string) bool {
if action == "delete" {
level, ok := pc.Permissions[DimensionConversationDelete]
return ok && level.CanWrite()
}
for _, dim := range []PermissionDimension{
DimensionConversationManage,
DimensionConversationUnassignedManage,
DimensionConversationParticipatingManage,
} {
level, ok := pc.Permissions[dim]
if !ok {
continue
}
if action == "read" && level.CanRead() {
return true
}
if matchesAction(level, action) {
return true
}
}
return false
}
// mapActionToDimension maps a (action, resource) pair to a PermissionDimension.
// This bridges the flat permission constants in permission.go with the
// 6 enterprise dimensions.
@@ -296,14 +335,16 @@ func (pc *PolicyContext) Scope(db *gorm.DB, resource string) *gorm.DB {
switch resource {
case "conversation":
if pc.Can("manage", "conversation") {
if pc.GetPermissionLevel(DimensionConversationManage).CanRead() {
// Full manage: see all conversations in account
return db.Where("account_id = ?", pc.AccountID)
}
if pc.Can("read", "conversation") {
// Read-only: see assigned + unassigned conversations
if pc.GetPermissionLevel(DimensionConversationUnassignedManage).CanRead() {
return db.Where("account_id = ? AND (assignee_id = ? OR assignee_id IS NULL OR assignee_id = 0)", pc.AccountID, pc.UserID)
}
if pc.GetPermissionLevel(DimensionConversationParticipatingManage).CanRead() {
return db.Where("account_id = ? AND (assignee_id = ? OR id IN (SELECT conversation_id FROM conversation_participants WHERE user_id = ?))", pc.AccountID, pc.UserID, pc.UserID)
}
// No access: no conversations visible
return db.Where("account_id = ? AND 1=0", pc.AccountID) // empty result set
@@ -364,4 +405,4 @@ func (pc *PolicyContext) HasFeatureAccess(dim PermissionDimension, required Perm
default:
return false
}
}
}