412 lines
13 KiB
Go
412 lines
13 KiB
Go
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) ---
|
|
// 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"
|
|
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,
|
|
DimensionConversationUnassignedManage,
|
|
DimensionConversationParticipatingManage,
|
|
DimensionContactManage,
|
|
DimensionReportManage,
|
|
DimensionKnowledgeBaseManage,
|
|
}
|
|
|
|
// 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,
|
|
DimensionConversationUnassignedManage: PermissionFull,
|
|
DimensionConversationParticipatingManage: 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 RoleAdministrator, all permissions are set to "full".
|
|
// For RoleAgent, agent default permissions are applied.
|
|
// For RoleCustom, 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,
|
|
}
|
|
|
|
if role == RoleAgent && customRoleID > 0 {
|
|
pc.Role = RoleCustom
|
|
}
|
|
|
|
// Apply role-based defaults
|
|
switch pc.Role {
|
|
case RoleAdministrator:
|
|
pc.Permissions = AdministratorPermissions
|
|
case RoleAgent:
|
|
pc.Permissions = AgentDefaultPermissions
|
|
case RoleCustom:
|
|
// 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 = PermissionMatrixMap{}
|
|
}
|
|
}
|
|
|
|
return pc
|
|
}
|
|
|
|
// IsAdministrator returns true if the role is administrator.
|
|
func (pc *PolicyContext) IsAdministrator() bool {
|
|
return pc.Role == RoleAdministrator
|
|
}
|
|
|
|
// IsAgent returns true if the role is agent.
|
|
func (pc *PolicyContext) IsAgent() bool {
|
|
return pc.Role == RoleAgent && pc.CustomRoleID == 0
|
|
}
|
|
|
|
// IsCustomRole returns true if the role is a custom (enterprise) role.
|
|
func (pc *PolicyContext) IsCustomRole() bool {
|
|
return pc.Role == RoleCustom || (pc.Role == RoleAgent && pc.CustomRoleID > 0)
|
|
}
|
|
|
|
// 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 RoleAdministrator, Can() always returns true.
|
|
// For RoleAgent, Can() checks against AgentDefaultPermissions.
|
|
// For RoleCustom, 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 == RoleSuperAdmin {
|
|
return true
|
|
}
|
|
|
|
// Apply default permissions if not explicitly set
|
|
if pc.Permissions == nil {
|
|
switch pc.Role {
|
|
case RoleAdministrator:
|
|
pc.Permissions = AdministratorPermissions
|
|
case RoleAgent:
|
|
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" {
|
|
return pc.canAccessConversationAction("read")
|
|
}
|
|
|
|
if resource == "conversation" || resource == "message" {
|
|
if pc.canAccessConversationAction(action) {
|
|
return true
|
|
}
|
|
if action != "delete" {
|
|
return false
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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.
|
|
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.GetPermissionLevel(DimensionConversationManage).CanRead() {
|
|
// Full manage: see all conversations in account
|
|
return db.Where("account_id = ?", pc.AccountID)
|
|
}
|
|
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
|
|
|
|
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
|
|
}
|
|
}
|