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: role, 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 = newRole 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 switch au.Role { case "administrator": permissions = auth.AdministratorPermissions case "agent": permissions = auth.AgentDefaultPermissions case "custom_role": 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 } } else { permissions = auth.AgentDefaultPermissions } } return auth.NewPolicyContext(userID, accountID, au.Role, 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 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 }