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
@@ -76,6 +76,12 @@ type UseCases struct {
|
||||
GetContentTypeUseCase *useCases.GetContentTypeUseCase
|
||||
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
|
||||
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
|
||||
ListRolesUseCase *useCases.ListRolesUseCase
|
||||
GetRoleUseCase *useCases.GetRoleUseCase
|
||||
CreateRoleUseCase *useCases.CreateRoleUseCase
|
||||
UpdateRoleUseCase *useCases.UpdateRoleUseCase
|
||||
DeleteRoleUseCase *useCases.DeleteRoleUseCase
|
||||
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
|
||||
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
|
||||
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
|
||||
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
|
||||
@@ -380,11 +386,17 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
|
||||
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, time.Now),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
|
||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
|
||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
|
||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.RoleRepo),
|
||||
ListRolesUseCase: useCases.NewListRolesUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
|
||||
GetRoleUseCase: useCases.NewGetRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
|
||||
CreateRoleUseCase: useCases.NewCreateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||
UpdateRoleUseCase: useCases.NewUpdateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder, time.Now),
|
||||
DeleteRoleUseCase: useCases.NewDeleteRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||
ReassignRoleMembersUseCase: useCases.NewReassignRoleMembersUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -100,3 +100,21 @@ const InvalidFieldErrorMessage = "invalid_field"
|
||||
const SchemaIncompatibleErrorMessage = "schema_incompatible"
|
||||
const EntryNotFoundErrorMessage = "entry_not_found"
|
||||
const InvalidEntryErrorMessage = "invalid_entry"
|
||||
|
||||
const RoleNotFoundErrorMessage = "role_not_found"
|
||||
const RoleLockedErrorMessage = "role_locked"
|
||||
const RoleBuiltInErrorMessage = "role_built_in"
|
||||
const BuiltInNameErrorMessage = "built_in_name"
|
||||
const NotBuiltInErrorMessage = "not_built_in"
|
||||
const OwnRoleErrorMessage = "own_role"
|
||||
const EscalationErrorMessage = "escalation"
|
||||
const EscalationHeader = "X-Escalation-Key"
|
||||
const RoleNameTakenErrorMessage = "role_name_taken"
|
||||
const RoleConflictErrorMessage = "role_conflict"
|
||||
const RoleHasMembersErrorMessage = "role_has_members"
|
||||
const InvalidRoleNameErrorMessage = "invalid_role_name"
|
||||
const UnknownPermissionErrorMessage = "unknown_permission"
|
||||
const InvalidScopeErrorMessage = "invalid_scope"
|
||||
const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
|
||||
const DuplicatePermissionErrorMessage = "duplicate_permission"
|
||||
const UnknownContentTypeErrorMessage = "unknown_content_type"
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"RenewCMS/api/handlers/llms"
|
||||
"RenewCMS/api/handlers/media"
|
||||
"RenewCMS/api/handlers/permission"
|
||||
rolehandler "RenewCMS/api/handlers/role"
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/site"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
@@ -244,6 +245,39 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
roleHandler := rolehandler.NewHandler(container.ListRolesUseCase, container.GetRoleUseCase, container.CreateRoleUseCase, container.UpdateRoleUseCase, container.DeleteRoleUseCase, container.ReassignRoleMembersUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyRolesView))
|
||||
r.Get("/", roleHandler.List)
|
||||
r.Get("/{id}", roleHandler.Get)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyRolesManage))
|
||||
r.Post("/", roleHandler.Create)
|
||||
r.Put("/{id}", roleHandler.Update)
|
||||
r.Delete("/{id}", roleHandler.Delete)
|
||||
r.Post("/{id}/reassign", roleHandler.Reassign)
|
||||
r.Post("/{id}/reset", roleHandler.Reset)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -474,6 +508,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
|
||||
|
||||
Reference in new issue
Block a user