Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
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,
|
||||
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,
|
||||
}
|
||||
|
||||
if role == "agent" && customRoleID > 0 {
|
||||
pc.Role = "custom_role"
|
||||
}
|
||||
|
||||
// Apply role-based defaults
|
||||
switch pc.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" && pc.CustomRoleID == 0
|
||||
}
|
||||
|
||||
// IsCustomRole returns true if the role is a custom (enterprise) role.
|
||||
func (pc *PolicyContext) IsCustomRole() bool {
|
||||
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.
|
||||
// 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" {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user