Files
gochat/backend/internal/service/rbac_service.go
T
rogee aeddedf2a3 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.
2026-07-07 14:44:12 +08:00

524 lines
16 KiB
Go

package service
// Reference: P2E §2 — RBAC Service
// Provides role assignment, permission checking, custom role CRUD operations.
// This service layer bridges the middleware/auth policy system with the database models.
//
// NOTE: This package imports both internal/auth and internal/model. Since model does NOT
// import auth (to avoid circular dependency), the conversion between model permission maps
// and auth.PermissionMatrixMap happens here in the service layer.
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/model"
)
// RBACService provides RBAC operations: role assignment, permission checking,
// and custom role management.
type RBACService struct {
db *gorm.DB
}
// NewRBACService creates a new RBAC service with the given database.
func NewRBACService(db *gorm.DB) *RBACService {
return &RBACService{db: db}
}
// --- Conversion helpers ---
// Convert between model.PermissionDimension/PermissionLevel and auth.PermissionDimension/PermissionLevel
// These are string constants that have the same values, so direct string casting works.
func modelPermMapToAuthMatrix(m map[model.PermissionDimension]model.PermissionLevel) auth.PermissionMatrixMap {
result := auth.PermissionMatrixMap{}
for dim, level := range m {
result[auth.PermissionDimension(dim)] = auth.PermissionLevel(level)
}
return result
}
func authMatrixToModelPermMap(m auth.PermissionMatrixMap) map[model.PermissionDimension]model.PermissionLevel {
result := map[model.PermissionDimension]model.PermissionLevel{}
for dim, level := range m {
result[model.PermissionDimension(dim)] = model.PermissionLevel(level)
}
return result
}
// --- AccountUser Operations (Role Assignment) ---
// GetAccountUser retrieves the AccountUser record for a user in a specific account.
func (s *RBACService) GetAccountUser(userID, accountID uint) (*model.AccountUser, error) {
var au model.AccountUser
err := s.db.Where("user_id = ? AND account_id = ?", userID, accountID).
First(&au).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user %d is not a member of account %d", userID, accountID)
}
return nil, err
}
return &au, nil
}
// AddAccountUser adds a user to an account with a specified role.
// This corresponds to Chatwoot's AccountUser creation during invitation flow.
func (s *RBACService) AddAccountUser(userID, accountID uint, role string, customRoleID uint, invitedBy uint) (*model.AccountUser, error) {
// Validate role
if !isValidRole(role) {
return nil, fmt.Errorf("invalid role '%s': must be 'agent', 'administrator', or 'custom_role'", role)
}
// Check if user is already a member
existing, err := s.GetAccountUser(userID, accountID)
if err == nil && existing != nil {
return nil, fmt.Errorf("user %d is already a member of account %d", userID, accountID)
}
au := &model.AccountUser{
UserID: userID,
AccountID: accountID,
Role: normalizeAccountUserRole(role, customRoleID),
CustomRoleID: customRoleID,
Availability: "offline",
InvitedBy: invitedBy,
}
if err := s.db.Create(au).Error; err != nil {
return nil, err
}
return au, nil
}
// UpdateAccountUserRole changes a user's role in a specific account.
func (s *RBACService) UpdateAccountUserRole(userID, accountID uint, newRole string, customRoleID uint) (*model.AccountUser, error) {
if !isValidRole(newRole) {
return nil, fmt.Errorf("invalid role '%s'", newRole)
}
au, err := s.GetAccountUser(userID, accountID)
if err != nil {
return nil, err
}
au.Role = normalizeAccountUserRole(newRole, customRoleID)
au.CustomRoleID = customRoleID
if err := s.db.Save(au).Error; err != nil {
return nil, err
}
return au, nil
}
// RemoveAccountUser removes a user from an account (soft delete).
func (s *RBACService) RemoveAccountUser(userID, accountID uint) error {
result := s.db.Where("user_id = ? AND account_id = ?", userID, accountID).
Delete(&model.AccountUser{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("user %d is not a member of account %d", userID, accountID)
}
return nil
}
// ListAccountUsers retrieves all users in an account with their roles.
func (s *RBACService) ListAccountUsers(accountID uint) ([]model.AccountUser, error) {
var users []model.AccountUser
err := s.db.Where("account_id = ?", accountID).
Preload("User").
Find(&users).Error
return users, err
}
// ListUserAccounts retrieves all accounts a user belongs to with their roles.
func (s *RBACService) ListUserAccounts(userID uint) ([]model.AccountUser, error) {
var accounts []model.AccountUser
err := s.db.Where("user_id = ?", userID).
Preload("Account").
Find(&accounts).Error
return accounts, err
}
// UpdateAvailability updates a user's availability status in an account.
func (s *RBACService) UpdateAvailability(userID, accountID uint, availability string) error {
if !isValidAvailability(availability) {
return fmt.Errorf("invalid availability '%s': must be 'online', 'offline', or 'busy'", availability)
}
return s.db.Model(&model.AccountUser{}).
Where("user_id = ? AND account_id = ?", userID, accountID).
Update("availability", availability).Error
}
// --- Custom Role Operations (Enterprise) ---
// CreateCustomRole creates a new custom enterprise role.
func (s *RBACService) CreateCustomRole(accountID uint, name string, permissions auth.PermissionMatrixMap, description string) (*model.CustomRole, error) {
cr := &model.CustomRole{
AccountID: accountID,
Name: name,
Description: description,
}
// Convert auth.PermissionMatrixMap to model permission map for storage
modelPermMap := authMatrixToModelPermMap(permissions)
if err := cr.SetPermissionMap(modelPermMap); err != nil {
return nil, fmt.Errorf("failed to serialize permissions: %w", err)
}
if err := s.db.Create(cr).Error; err != nil {
return nil, err
}
return cr, nil
}
// GetCustomRole retrieves a custom role by ID.
func (s *RBACService) GetCustomRole(customRoleID uint) (*model.CustomRole, error) {
var cr model.CustomRole
err := s.db.Where("id = ?", customRoleID).First(&cr).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("custom role %d not found", customRoleID)
}
return nil, err
}
return &cr, nil
}
// GetCustomRolePermissionMatrix retrieves the auth.PermissionMatrixMap for a custom role.
// This is the key conversion method that bridges the model (raw JSONB) with auth (PermissionMatrix).
func (s *RBACService) GetCustomRolePermissionMatrix(customRoleID uint) (auth.PermissionMatrixMap, error) {
cr, err := s.GetCustomRole(customRoleID)
if err != nil {
return nil, err
}
modelMap, err := cr.GetPermissionMap()
if err != nil {
return nil, err
}
return modelPermMapToAuthMatrix(modelMap), nil
}
// UpdateCustomRole updates a custom role's name, permissions, or description.
func (s *RBACService) UpdateCustomRole(customRoleID uint, name string, permissions auth.PermissionMatrixMap, description string) (*model.CustomRole, error) {
cr, err := s.GetCustomRole(customRoleID)
if err != nil {
return nil, err
}
if name != "" {
cr.Name = name
}
if permissions != nil {
modelPermMap := authMatrixToModelPermMap(permissions)
if err := cr.SetPermissionMap(modelPermMap); err != nil {
return nil, fmt.Errorf("failed to serialize permissions: %w", err)
}
}
if description != "" {
cr.Description = description
}
if err := s.db.Save(cr).Error; err != nil {
return nil, err
}
return cr, nil
}
// DeleteCustomRole deletes a custom role (soft delete).
// All AccountUsers referencing this role will be downgraded to agent role.
func (s *RBACService) DeleteCustomRole(customRoleID uint) error {
// Downgrade all account users with this custom role to agent
err := s.db.Model(&model.AccountUser{}).
Where("custom_role_id = ?", customRoleID).
Updates(map[string]interface{}{
"role": "agent",
"custom_role_id": 0,
}).Error
if err != nil {
return err
}
// Soft delete the custom role
result := s.db.Delete(&model.CustomRole{}, customRoleID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("custom role %d not found", customRoleID)
}
return nil
}
// ListCustomRoles retrieves all custom roles for an account.
func (s *RBACService) ListCustomRoles(accountID uint) ([]model.CustomRole, error) {
var roles []model.CustomRole
err := s.db.Where("account_id = ?", accountID).Find(&roles).Error
return roles, err
}
// --- Permission Checking Operations ---
// BuildPolicyContext constructs a full PolicyContext for a user in an account.
// This is the primary method for creating the policy context that middleware and
// handlers use for permission checks.
func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyContext, error) {
au, err := s.GetAccountUser(userID, accountID)
if err != nil {
return nil, err
}
permissions := auth.PermissionMatrixMap{}
// Load permissions based on role. Chatwoot keeps AccountUser.role as
// agent/administrator; custom-role behavior comes from custom_role_id.
effectiveRole := au.Role
switch au.Role {
case "administrator":
permissions = auth.AdministratorPermissions
case "agent":
if au.CustomRoleID > 0 {
pm, err := s.GetCustomRolePermissionMatrix(au.CustomRoleID)
if err != nil {
// Fallback to agent defaults if custom role not found
permissions = auth.AgentDefaultPermissions
} else {
permissions = pm
effectiveRole = "custom_role"
}
} else {
permissions = auth.AgentDefaultPermissions
}
case "custom_role":
effectiveRole = "custom_role"
if au.CustomRoleID > 0 {
pm, err := s.GetCustomRolePermissionMatrix(au.CustomRoleID)
if err != nil {
permissions = auth.AgentDefaultPermissions
} else {
permissions = pm
}
} else {
permissions = auth.AgentDefaultPermissions
}
}
return auth.NewPolicyContext(userID, accountID, effectiveRole, au.CustomRoleID, permissions), nil
}
// CanPerform checks if a user can perform an action on a resource in an account.
// Convenience method that builds a PolicyContext and calls Can().
func (s *RBACService) CanPerform(userID, accountID uint, action, resource string) (bool, error) {
pc, err := s.BuildPolicyContext(userID, accountID)
if err != nil {
return false, err
}
return pc.Can(action, resource), nil
}
// ScopeQuery returns a scoped GORM query filtered by the user's permissions.
func (s *RBACService) ScopeQuery(userID, accountID uint, resource string) (*gorm.DB, error) {
pc, err := s.BuildPolicyContext(userID, accountID)
if err != nil {
return nil, err
}
return pc.Scope(s.db, resource), nil
}
// --- PlatformApp Operations ---
// CreatePlatformApp creates a new platform application.
func (s *RBACService) CreatePlatformApp(name string, accountID uint, appType string, description string) (*model.PlatformApp, error) {
pa := &model.PlatformApp{
Name: name,
AccountID: &accountID,
Description: description,
Type: appType,
Status: "active",
}
if err := s.db.Create(pa).Error; err != nil {
return nil, err
}
return pa, nil
}
// GetPlatformApp retrieves a platform app by ID.
func (s *RBACService) GetPlatformApp(id uint) (*model.PlatformApp, error) {
var pa model.PlatformApp
err := s.db.Where("id = ?", id).First(&pa).Error
if err != nil {
return nil, err
}
return &pa, nil
}
// GetPlatformAppByAccessToken retrieves a platform app by its AccessToken.
func (s *RBACService) GetPlatformAppByAccessToken(tokenPrefix string) (*model.PlatformApp, error) {
var token model.AccessToken
if err := s.db.Where("token_prefix = ? AND owner_type = ? AND deleted_at IS NULL", tokenPrefix, model.AccessTokenOwnerTypePlatformApp).First(&token).Error; err != nil {
return nil, err
}
var pa model.PlatformApp
if err := s.db.Where("id = ? AND status = 'active'", token.OwnerID).First(&pa).Error; err != nil {
return nil, err
}
return &pa, nil
}
// UpdatePlatformApp updates a platform app's details.
func (s *RBACService) UpdatePlatformApp(id uint, name string, description string, status string) (*model.PlatformApp, error) {
pa, err := s.GetPlatformApp(id)
if err != nil {
return nil, err
}
if name != "" {
pa.Name = name
}
if description != "" {
pa.Description = description
}
if status != "" {
pa.Status = status
}
if err := s.db.Save(pa).Error; err != nil {
return nil, err
}
return pa, nil
}
// DeletePlatformApp soft-deletes a platform app.
func (s *RBACService) DeletePlatformApp(id uint) error {
result := s.db.Delete(&model.PlatformApp{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("platform app %d not found", id)
}
return nil
}
// RegenerateAccessToken generates a new AccessToken for a platform app.
func (s *RBACService) RegenerateAccessToken(id uint) (string, error) {
pa, err := s.GetPlatformApp(id)
if err != nil {
return "", err
}
newKey, err := generateAPIKey()
if err != nil {
return "", err
}
// Create a new AccessToken for the platform app
tokenPrefix := newKey[:8]
token := &model.AccessToken{
OwnerType: model.AccessTokenOwnerTypePlatformApp,
OwnerID: pa.ID,
Token: newKey,
TokenPrefix: tokenPrefix,
Name: "PlatformApp API Token",
}
if err := s.db.Create(token).Error; err != nil {
return "", err
}
return newKey, nil
}
// ListPlatformApps retrieves all platform apps, optionally filtered by account.
func (s *RBACService) ListPlatformApps(accountID uint) ([]model.PlatformApp, error) {
var apps []model.PlatformApp
query := s.db
if accountID > 0 {
query = query.Where("account_id = ?", accountID)
}
err := query.Find(&apps).Error
return apps, err
}
// --- Helper Functions ---
func isValidRole(role string) bool {
return role == "agent" || role == "administrator" || role == "custom_role"
}
func normalizeAccountUserRole(role string, customRoleID uint) string {
if role == "custom_role" || (role == "" && customRoleID > 0) {
return "agent"
}
return role
}
func isValidAvailability(avail string) bool {
return avail == "online" || avail == "offline" || avail == "busy"
}
// generateAPIKey creates a secure random API key for platform apps.
func generateAPIKey() (string, error) {
bytes := make([]byte, 32) // 256-bit key
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return "gchat_" + hex.EncodeToString(bytes), nil
}
// --- RBACLookup Interface Implementation ---
// These methods make RBACService implement the middleware.RBACLookup interface
// so it can be used with AccountScopeWithService middleware.
// Reference: middleware/account_scope.go — RBACLookup interface
// GetAccountUserRole returns a middleware.AccountUserRole DTO from the AccountUser model.
// This implements the middleware.RBACLookup interface.
func (s *RBACService) GetAccountUserRole(userID, accountID uint) (*AccountUserRole, error) {
au, err := s.GetAccountUser(userID, accountID)
if err != nil {
return nil, err
}
return &AccountUserRole{
UserID: au.UserID,
AccountID: au.AccountID,
Role: au.Role,
CustomRoleID: au.CustomRoleID,
Availability: au.Availability,
}, nil
}
// GetCustomRolePermissions returns the auth.PermissionMatrixMap for a custom role.
// This implements the middleware.RBACLookup interface.
func (s *RBACService) GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) {
return s.GetCustomRolePermissionMatrix(customRoleID)
}
// AccountUserRole is a DTO that mirrors middleware.AccountUserRole.
// Both have the same fields; the service populates this from model.AccountUser
// and middleware reads it. We define it here so the service can return it
// without importing the middleware package (which would create a cycle).
type AccountUserRole struct {
UserID uint
AccountID uint
Role string
CustomRoleID uint
Availability string
}