From 6da48504fa81922e9dc3b910006321c7d7397018 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Tue, 19 Aug 2025 21:29:37 +0700 Subject: [PATCH] add role based permissions --- internal/contract/rbac_contract.go | 54 +++++- internal/entities/module.go | 18 ++ internal/entities/rbac.go | 13 +- internal/handler/rbac_handler.go | 45 +++++ internal/repository/rbac_repository.go | 80 ++++++++- internal/router/health_handler.go | 5 + internal/router/router.go | 8 + internal/service/letter_outgoing_service.go | 1 - internal/service/rbac_service.go | 162 +++++++++++++++++- internal/transformer/common_transformer.go | 7 +- ...14_modules_and_permissions_update.down.sql | 15 ++ ...0014_modules_and_permissions_update.up.sql | 65 +++++++ 12 files changed, 461 insertions(+), 12 deletions(-) create mode 100644 internal/entities/module.go create mode 100644 migrations/000014_modules_and_permissions_update.down.sql create mode 100644 migrations/000014_modules_and_permissions_update.up.sql diff --git a/internal/contract/rbac_contract.go b/internal/contract/rbac_contract.go index ecc286c..8ac60c1 100644 --- a/internal/contract/rbac_contract.go +++ b/internal/contract/rbac_contract.go @@ -9,9 +9,8 @@ import ( type PermissionResponse struct { ID uuid.UUID `json:"id"` Code string `json:"code"` + Action string `json:"action,omitempty"` Description *string `json:"description,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` } type CreatePermissionRequest struct { @@ -55,3 +54,54 @@ type UpdateRoleRequest struct { type ListRolesResponse struct { Roles []RoleWithPermissionsResponse `json:"roles"` } + +// Module contracts +type ModuleResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code"` +} + +type ModuleWithPermissionsResponse struct { + Module ModuleResponse `json:"module"` + Permissions []PermissionResponse `json:"permissions"` +} + +type PermissionsGroupedResponse struct { + Data []ModuleWithPermissionsResponse `json:"data"` +} + +// New Role contracts for the required API +type ModulePermissionInput struct { + Module string `json:"module" binding:"required"` + Actions []string `json:"actions" binding:"required"` +} + +type CreateOrUpdateRoleRequest struct { + Name string `json:"name" binding:"required"` + Code string `json:"code" binding:"required"` + Description string `json:"description,omitempty"` + Permissions []ModulePermissionInput `json:"permissions" binding:"required"` +} + +type RoleDetailResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Permissions []RolePermissionModuleResponse `json:"permissions"` +} + +type RolePermissionModuleResponse struct { + Module ModuleResponse `json:"module"` + Actions []PermissionActionResponse `json:"actions"` +} + +type PermissionActionResponse struct { + ID uuid.UUID `json:"id"` + Action string `json:"action"` + Code string `json:"code"` + Description string `json:"description,omitempty"` +} diff --git a/internal/entities/module.go b/internal/entities/module.go new file mode 100644 index 0000000..b8704ce --- /dev/null +++ b/internal/entities/module.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type Module struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Code string `gorm:"uniqueIndex;not null" json:"code"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + Permissions []Permission `gorm:"foreignKey:ModuleID" json:"permissions,omitempty"` +} + +func (Module) TableName() string { return "modules" } \ No newline at end of file diff --git a/internal/entities/rbac.go b/internal/entities/rbac.go index 0485771..60f7232 100644 --- a/internal/entities/rbac.go +++ b/internal/entities/rbac.go @@ -18,11 +18,14 @@ type Role struct { func (Role) TableName() string { return "roles" } type Permission struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Code string `gorm:"uniqueIndex;not null" json:"code"` - Description string `json:"description"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + ModuleID *uuid.UUID `gorm:"type:uuid" json:"module_id"` + Module *Module `gorm:"foreignKey:ModuleID" json:"module,omitempty"` + Action string `json:"action"` + Code string `gorm:"uniqueIndex;not null" json:"code"` + Description string `json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` } func (Permission) TableName() string { return "permissions" } diff --git a/internal/handler/rbac_handler.go b/internal/handler/rbac_handler.go index 21052fa..7efd797 100644 --- a/internal/handler/rbac_handler.go +++ b/internal/handler/rbac_handler.go @@ -20,6 +20,11 @@ type RBACService interface { UpdateRole(ctx context.Context, id uuid.UUID, req *contract.UpdateRoleRequest) (*contract.RoleWithPermissionsResponse, error) DeleteRole(ctx context.Context, id uuid.UUID) error ListRoles(ctx context.Context) (*contract.ListRolesResponse, error) + + // New methods + GetPermissionsGrouped(ctx context.Context) (*contract.PermissionsGroupedResponse, error) + CreateOrUpdateRole(ctx context.Context, req *contract.CreateOrUpdateRoleRequest) (*contract.RoleDetailResponse, error) + GetRoleDetail(ctx context.Context, roleID uuid.UUID) (*contract.RoleDetailResponse, error) } type RBACHandler struct{ svc RBACService } @@ -135,3 +140,43 @@ func (h *RBACHandler) ListRoles(c *gin.Context) { } c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) } + +// New handlers for the required API endpoints +func (h *RBACHandler) GetPermissionsGrouped(c *gin.Context) { + resp, err := h.svc.GetPermissionsGrouped(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *RBACHandler) CreateOrUpdateRole(c *gin.Context) { + var req contract.CreateOrUpdateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body: " + err.Error(), Code: 400}) + return + } + + resp, err := h.svc.CreateOrUpdateRole(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *RBACHandler) GetRoleDetail(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) + return + } + + resp, err := h.svc.GetRoleDetail(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, resp) +} diff --git a/internal/repository/rbac_repository.go b/internal/repository/rbac_repository.go index f8b6e92..8a17814 100644 --- a/internal/repository/rbac_repository.go +++ b/internal/repository/rbac_repository.go @@ -27,7 +27,7 @@ func (r *RBACRepository) DeletePermission(ctx context.Context, id uuid.UUID) err } func (r *RBACRepository) ListPermissions(ctx context.Context) ([]entities.Permission, error) { var perms []entities.Permission - if err := r.db.WithContext(ctx).Order("code ASC").Find(&perms).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("Module").Order("code ASC").Find(&perms).Error; err != nil { return nil, err } return perms, nil @@ -86,6 +86,7 @@ func (r *RBACRepository) SetRolePermissionsByCodes(ctx context.Context, roleID u func (r *RBACRepository) GetPermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]entities.Permission, error) { var perms []entities.Permission if err := r.db.WithContext(ctx). + Preload("Module"). Table("permissions p"). Select("p.*"). Joins("JOIN role_permissions rp ON rp.permission_id = p.id"). @@ -95,3 +96,80 @@ func (r *RBACRepository) GetPermissionsByRoleID(ctx context.Context, roleID uuid } return perms, nil } + +// Modules +func (r *RBACRepository) CreateModule(ctx context.Context, m *entities.Module) error { + return r.db.WithContext(ctx).Create(m).Error +} + +func (r *RBACRepository) UpdateModule(ctx context.Context, m *entities.Module) error { + return r.db.WithContext(ctx).Model(&entities.Module{}).Where("id = ?", m.ID).Updates(m).Error +} + +func (r *RBACRepository) DeleteModule(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.Module{}, "id = ?", id).Error +} + +func (r *RBACRepository) ListModules(ctx context.Context) ([]entities.Module, error) { + var modules []entities.Module + if err := r.db.WithContext(ctx).Order("name ASC").Find(&modules).Error; err != nil { + return nil, err + } + return modules, nil +} + +func (r *RBACRepository) GetModuleByCode(ctx context.Context, code string) (*entities.Module, error) { + var m entities.Module + if err := r.db.WithContext(ctx).First(&m, "code = ?", code).Error; err != nil { + return nil, err + } + return &m, nil +} + +func (r *RBACRepository) GetModuleByID(ctx context.Context, id uuid.UUID) (*entities.Module, error) { + var m entities.Module + if err := r.db.WithContext(ctx).First(&m, "id = ?", id).Error; err != nil { + return nil, err + } + return &m, nil +} + +func (r *RBACRepository) GetPermissionsGroupedByModule(ctx context.Context) ([]entities.Module, error) { + var modules []entities.Module + if err := r.db.WithContext(ctx). + Preload("Permissions", func(db *gorm.DB) *gorm.DB { + return db.Order("action ASC") + }). + Find(&modules).Error; err != nil { + return nil, err + } + return modules, nil +} + +func (r *RBACRepository) GetRoleByID(ctx context.Context, id uuid.UUID) (*entities.Role, error) { + var role entities.Role + if err := r.db.WithContext(ctx).First(&role, "id = ?", id).Error; err != nil { + return nil, err + } + return &role, nil +} + +func (r *RBACRepository) SetRolePermissionsByIDs(ctx context.Context, roleID uuid.UUID, permissionIDs []uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Delete existing permissions + if err := tx.WithContext(ctx).Where("role_id = ?", roleID).Delete(&entities.RolePermission{}).Error; err != nil { + return err + } + + // Add new permissions + if len(permissionIDs) == 0 { + return nil + } + + pairs := make([]entities.RolePermission, 0, len(permissionIDs)) + for _, permID := range permissionIDs { + pairs = append(pairs, entities.RolePermission{RoleID: roleID, PermissionID: permID}) + } + return tx.WithContext(ctx).Create(&pairs).Error + }) +} diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 8445d92..585a6b8 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -31,6 +31,11 @@ type RBACHandler interface { UpdateRole(c *gin.Context) DeleteRole(c *gin.Context) ListRoles(c *gin.Context) + + // New methods + GetPermissionsGrouped(c *gin.Context) + CreateOrUpdateRole(c *gin.Context) + GetRoleDetail(c *gin.Context) } type MasterHandler interface { diff --git a/internal/router/router.go b/internal/router/router.go index 8fa388e..a264a6d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -113,6 +113,14 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { rbac.DELETE("/roles/:id", r.rbacHandler.DeleteRole) } + roles := v1.Group("/roles") + roles.Use(r.authMiddleware.RequireAuth()) + { + roles.POST("", r.rbacHandler.CreateOrUpdateRole) + roles.GET("/permissions", r.rbacHandler.GetPermissionsGrouped) + roles.GET("/:id", r.rbacHandler.GetRoleDetail) + } + master := v1.Group("/master") master.Use(r.authMiddleware.RequireAuth()) { diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go index e002863..63223ce 100644 --- a/internal/service/letter_outgoing_service.go +++ b/internal/service/letter_outgoing_service.go @@ -874,7 +874,6 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi } } - // Include Approvals if loaded if len(letter.Approvals) > 0 { resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals)) for i, approval := range letter.Approvals { diff --git a/internal/service/rbac_service.go b/internal/service/rbac_service.go index ea8d4da..143f338 100644 --- a/internal/service/rbac_service.go +++ b/internal/service/rbac_service.go @@ -28,7 +28,12 @@ func (s *RBACServiceImpl) CreatePermission(ctx context.Context, req *contract.Cr if err := s.repo.CreatePermission(ctx, p); err != nil { return nil, err } - return &contract.PermissionResponse{ID: p.ID, Code: p.Code, Description: &p.Description, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt}, nil + return &contract.PermissionResponse{ + ID: p.ID, + Code: p.Code, + Action: p.Action, + Description: &p.Description, + }, nil } func (s *RBACServiceImpl) UpdatePermission(ctx context.Context, id uuid.UUID, req *contract.UpdatePermissionRequest) (*contract.PermissionResponse, error) { p := &entities.Permission{ID: id} @@ -48,7 +53,12 @@ func (s *RBACServiceImpl) UpdatePermission(ctx context.Context, id uuid.UUID, re } for _, x := range perms { if x.ID == id { - return &contract.PermissionResponse{ID: x.ID, Code: x.Code, Description: &x.Description, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt}, nil + return &contract.PermissionResponse{ + ID: x.ID, + Code: x.Code, + Action: x.Action, + Description: &x.Description, + }, nil } } return nil, nil @@ -126,3 +136,151 @@ func (s *RBACServiceImpl) ListRoles(ctx context.Context) (*contract.ListRolesRes } return &contract.ListRolesResponse{Roles: out}, nil } + +// New methods for the required API endpoints +func (s *RBACServiceImpl) GetPermissionsGrouped(ctx context.Context) (*contract.PermissionsGroupedResponse, error) { + modules, err := s.repo.ListModules(ctx) + if err != nil { + return nil, err + } + + result := make([]contract.ModuleWithPermissionsResponse, 0, len(modules)) + for _, module := range modules { + perms, err := s.repo.ListPermissions(ctx) + if err != nil { + return nil, err + } + + modulePerms := make([]contract.PermissionResponse, 0) + for _, perm := range perms { + if perm.ModuleID != nil && *perm.ModuleID == module.ID { + modulePerms = append(modulePerms, contract.PermissionResponse{ + ID: perm.ID, + Code: perm.Code, + Action: perm.Action, + Description: &perm.Description, + }) + } + } + + result = append(result, contract.ModuleWithPermissionsResponse{ + Module: contract.ModuleResponse{ + ID: module.ID, + Name: module.Name, + Code: module.Code, + }, + Permissions: modulePerms, + }) + } + + return &contract.PermissionsGroupedResponse{Data: result}, nil +} + +func (s *RBACServiceImpl) CreateOrUpdateRole(ctx context.Context, req *contract.CreateOrUpdateRoleRequest) (*contract.RoleDetailResponse, error) { + // Check if role exists + existingRole, _ := s.repo.GetRoleByCode(ctx, req.Code) + + var role *entities.Role + if existingRole != nil { + // Update existing role + role = existingRole + role.Name = req.Name + role.Description = req.Description + if err := s.repo.UpdateRole(ctx, role); err != nil { + return nil, err + } + } else { + // Create new role + role = &entities.Role{ + Name: req.Name, + Code: req.Code, + Description: req.Description, + } + if err := s.repo.CreateRole(ctx, role); err != nil { + return nil, err + } + } + + // Set permissions based on module and actions + permissionIDs := make([]uuid.UUID, 0) + for _, modPerm := range req.Permissions { + _, err := s.repo.GetModuleByCode(ctx, modPerm.Module) + if err != nil { + continue // Skip if module not found + } + + for _, action := range modPerm.Actions { + permCode := modPerm.Module + "_" + action + perm, err := s.repo.GetPermissionByCode(ctx, permCode) + if err == nil && perm != nil { + permissionIDs = append(permissionIDs, perm.ID) + } + } + } + + if err := s.repo.SetRolePermissionsByIDs(ctx, role.ID, permissionIDs); err != nil { + return nil, err + } + + // Build response + return s.GetRoleDetail(ctx, role.ID) +} + +func (s *RBACServiceImpl) GetRoleDetail(ctx context.Context, roleID uuid.UUID) (*contract.RoleDetailResponse, error) { + role, err := s.repo.GetRoleByID(ctx, roleID) + if err != nil { + return nil, err + } + + permissions, err := s.repo.GetPermissionsByRoleID(ctx, roleID) + if err != nil { + return nil, err + } + + // Group permissions by module + moduleMap := make(map[uuid.UUID]*contract.RolePermissionModuleResponse) + + for _, perm := range permissions { + if perm.ModuleID == nil { + continue + } + + if _, exists := moduleMap[*perm.ModuleID]; !exists { + if perm.Module != nil { + moduleMap[*perm.ModuleID] = &contract.RolePermissionModuleResponse{ + Module: contract.ModuleResponse{ + ID: perm.Module.ID, + Name: perm.Module.Name, + Code: perm.Module.Code, + }, + Actions: []contract.PermissionActionResponse{}, + } + } + } + + if modResp, exists := moduleMap[*perm.ModuleID]; exists { + modResp.Actions = append(modResp.Actions, contract.PermissionActionResponse{ + ID: perm.ID, + Action: perm.Action, + Code: perm.Code, + Description: perm.Description, + }) + } + } + + // Convert map to slice + permissionModules := make([]contract.RolePermissionModuleResponse, 0, len(moduleMap)) + for _, modResp := range moduleMap { + permissionModules = append(permissionModules, *modResp) + } + + return &contract.RoleDetailResponse{ + ID: role.ID, + Name: role.Name, + Code: role.Code, + Description: role.Description, + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + Permissions: permissionModules, + }, nil +} diff --git a/internal/transformer/common_transformer.go b/internal/transformer/common_transformer.go index a5681b3..919932f 100644 --- a/internal/transformer/common_transformer.go +++ b/internal/transformer/common_transformer.go @@ -178,7 +178,12 @@ func TitlesToContract(titles []entities.Title) []contract.TitleResponse { func PermissionsToContract(perms []entities.Permission) []contract.PermissionResponse { out := make([]contract.PermissionResponse, 0, len(perms)) for _, p := range perms { - out = append(out, contract.PermissionResponse{ID: p.ID, Code: p.Code, Description: &p.Description, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt}) + out = append(out, contract.PermissionResponse{ + ID: p.ID, + Code: p.Code, + Action: p.Action, + Description: &p.Description, + }) } return out } diff --git a/migrations/000014_modules_and_permissions_update.down.sql b/migrations/000014_modules_and_permissions_update.down.sql new file mode 100644 index 0000000..1e4f720 --- /dev/null +++ b/migrations/000014_modules_and_permissions_update.down.sql @@ -0,0 +1,15 @@ +-- Rollback modules and permissions update + +-- Remove the new structured permissions +DELETE FROM permissions WHERE code LIKE '%_READ' OR code LIKE '%_WRITE' OR code LIKE '%_CREATE' OR code LIKE '%_DELETE'; + +-- Drop indexes +DROP INDEX IF EXISTS idx_permissions_module_id; + +-- Remove columns from permissions table +ALTER TABLE permissions + DROP COLUMN IF EXISTS module_id, + DROP COLUMN IF EXISTS action; + +-- Drop modules table +DROP TABLE IF EXISTS modules CASCADE; \ No newline at end of file diff --git a/migrations/000014_modules_and_permissions_update.up.sql b/migrations/000014_modules_and_permissions_update.up.sql new file mode 100644 index 0000000..7b29850 --- /dev/null +++ b/migrations/000014_modules_and_permissions_update.up.sql @@ -0,0 +1,65 @@ +-- Add modules table and update permissions structure + +-- Create modules table +CREATE TABLE IF NOT EXISTS modules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + code TEXT UNIQUE NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER trg_modules_updated_at + BEFORE UPDATE ON modules + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Add module_id and action columns to permissions table +ALTER TABLE permissions + ADD COLUMN IF NOT EXISTS module_id UUID REFERENCES modules(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS action TEXT; + +-- Create index on module_id for better query performance +CREATE INDEX IF NOT EXISTS idx_permissions_module_id ON permissions(module_id); + +-- Seed initial modules +INSERT INTO modules (name, code) VALUES + ('User Management', 'USER_MANAGEMENT'), + ('Content Management', 'CONTENT_MANAGEMENT'), + ('Letter Management', 'LETTER_MANAGEMENT'), + ('Disposition Management', 'DISPOSITION_MANAGEMENT'), + ('Reporting', 'REPORTING'), + ('Settings', 'SETTINGS') +ON CONFLICT (code) DO NOTHING; + +-- Update existing permissions to include module_id and action +-- This is a sample mapping - adjust based on your existing permission codes +UPDATE permissions SET + module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), + action = 'READ' +WHERE code LIKE 'letter.%' AND code LIKE '%.view'; + +UPDATE permissions SET + module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), + action = 'WRITE' +WHERE code LIKE 'letter.%' AND code LIKE '%.edit'; + +UPDATE permissions SET + module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), + action = 'CREATE' +WHERE code LIKE 'letter.%' AND code LIKE '%.create'; + +UPDATE permissions SET + module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), + action = 'DELETE' +WHERE code LIKE 'letter.%' AND code LIKE '%.delete'; + +-- Insert new structured permissions for each module +INSERT INTO permissions (module_id, action, code, description) +SELECT + m.id, + a.action, + CONCAT(m.code, '_', a.action), + CONCAT('Can ', LOWER(a.action), ' ', LOWER(m.name)) +FROM modules m +CROSS JOIN (VALUES ('READ'), ('WRITE'), ('CREATE'), ('DELETE')) AS a(action) +ON CONFLICT (code) DO NOTHING; \ No newline at end of file