package auth // Reference: P2E §2 — RBAC permission system // Replaces Chatwoot's Pundit Policy classes with a unified PolicyContext + permission matrix. // This file provides the core PolicyContext implementation with Can(), Scope(), and the // 6 enterprise permission dimensions. import ( "encoding/json" "fmt" "gorm.io/gorm" ) // --- Permission Dimensions (P2E §2.1) --- // Six enterprise permission dimensions, each with levels: full, read, none. 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" ) // AllDimensions lists all 6 permission dimensions for iteration. var AllDimensions = []PermissionDimension{ DimensionConversationManage, DimensionConversationDelete, DimensionContactManage, DimensionReportManage, DimensionKnowledgeBaseManage, DimensionAutomationManage, } // PermissionLevel represents the access level for a permission dimension. type PermissionLevel string const ( PermissionFull PermissionLevel = "full" PermissionRead PermissionLevel = "read" PermissionNone PermissionLevel = "none" ) // IsValid checks if a PermissionLevel is one of the allowed values. func (l PermissionLevel) IsValid() bool { return l == PermissionFull || l == PermissionRead || l == PermissionNone } // CanWrite returns true if the level grants write/full access. func (l PermissionLevel) CanWrite() bool { return l == PermissionFull } // CanRead returns true if the level grants at least read access. func (l PermissionLevel) CanRead() bool { return l == PermissionFull || l == PermissionRead } // --- Permission Matrix --- // PermissionMatrix maps each dimension to its level. // Stored as JSONB in CustomRole model. 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, } // 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, } // ToJSON serializes the permission matrix to JSON bytes (for JSONB storage). func (m PermissionMatrixMap) ToJSON() ([]byte, error) { return json.Marshal(m) } // PermissionMatrixFromJSON deserializes a permission matrix from JSON bytes. func PermissionMatrixFromJSON(data []byte) (PermissionMatrixMap, error) { var m PermissionMatrixMap if err := json.Unmarshal(data, &m); err != nil { return nil, err } // Validate each dimension/level for dim, level := range m { if !level.IsValid() { return nil, fmt.Errorf("invalid permission level '%s' for dimension '%s'", level, dim) } } return m, nil } // --- PolicyContext --- // PolicyContext encapsulates the authorization context for a request. // Reference: P2E §2.3 — PolicyContext structure // // PolicyContext is constructed from JWT claims + AccountUser data and provides: // - Can(action, resource) — check if the user can perform an action // - Scope() — return GORM WHERE conditions for data filtering type PolicyContext struct { UserID uint AccountID uint Role string // agent, administrator, or custom_role CustomRoleID uint Permissions PermissionMatrixMap } // NewPolicyContext creates a PolicyContext from the given parameters. // For "administrator" role, all permissions are set to "full". // For "agent" role, agent default permissions are applied. // For "custom_role" role, the provided permissions matrix is used. func NewPolicyContext(userID, accountID uint, role string, customRoleID uint, permissions PermissionMatrixMap) *PolicyContext { pc := &PolicyContext{ UserID: userID, AccountID: accountID, Role: role, CustomRoleID: customRoleID, Permissions: permissions, } // Apply role-based defaults switch role { case "administrator": pc.Permissions = AdministratorPermissions case "agent": pc.Permissions = AgentDefaultPermissions case "custom_role": // Use the provided permissions matrix (from CustomRole) if pc.Permissions == nil { pc.Permissions = AgentDefaultPermissions // fallback } } return pc } // IsAdministrator returns true if the role is administrator. func (pc *PolicyContext) IsAdministrator() bool { return pc.Role == "administrator" } // IsAgent returns true if the role is agent. func (pc *PolicyContext) IsAgent() bool { return pc.Role == "agent" } // IsCustomRole returns true if the role is a custom (enterprise) role. func (pc *PolicyContext) IsCustomRole() bool { return pc.Role == "custom_role" } // Can checks whether the current user is authorized for an action on a resource. // Reference: P2E §2.4 — PolicyContext.Can(action, resource) // // Action semantics: // - "manage" → requires PermissionFull on the corresponding dimension // - "read" → requires at least PermissionRead on the corresponding dimension // - "delete" → checks the delete-specific dimension (e.g., conversation_delete) // - "create" → requires PermissionFull on the corresponding manage dimension // - "update" → requires PermissionFull on the corresponding manage dimension // // For "administrator" role, Can() always returns true. // For "agent" role, Can() checks against AgentDefaultPermissions. // For "custom_role", Can() checks the provided PermissionMatrix. func (pc *PolicyContext) Can(action, resource string) bool { // Administrator and super_admin always have full access if pc.IsAdministrator() || pc.Role == "super_admin" { return true } // Apply default permissions if not explicitly set if pc.Permissions == nil { switch pc.Role { case "administrator": pc.Permissions = AdministratorPermissions case "agent": pc.Permissions = AgentDefaultPermissions default: return false } } // 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 false } return level.CanRead() } dimension := mapActionToDimension(action, resource) if dimension == "" { // Unknown resource/action combination — deny by default return false } level, ok := pc.Permissions[dimension] if !ok { return false } return matchesAction(level, action) } // mapActionToDimension maps a (action, resource) pair to a PermissionDimension. // This bridges the flat permission constants in permission.go with the // 6 enterprise dimensions. func mapActionToDimension(action, resource string) PermissionDimension { switch resource { case "conversation": switch action { case "delete": return DimensionConversationDelete default: // read, create, update, manage, assign, resolve, manage_labels return DimensionConversationManage } case "contact": return DimensionContactManage case "report": return DimensionReportManage case "knowledge_base": return DimensionKnowledgeBaseManage case "automation": return DimensionAutomationManage case "message": // Messages are a sub-resource of conversations switch action { case "delete": return DimensionConversationDelete default: // read, create return DimensionConversationManage } case "account": // Account-level actions: manage_users, create, delete, update // Map to conversation_manage dimension as account operations // are typically admin-level (administrator/super_admin get full access anyway) switch action { case "delete": return DimensionConversationDelete default: // create, update, manage_users return DimensionConversationManage } default: return "" } } // matchesAction checks whether a PermissionLevel satisfies the requested action. func matchesAction(level PermissionLevel, action string) bool { switch action { case "read": return level.CanRead() case "manage", "create", "update", "assign", "resolve", "manage_labels": return level.CanWrite() case "delete": return level.CanWrite() default: return false } } // Scope returns GORM WHERE conditions for data filtering based on the policy context. // Reference: P2E §2.5 — PolicyContext.Scope() // // For agents, scope filters conversations/contacts to those assigned to the agent // or that are unassigned. For administrators, no filtering is applied. // For custom roles, scope depends on the permission level on the relevant dimension. func (pc *PolicyContext) Scope(db *gorm.DB, resource string) *gorm.DB { if pc.IsAdministrator() { // Administrators see all data in the account return db.Where("account_id = ?", pc.AccountID) } switch resource { case "conversation": if pc.Can("manage", "conversation") { // 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 return db.Where("account_id = ? AND (assignee_id = ? OR assignee_id IS NULL OR assignee_id = 0)", pc.AccountID, pc.UserID) } // No access: no conversations visible return db.Where("account_id = ? AND 1=0", pc.AccountID) // empty result set case "contact": if pc.Can("manage", "contact") { return db.Where("account_id = ?", pc.AccountID) } if pc.Can("read", "contact") { // Agents can read all contacts in the account (Chatwoot pattern) return db.Where("account_id = ?", pc.AccountID) } return db.Where("account_id = ? AND 1=0", pc.AccountID) case "report": if pc.Can("read", "report") { return db.Where("account_id = ?", pc.AccountID) } return db.Where("account_id = ? AND 1=0", pc.AccountID) case "knowledge_base": if pc.Can("manage", "knowledge_base") || pc.Can("read", "knowledge_base") { return db.Where("account_id = ?", pc.AccountID) } return db.Where("account_id = ? AND 1=0", pc.AccountID) case "automation": if pc.Can("manage", "automation") || pc.Can("read", "automation") { return db.Where("account_id = ?", pc.AccountID) } return db.Where("account_id = ? AND 1=0", pc.AccountID) default: // Default: account-scoped with no additional restrictions return db.Where("account_id = ?", pc.AccountID) } } // GetPermissionLevel returns the permission level for a given dimension. func (pc *PolicyContext) GetPermissionLevel(dim PermissionDimension) PermissionLevel { if level, ok := pc.Permissions[dim]; ok { return level } return PermissionNone } // HasFeatureAccess checks if the permission level for a dimension // meets the required minimum level. func (pc *PolicyContext) HasFeatureAccess(dim PermissionDimension, required PermissionLevel) bool { current := pc.GetPermissionLevel(dim) switch required { case PermissionNone: return true case PermissionRead: return current.CanRead() case PermissionFull: return current.CanWrite() default: return false } }