feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.
This commit is contained in:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
@@ -0,0 +1,249 @@
|
||||
package role
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ListUseCase *useCases.ListRolesUseCase
|
||||
GetUseCase *useCases.GetRoleUseCase
|
||||
CreateUseCase *useCases.CreateRoleUseCase
|
||||
UpdateUseCase *useCases.UpdateRoleUseCase
|
||||
DeleteUseCase *useCases.DeleteRoleUseCase
|
||||
ReassignUseCase *useCases.ReassignRoleMembersUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListRolesUseCase, get *useCases.GetRoleUseCase, create *useCases.CreateRoleUseCase, update *useCases.UpdateRoleUseCase, deleteUseCase *useCases.DeleteRoleUseCase, reassign *useCases.ReassignRoleMembersUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, GetUseCase: get, CreateUseCase: create, UpdateUseCase: update, DeleteUseCase: deleteUseCase, ReassignUseCase: reassign}
|
||||
}
|
||||
|
||||
type GrantRequest struct {
|
||||
Key string `json:"key" validate:"required,max=100"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type PostRole struct {
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Description string `json:"description" validate:"max=255"`
|
||||
Grants []GrantRequest `json:"grants" validate:"dive"`
|
||||
}
|
||||
|
||||
type PutRole struct {
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Description string `json:"description" validate:"max=255"`
|
||||
Grants []GrantRequest `json:"grants" validate:"dive"`
|
||||
Version uint32 `json:"version" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type ResetRole struct {
|
||||
Version uint32 `json:"version" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type ReassignRole struct {
|
||||
TargetRoleID uint32 `json:"target_role_id" validate:"required"`
|
||||
}
|
||||
|
||||
func toGrants(requested []GrantRequest) []domain.Grant {
|
||||
grants := make([]domain.Grant, 0, len(requested))
|
||||
for _, grant := range requested {
|
||||
grants = append(grants, domain.Grant{Key: grant.Key, Scope: domain.Scope(grant.Scope)})
|
||||
}
|
||||
return grants
|
||||
}
|
||||
|
||||
func parseRoleId(r *http.Request) (uint32, error) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
func decodeRequest(w http.ResponseWriter, r *http.Request, request any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func readId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
|
||||
id, err := parseRoleId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type refusal struct {
|
||||
cause error
|
||||
status int
|
||||
message string
|
||||
}
|
||||
|
||||
var refusals = []refusal{
|
||||
{domain.ErrRoleNotFound, http.StatusNotFound, apperr.RoleNotFoundErrorMessage},
|
||||
{domain.ErrRoleLocked, http.StatusForbidden, apperr.RoleLockedErrorMessage},
|
||||
{domain.ErrBuiltInRole, http.StatusConflict, apperr.RoleBuiltInErrorMessage},
|
||||
{domain.ErrBuiltInName, http.StatusBadRequest, apperr.BuiltInNameErrorMessage},
|
||||
{domain.ErrNotBuiltIn, http.StatusBadRequest, apperr.NotBuiltInErrorMessage},
|
||||
{domain.ErrOwnRole, http.StatusForbidden, apperr.OwnRoleErrorMessage},
|
||||
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
|
||||
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
|
||||
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
|
||||
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
|
||||
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
|
||||
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
|
||||
{domain.ErrInvalidScope, http.StatusBadRequest, apperr.InvalidScopeErrorMessage},
|
||||
{domain.ErrOwnerOnlyGrant, http.StatusBadRequest, apperr.OwnerOnlyPermissionErrorMessage},
|
||||
{domain.ErrDuplicateGrant, http.StatusBadRequest, apperr.DuplicatePermissionErrorMessage},
|
||||
{domain.ErrUnknownContentType, http.StatusBadRequest, apperr.UnknownContentTypeErrorMessage},
|
||||
}
|
||||
|
||||
func writeRoleError(w http.ResponseWriter, err error) {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
var escalation domain.EscalationError
|
||||
if errors.As(err, &escalation) {
|
||||
w.Header().Set(apperr.EscalationHeader, escalation.Key)
|
||||
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
for _, candidate := range refusals {
|
||||
if errors.Is(err, candidate.cause) {
|
||||
http.Error(w, candidate.message, candidate.status)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
|
||||
listed, err := h.ListUseCase.List()
|
||||
if err != nil {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listed)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := readId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
found, err := h.GetUseCase.Get(id)
|
||||
if err != nil {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, found)
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var request PostRole
|
||||
if !decodeRequest(w, r, &request) {
|
||||
return
|
||||
}
|
||||
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateRoleCommand{
|
||||
Caller: access.CallerOf(r), Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants),
|
||||
})
|
||||
if err != nil {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := readId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request PutRole
|
||||
if !decodeRequest(w, r, &request) {
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(r.Context(), useCases.UpdateRoleCommand{
|
||||
Caller: access.CallerOf(r), ID: id, Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants), Version: request.Version,
|
||||
})
|
||||
if err != nil {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := readId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request ResetRole
|
||||
if !decodeRequest(w, r, &request) {
|
||||
return
|
||||
}
|
||||
restored, err := h.UpdateUseCase.Reset(r.Context(), useCases.ResetRoleCommand{Caller: access.CallerOf(r), ID: id, Version: request.Version})
|
||||
if err != nil {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, restored)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := readId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteRoleCommand{Caller: access.CallerOf(r), ID: id}); err != nil {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Reassign(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := readId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request ReassignRole
|
||||
if !decodeRequest(w, r, &request) {
|
||||
return
|
||||
}
|
||||
moved, err := h.ReassignUseCase.Reassign(r.Context(), useCases.ReassignRoleMembersCommand{Caller: access.CallerOf(r), SourceID: id, TargetID: request.TargetRoleID})
|
||||
if err != nil {
|
||||
writeRoleError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"moved": moved})
|
||||
}
|
||||
Reference in new issue
Block a user