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.
524 lines
16 KiB
Go
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
|
|
}
|