Files
gochat/backend/internal/auth/policy.go
T
2026-09-14 10:47:21 +08:00

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
}
}