From 001d02c587f730c22ee4712b840c89d18ad82233 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Sat, 9 Aug 2025 15:28:25 +0700 Subject: [PATCH] Add user Roles --- internal/app/app.go | 10 +- internal/contract/rbac_contract.go | 57 +++++++++ internal/entities/role_permission.go | 10 ++ internal/handler/rbac_handler.go | 137 +++++++++++++++++++++ internal/repository/rbac_repository.go | 97 +++++++++++++++ internal/router/health_handler.go | 12 ++ internal/router/router.go | 16 +++ internal/service/rbac_service.go | 128 +++++++++++++++++++ internal/transformer/common_transformer.go | 20 +++ 9 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 internal/contract/rbac_contract.go create mode 100644 internal/entities/role_permission.go create mode 100644 internal/handler/rbac_handler.go create mode 100644 internal/repository/rbac_repository.go create mode 100644 internal/service/rbac_service.go diff --git a/internal/app/app.go b/internal/app/app.go index e2a78a8..2155bdd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -43,14 +43,16 @@ func (a *App) Initialize(cfg *config.Config) error { middlewares := a.initMiddleware(services) healthHandler := handler.NewHealthHandler() fileHandler := handler.NewFileHandler(services.fileService) + rbacHandler := handler.NewRBACHandler(services.rbacService) a.router = router.NewRouter( cfg, handler.NewAuthHandler(services.authService), middlewares.authMiddleware, healthHandler, - handler.NewUserHandler(services.userService, &validator.UserValidatorImpl{}), + handler.NewUserHandler(services.userService, validator.NewUserValidator()), fileHandler, + rbacHandler, ) return nil @@ -99,6 +101,7 @@ type repositories struct { userRepo *repository.UserRepositoryImpl userProfileRepo *repository.UserProfileRepository titleRepo *repository.TitleRepository + rbacRepo *repository.RBACRepository } func (a *App) initRepositories() *repositories { @@ -106,6 +109,7 @@ func (a *App) initRepositories() *repositories { userRepo: repository.NewUserRepository(a.db), userProfileRepo: repository.NewUserProfileRepository(a.db), titleRepo: repository.NewTitleRepository(a.db), + rbacRepo: repository.NewRBACRepository(a.db), } } @@ -123,6 +127,7 @@ type services struct { userService *service.UserServiceImpl authService *service.AuthServiceImpl fileService *service.FileServiceImpl + rbacService *service.RBACServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -137,10 +142,13 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con s3Client := client.NewFileClient(fileCfg) fileSvc := service.NewFileService(s3Client, processors.userProcessor, "profile", "documents") + rbacSvc := service.NewRBACService(repos.rbacRepo) + return &services{ userService: userSvc, authService: authService, fileService: fileSvc, + rbacService: rbacSvc, } } diff --git a/internal/contract/rbac_contract.go b/internal/contract/rbac_contract.go new file mode 100644 index 0000000..ecc286c --- /dev/null +++ b/internal/contract/rbac_contract.go @@ -0,0 +1,57 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type PermissionResponse struct { + ID uuid.UUID `json:"id"` + Code string `json:"code"` + Description *string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreatePermissionRequest struct { + Code string `json:"code"` // unique + Description *string `json:"description,omitempty"` +} + +type UpdatePermissionRequest struct { + Code *string `json:"code,omitempty"` + Description *string `json:"description,omitempty"` +} + +type ListPermissionsResponse struct { + Permissions []PermissionResponse `json:"permissions"` +} + +type RoleWithPermissionsResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Description *string `json:"description,omitempty"` + Permissions []PermissionResponse `json:"permissions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateRoleRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Description *string `json:"description,omitempty"` + PermissionCodes []string `json:"permission_codes,omitempty"` +} + +type UpdateRoleRequest struct { + Name *string `json:"name,omitempty"` + Code *string `json:"code,omitempty"` + Description *string `json:"description,omitempty"` + PermissionCodes *[]string `json:"permission_codes,omitempty"` +} + +type ListRolesResponse struct { + Roles []RoleWithPermissionsResponse `json:"roles"` +} diff --git a/internal/entities/role_permission.go b/internal/entities/role_permission.go new file mode 100644 index 0000000..e4bd9c5 --- /dev/null +++ b/internal/entities/role_permission.go @@ -0,0 +1,10 @@ +package entities + +import "github.com/google/uuid" + +type RolePermission struct { + RoleID uuid.UUID `gorm:"type:uuid;primaryKey" json:"role_id"` + PermissionID uuid.UUID `gorm:"type:uuid;primaryKey" json:"permission_id"` +} + +func (RolePermission) TableName() string { return "role_permissions" } diff --git a/internal/handler/rbac_handler.go b/internal/handler/rbac_handler.go new file mode 100644 index 0000000..21052fa --- /dev/null +++ b/internal/handler/rbac_handler.go @@ -0,0 +1,137 @@ +package handler + +import ( + "context" + "net/http" + + "eslogad-be/internal/contract" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type RBACService interface { + CreatePermission(ctx context.Context, req *contract.CreatePermissionRequest) (*contract.PermissionResponse, error) + UpdatePermission(ctx context.Context, id uuid.UUID, req *contract.UpdatePermissionRequest) (*contract.PermissionResponse, error) + DeletePermission(ctx context.Context, id uuid.UUID) error + ListPermissions(ctx context.Context) (*contract.ListPermissionsResponse, error) + + CreateRole(ctx context.Context, req *contract.CreateRoleRequest) (*contract.RoleWithPermissionsResponse, error) + 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) +} + +type RBACHandler struct{ svc RBACService } + +func NewRBACHandler(svc RBACService) *RBACHandler { return &RBACHandler{svc: svc} } + +func (h *RBACHandler) CreatePermission(c *gin.Context) { + var req contract.CreatePermissionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) + return + } + resp, err := h.svc.CreatePermission(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) +} + +func (h *RBACHandler) UpdatePermission(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 + } + var req contract.UpdatePermissionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) + return + } + resp, err := h.svc.UpdatePermission(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + +func (h *RBACHandler) DeletePermission(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 + } + if err := h.svc.DeletePermission(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "deleted"}) +} + +func (h *RBACHandler) ListPermissions(c *gin.Context) { + resp, err := h.svc.ListPermissions(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + +func (h *RBACHandler) CreateRole(c *gin.Context) { + var req contract.CreateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) + return + } + resp, err := h.svc.CreateRole(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) +} + +func (h *RBACHandler) UpdateRole(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 + } + var req contract.UpdateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) + return + } + resp, err := h.svc.UpdateRole(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + +func (h *RBACHandler) DeleteRole(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 + } + if err := h.svc.DeleteRole(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "deleted"}) +} + +func (h *RBACHandler) ListRoles(c *gin.Context) { + resp, err := h.svc.ListRoles(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} diff --git a/internal/repository/rbac_repository.go b/internal/repository/rbac_repository.go new file mode 100644 index 0000000..f8b6e92 --- /dev/null +++ b/internal/repository/rbac_repository.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + + "eslogad-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type RBACRepository struct { + db *gorm.DB +} + +func NewRBACRepository(db *gorm.DB) *RBACRepository { return &RBACRepository{db: db} } + +// Permissions +func (r *RBACRepository) CreatePermission(ctx context.Context, p *entities.Permission) error { + return r.db.WithContext(ctx).Create(p).Error +} +func (r *RBACRepository) UpdatePermission(ctx context.Context, p *entities.Permission) error { + return r.db.WithContext(ctx).Model(&entities.Permission{}).Where("id = ?", p.ID).Updates(p).Error +} +func (r *RBACRepository) DeletePermission(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.Permission{}, "id = ?", id).Error +} +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 { + return nil, err + } + return perms, nil +} +func (r *RBACRepository) GetPermissionByCode(ctx context.Context, code string) (*entities.Permission, error) { + var p entities.Permission + if err := r.db.WithContext(ctx).First(&p, "code = ?", code).Error; err != nil { + return nil, err + } + return &p, nil +} + +// Roles +func (r *RBACRepository) CreateRole(ctx context.Context, role *entities.Role) error { + return r.db.WithContext(ctx).Create(role).Error +} +func (r *RBACRepository) UpdateRole(ctx context.Context, role *entities.Role) error { + return r.db.WithContext(ctx).Model(&entities.Role{}).Where("id = ?", role.ID).Updates(role).Error +} +func (r *RBACRepository) DeleteRole(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.Role{}, "id = ?", id).Error +} +func (r *RBACRepository) ListRoles(ctx context.Context) ([]entities.Role, error) { + var roles []entities.Role + if err := r.db.WithContext(ctx).Order("name ASC").Find(&roles).Error; err != nil { + return nil, err + } + return roles, nil +} +func (r *RBACRepository) GetRoleByCode(ctx context.Context, code string) (*entities.Role, error) { + var role entities.Role + if err := r.db.WithContext(ctx).First(&role, "code = ?", code).Error; err != nil { + return nil, err + } + return &role, nil +} + +func (r *RBACRepository) SetRolePermissionsByCodes(ctx context.Context, roleID uuid.UUID, permCodes []string) error { + if err := r.db.WithContext(ctx).Where("role_id = ?", roleID).Delete(&entities.RolePermission{}).Error; err != nil { + return err + } + if len(permCodes) == 0 { + return nil + } + var perms []entities.Permission + if err := r.db.WithContext(ctx).Where("code IN ?", permCodes).Find(&perms).Error; err != nil { + return err + } + pairs := make([]entities.RolePermission, 0, len(perms)) + for _, p := range perms { + pairs = append(pairs, entities.RolePermission{RoleID: roleID, PermissionID: p.ID}) + } + return r.db.WithContext(ctx).Create(&pairs).Error +} + +func (r *RBACRepository) GetPermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]entities.Permission, error) { + var perms []entities.Permission + if err := r.db.WithContext(ctx). + Table("permissions p"). + Select("p.*"). + Joins("JOIN role_permissions rp ON rp.permission_id = p.id"). + Where("rp.role_id = ?", roleID). + Find(&perms).Error; err != nil { + return nil, err + } + return perms, nil +} diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 83d9ab2..26d49fa 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -18,3 +18,15 @@ type FileHandler interface { UploadProfileAvatar(c *gin.Context) UploadDocument(c *gin.Context) } + +type RBACHandler interface { + CreatePermission(c *gin.Context) + UpdatePermission(c *gin.Context) + DeletePermission(c *gin.Context) + ListPermissions(c *gin.Context) + + CreateRole(c *gin.Context) + UpdateRole(c *gin.Context) + DeleteRole(c *gin.Context) + ListRoles(c *gin.Context) +} diff --git a/internal/router/router.go b/internal/router/router.go index b839f4b..c57222b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -14,6 +14,7 @@ type Router struct { authMiddleware AuthMiddleware userHandler UserHandler fileHandler FileHandler + rbacHandler RBACHandler } func NewRouter( @@ -23,6 +24,7 @@ func NewRouter( healthHandler HealthHandler, userHandler UserHandler, fileHandler FileHandler, + rbacHandler RBACHandler, ) *Router { return &Router{ config: cfg, @@ -31,6 +33,7 @@ func NewRouter( healthHandler: healthHandler, userHandler: userHandler, fileHandler: fileHandler, + rbacHandler: rbacHandler, } } @@ -77,5 +80,18 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { files.POST("/documents", r.fileHandler.UploadDocument) } + + rbac := v1.Group("/rbac") + rbac.Use(r.authMiddleware.RequireAuth()) + { + rbac.GET("/permissions", r.rbacHandler.ListPermissions) + rbac.POST("/permissions", r.rbacHandler.CreatePermission) + rbac.PUT("/permissions/:id", r.rbacHandler.UpdatePermission) + rbac.DELETE("/permissions/:id", r.rbacHandler.DeletePermission) + rbac.GET("/roles", r.rbacHandler.ListRoles) + rbac.POST("/roles", r.rbacHandler.CreateRole) + rbac.PUT("/roles/:id", r.rbacHandler.UpdateRole) + rbac.DELETE("/roles/:id", r.rbacHandler.DeleteRole) + } } } diff --git a/internal/service/rbac_service.go b/internal/service/rbac_service.go new file mode 100644 index 0000000..ea8d4da --- /dev/null +++ b/internal/service/rbac_service.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +type RBACServiceImpl struct { + repo *repository.RBACRepository +} + +func NewRBACService(repo *repository.RBACRepository) *RBACServiceImpl { + return &RBACServiceImpl{repo: repo} +} + +// Permissions +func (s *RBACServiceImpl) CreatePermission(ctx context.Context, req *contract.CreatePermissionRequest) (*contract.PermissionResponse, error) { + p := &entities.Permission{Code: req.Code} + if req.Description != nil { + p.Description = *req.Description + } + 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 +} +func (s *RBACServiceImpl) UpdatePermission(ctx context.Context, id uuid.UUID, req *contract.UpdatePermissionRequest) (*contract.PermissionResponse, error) { + p := &entities.Permission{ID: id} + if req.Code != nil { + p.Code = *req.Code + } + if req.Description != nil { + p.Description = *req.Description + } + if err := s.repo.UpdatePermission(ctx, p); err != nil { + return nil, err + } + // fetch full row + perms, err := s.repo.ListPermissions(ctx) + if err != nil { + return nil, err + } + 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 nil, nil +} +func (s *RBACServiceImpl) DeletePermission(ctx context.Context, id uuid.UUID) error { + return s.repo.DeletePermission(ctx, id) +} +func (s *RBACServiceImpl) ListPermissions(ctx context.Context) (*contract.ListPermissionsResponse, error) { + perms, err := s.repo.ListPermissions(ctx) + if err != nil { + return nil, err + } + return &contract.ListPermissionsResponse{Permissions: transformer.PermissionsToContract(perms)}, nil +} + +// Roles +func (s *RBACServiceImpl) CreateRole(ctx context.Context, req *contract.CreateRoleRequest) (*contract.RoleWithPermissionsResponse, error) { + role := &entities.Role{Name: req.Name, Code: req.Code} + if req.Description != nil { + role.Description = *req.Description + } + if err := s.repo.CreateRole(ctx, role); err != nil { + return nil, err + } + if len(req.PermissionCodes) > 0 { + _ = s.repo.SetRolePermissionsByCodes(ctx, role.ID, req.PermissionCodes) + } + perms, _ := s.repo.GetPermissionsByRoleID(ctx, role.ID) + resp := transformer.RoleWithPermissionsToContract(*role, perms) + return &resp, nil +} +func (s *RBACServiceImpl) UpdateRole(ctx context.Context, id uuid.UUID, req *contract.UpdateRoleRequest) (*contract.RoleWithPermissionsResponse, error) { + role := &entities.Role{ID: id} + if req.Name != nil { + role.Name = *req.Name + } + if req.Code != nil { + role.Code = *req.Code + } + if req.Description != nil { + role.Description = *req.Description + } + if err := s.repo.UpdateRole(ctx, role); err != nil { + return nil, err + } + if req.PermissionCodes != nil { + _ = s.repo.SetRolePermissionsByCodes(ctx, id, *req.PermissionCodes) + } + perms, _ := s.repo.GetPermissionsByRoleID(ctx, id) + // fetch updated role + roles, err := s.repo.ListRoles(ctx) + if err != nil { + return nil, err + } + for _, r := range roles { + if r.ID == id { + resp := transformer.RoleWithPermissionsToContract(r, perms) + return &resp, nil + } + } + return nil, nil +} +func (s *RBACServiceImpl) DeleteRole(ctx context.Context, id uuid.UUID) error { + return s.repo.DeleteRole(ctx, id) +} +func (s *RBACServiceImpl) ListRoles(ctx context.Context) (*contract.ListRolesResponse, error) { + roles, err := s.repo.ListRoles(ctx) + if err != nil { + return nil, err + } + out := make([]contract.RoleWithPermissionsResponse, 0, len(roles)) + for _, r := range roles { + perms, _ := s.repo.GetPermissionsByRoleID(ctx, r.ID) + out = append(out, transformer.RoleWithPermissionsToContract(r, perms)) + } + return &contract.ListRolesResponse{Roles: out}, nil +} diff --git a/internal/transformer/common_transformer.go b/internal/transformer/common_transformer.go index 1db67bb..2e8d524 100644 --- a/internal/transformer/common_transformer.go +++ b/internal/transformer/common_transformer.go @@ -170,3 +170,23 @@ func TitlesToContract(titles []entities.Title) []contract.TitleResponse { } return out } + +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}) + } + return out +} + +func RoleWithPermissionsToContract(role entities.Role, perms []entities.Permission) contract.RoleWithPermissionsResponse { + return contract.RoleWithPermissionsResponse{ + ID: role.ID, + Name: role.Name, + Code: role.Code, + Description: &role.Description, + Permissions: PermissionsToContract(perms), + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + } +}