feat: Users API, ownership transfer and removal of the legacy role string

This commit is contained in:
Florian Sylvain committed 2026-10-27 03:08:15 +01:00
1 parent 094df63a7f
commit 80f7037d3a
124 files changed
+4543 -723

No files matched your search

+9 -5
View File
@@ -103,6 +103,8 @@ type UseCases struct {
CancelInviteUseCase *useCases.CancelInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
GetInviteUseCase *useCases.GetInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase SendMailUseCase *useCases.SendMailUseCase
@@ -406,7 +408,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now), UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo), DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
@@ -414,12 +416,14 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
+2 -9
View File
@@ -32,15 +32,8 @@ type meAuthor struct {
ArticleCount int `json:"article_count"` ArticleCount int `json:"article_count"`
} }
type meRole struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
type meAccess struct { type meAccess struct {
Role meRole `json:"role"` Role useCases.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"` Permissions []role.Grant `json:"permissions"`
} }
@@ -52,7 +45,7 @@ type meResponse struct {
func toMeAccess(access useCases.Access) meAccess { func toMeAccess(access useCases.Access) meAccess {
return meAccess{ return meAccess{
Role: meRole{ID: access.Role.ID, Name: access.Role.Name, BuiltIn: access.Role.BuiltIn, Locked: access.Role.Locked}, Role: useCases.NewRoleRef(access.Role),
Permissions: access.Permissions, Permissions: access.Permissions,
} }
} }
@@ -0,0 +1,68 @@
package ownership
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
TransferUseCase *useCases.TransferOwnershipUseCase
}
type TransferRequest struct {
UserID uint32 `json:"user_id" validate:"required"`
ConfirmEmail string `json:"confirm_email" validate:"required"`
}
func NewHandler(transfer *useCases.TransferOwnershipUseCase) *Handler {
return &Handler{TransferUseCase: transfer}
}
func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) {
var request TransferRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
transferred, err := h.TransferUseCase.Transfer(r.Context(), useCases.TransferOwnershipCommand{
Caller: access.CallerOf(r),
UserID: request.UserID,
ConfirmEmail: request.ConfirmEmail,
})
if err != nil {
writeTransferError(w, err)
return
}
body, err := json.Marshal(transferred)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func writeTransferError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrInvalidTransfer):
http.Error(w, apperr.InvalidTransferErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrConfirmationMismatch):
http.Error(w, apperr.ConfirmationMismatchErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+45 -17
View File
@@ -1,10 +1,12 @@
package user package user
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
@@ -25,6 +27,7 @@ type InviteHandler struct {
type CreateInviteRequest struct { type CreateInviteRequest struct {
Email string `json:"email" validate:"required,inviteemail"` Email string `json:"email" validate:"required,inviteemail"`
RoleID uint32 `json:"role_id"`
} }
type inviteResponse struct { type inviteResponse struct {
@@ -32,6 +35,7 @@ type inviteResponse struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Status string `json:"status"` Status string `json:"status"`
Role useCases.RoleRef `json:"role"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
@@ -40,6 +44,7 @@ type inviteListItem struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Role *useCases.RoleRef `json:"role"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -50,7 +55,7 @@ type userListItem struct {
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` Role *useCases.RoleRef `json:"role"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -83,8 +88,10 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
} }
created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{ created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
Caller: access.CallerOf(r),
Email: request.Email, Email: request.Email,
InvitedBy: userId, InvitedBy: userId,
RoleID: request.RoleID,
}) })
if err != nil { if err != nil {
writeCreateInviteError(w, err) writeCreateInviteError(w, err)
@@ -94,12 +101,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
writeInviteResponse(w, http.StatusCreated, created) writeInviteResponse(w, http.StatusCreated, created)
} }
func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) { func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.InviteView) {
body, err := json.Marshal(inviteResponse{ body, err := json.Marshal(inviteResponse{
ID: entry.ID, ID: entry.ID,
Kind: useCases.EntryKindInvite, Kind: useCases.EntryKindInvite,
Email: entry.Email, Email: entry.Email,
Status: string(entry.StatusAt(time.Now())), Status: string(entry.StatusAt(time.Now())),
Role: entry.Role,
CreatedAt: entry.CreatedAt, CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt, ExpiresAt: entry.ExpiresAt,
}) })
@@ -144,7 +152,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{ID: id, RenewedBy: userId}) renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{Caller: access.CallerOf(r), ID: id, RenewedBy: userId})
if err != nil { if err != nil {
writeManageInviteError(w, err) writeManageInviteError(w, err)
return return
@@ -152,7 +160,26 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
writeInviteResponse(w, http.StatusOK, renewed) writeInviteResponse(w, http.StatusOK, renewed)
} }
func writeRoleRefusal(w http.ResponseWriter, err error) bool {
var escalation role.EscalationError
switch {
case errors.As(err, &escalation):
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrRoleLocked):
http.Error(w, apperr.RoleLockedErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrUnknownRole):
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
default:
return false
}
return true
}
func writeManageInviteError(w http.ResponseWriter, err error) { func writeManageInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch { switch {
case errors.Is(err, invite.ErrInviteNotFound): case errors.Is(err, invite.ErrInviteNotFound):
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
@@ -168,6 +195,9 @@ func writeManageInviteError(w http.ResponseWriter, err error) {
} }
func writeCreateInviteError(w http.ResponseWriter, err error) { func writeCreateInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch { switch {
case errors.Is(err, invite.ErrEmailHasAccount): case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict) http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
@@ -183,22 +213,20 @@ func writeCreateInviteError(w http.ResponseWriter, err error) {
} }
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) { func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites() entries, err := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
}
body, err := json.Marshal(items)
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body) items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItemOf(entry))
}
writeNoStoreJSON(w, http.StatusOK, items)
} }
+88
View File
@@ -0,0 +1,88 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type RoleHandler struct {
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
}
type ChangeRoleRequest struct {
RoleID uint32 `json:"role_id" validate:"required"`
}
func NewRoleHandler(changeRole *useCases.ChangeUserRoleUseCase) *RoleHandler {
return &RoleHandler{ChangeUserRoleUseCase: changeRole}
}
func userListItemOf(entry useCases.DirectoryEntry) userListItem {
return userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt}
}
func writeNoStoreJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func (h *RoleHandler) Change(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request ChangeRoleRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
changed, err := h.ChangeUserRoleUseCase.ChangeRole(r.Context(), useCases.ChangeUserRoleCommand{
Caller: access.CallerOf(r),
TargetID: uint32(targetID),
RoleID: request.RoleID,
})
if err != nil {
writeChangeRoleError(w, err)
return
}
writeNoStoreJSON(w, http.StatusOK, userListItemOf(changed))
}
func writeChangeRoleError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, role.ErrOwnRole):
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+5
View File
@@ -118,3 +118,8 @@ const InvalidScopeErrorMessage = "invalid_scope"
const OwnerOnlyPermissionErrorMessage = "owner_only_permission" const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
const DuplicatePermissionErrorMessage = "duplicate_permission" const DuplicatePermissionErrorMessage = "duplicate_permission"
const UnknownContentTypeErrorMessage = "unknown_content_type" const UnknownContentTypeErrorMessage = "unknown_content_type"
const InvalidRoleErrorMessage = "invalid_role"
const UserRoleChangedErrorMessage = "user_role_changed"
const InvalidTransferErrorMessage = "invalid_transfer"
const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
-2
View File
@@ -13,7 +13,6 @@ type PublicUser struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Role user.Role `json:"role"`
} }
func New(u user.User) PublicUser { func New(u user.User) PublicUser {
@@ -25,6 +24,5 @@ func New(u user.User) PublicUser {
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt, UpdatedAt: u.UpdatedAt,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Role: u.Role,
} }
} }
+18
View File
@@ -13,6 +13,7 @@ import (
"RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/llms" "RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media" "RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/ownership"
"RenewCMS/api/handlers/permission" "RenewCMS/api/handlers/permission"
rolehandler "RenewCMS/api/handlers/role" rolehandler "RenewCMS/api/handlers/role"
"RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/settings"
@@ -164,7 +165,9 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
userHandler := user.NewHandler(container.UpdateUserProfileUseCase) userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase) lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
@@ -182,6 +185,18 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r.Delete("/invite/{id}", inviteHandler.CancelInvite) r.Delete("/invite/{id}", inviteHandler.CancelInvite)
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite) r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
}) })
})
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.KeyUsersChangeRole))
r.Put("/{id}/role", roleHandler.Change)
})
return r return r
} }
@@ -220,6 +235,8 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase) sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap) r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -241,6 +258,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update) r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault) r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete) r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
}) })
return r return r
} }
+68 -5
View File
@@ -33,7 +33,7 @@ for example `https://cms.example/v1/article/`.
| --- | --- | | --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) | | Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) | | Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings | | Settings | GET /v1/settings |
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap | | Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
| Authors | GET /v1/author/ | | Authors | GET /v1/author/ |
@@ -74,7 +74,7 @@ never changes the response or the change itself.
| --- | --- | | --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` | | `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` | | `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
| `authors` | `author.create`, `author.edit`, `author.delete` | | `authors` | `author.create`, `author.edit`, `author.delete` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
@@ -372,7 +372,8 @@ A role is `{ id, name, description, built_in, locked, version, grants, members,
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role `grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts `none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
users by their effective role (the legacy `owner` and `admin` strings count for the built-in role of that name). the users holding the role plus the open invites (pending or expired, never accepted ones) for it, counted in two
statements whatever the number of roles. A user whose `role_id` is 0 or names no role counts for nobody.
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role `updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted. never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
@@ -387,7 +388,7 @@ Refusals are plain text, one snake_case code per body:
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. | | 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. | | 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
| 404 | `role_not_found` | Unknown role id. | | 404 | `role_not_found` | Unknown role id. |
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members; deleting or moving members out of a built-in role. | | 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members (users or open invites); deleting or moving members out of a built-in role. |
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup, Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
@@ -397,13 +398,75 @@ at the same version and records nothing. Members see an edit on their next reque
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`, `{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` and is recorded only when `changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` (users and open invites together) and is recorded only when
`moved` is above 0. A refusal records nothing. `moved` is above 0. A refusal records nothing.
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard. `GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
creating one with the key of a deleted type clears any stale row for that key. creating one with the key of a deleted type clears any stale row for that key.
## Users, invites and ownership
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
`users.role` column of older databases stays in place, unused). `RoleRef` is the one role summary shape,
`{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items
(`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`,
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `author` and `access`.
| Route | Permission | Session only | Purpose |
| --- | --- | --- | --- |
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item. |
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
| `POST /v1/site/transfer-ownership` | `system.transfer_ownership` | yes | Body `{"user_id", "confirm_email"}` (both required). Answers 200 `{ "owner": { id, name }, "former_owner": { id, name, role } }`. |
The no-escalation rule applies to people as it does to roles: the caller must cover (`GrantSet.Covers`) the target's
current role and the new role on a role change, the role of an invite on create and renew. An API key may create and
renew invites, and the check then uses the live role of the key's creator. Nobody can give, take away or invite to the
Owner role: `PUT /v1/user/{id}/role` to or from it, and an invite for it, are 403 `role_locked`; only the transfer moves
the Owner role. Changing the role of your own account is 403 `own_role`. Giving the role a user already holds answers
200 and writes and records nothing. A role change keeps `token_version`, sessions and keys: the next request is decided
by the new grants. An unverified user may be given a role.
The order of checks on `PUT /v1/user/{id}/role` is: session, permission, id, body, target user, target holds a locked
role, `role_id` lookup, new role locked, own account, current role covered, new role covered, same role (no-op), write.
On `POST /v1/user/invite`: permission, rate limiter, body, inviter, role (default Writer), role locked, role covered,
email already has an account, pending invite, create, mail. On `POST /v1/user/invite/{id}/renew`: invite lookup (404),
already accepted (409), invite role covered, renew, mail. On `POST /v1/site/transfer-ownership`: session, permission,
body, target, `confirm_email`, write. Every refusal writes nothing, not even the deletion of an expired invite.
| Status | Body | When |
| --- | --- | --- |
| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. |
| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). |
| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). |
| 403 | `role_locked` | The target holds the Owner role, or the role to give is the Owner role. |
| 403 | `own_role` | The target of a role change is the caller's own account. |
| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. |
| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. |
| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. |
| 409 | `user_role_changed` | The target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also
under two simultaneous transfers (one answers 200, the other 409).
Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept
time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing.
Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500.
A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on
`GET /v1/auth/me`; it is never promoted.
Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before,
after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`,
`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and
`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a
no-op record nothing; a storage failure at the write records the same event with outcome `failed`.
## Content permissions ## Content permissions
Articles and the entries of custom content types are checked against the `content.*` permissions of the Articles and the entries of custom content types are checked against the `content.*` permissions of the
+5 -4
View File
@@ -15,7 +15,7 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. | | Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. | | Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. | | Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. | | Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the role backfills (legacy `users.role` into `role_id` while that column exists, invites without a role to Admin, then `created_by`). Only `sqlite` exists today. |
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. | | Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. | | Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. | | Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
@@ -27,7 +27,8 @@ Nothing under `internal/` imports `api/`.
| Package | Role | | Package | Role |
|---|---| |---|---|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). | | `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`), the users list with invitations (`CreateInvite`, `ListUsers`) and the role change (`RoleHandler.Change`). |
| `api/handlers/ownership` | `POST /v1/site/transfer-ownership`, built by `NewHandler(TransferOwnershipUseCase)` and mounted by `NewSiteRouter` in the session group. |
| `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. | | `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. | | `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. | | `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
@@ -113,9 +114,9 @@ At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMe
### Roles and permissions ### Roles and permissions
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers` and `RemoveTypedGrants`; there is no separate grant listing. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished. `internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers`, `RemoveTypedGrants` and `MemberCounts` (users grouped by `role_id` plus invites with `accepted_at` NULL grouped by `role_id`, two statements for any number of roles); no role use case scans `user.Repository.GetAll`. `user.Repository` gains `UpdateRole(id, from, to)`, a compare-and-swap on the role the use case read (`ErrRoleChanged`, `ErrUserNotFound`, `role.ErrRoleNotFound`), and `TransferOwnership(from, to, ownerRoleID, formerRoleID)`, which updates both rows in one transaction and fails whole when `from` no longer holds the Owner role (`ErrRoleChanged`) or `to` is missing or unverified (`ErrInvalidTransfer`). `invite.Invite` carries `RoleID`. `ChangeUserRoleUseCase`, `TransferOwnershipUseCase` and the amended invite use cases find the built-in roles with one helper, `builtInRole(roles, name)` (`GetByName`, then `built_in` must be true), and a user's role with `resolveRole`, which reads `role_id` only. `Delete` re-counts users and open invites inside its transaction and `ReassignMembers` moves users and open invites in one transaction and returns their sum. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished.
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (or `GetByName` for the legacy owner and admin strings), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`. Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (a user whose `role_id` is 0 resolves to `ErrRoleNotFound`, an empty grant set), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version. Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version.
+1
View File
@@ -1,4 +1,5 @@
Feature: Every real action of the back-office is recorded in the activity log from inside its use case Feature: Every real action of the back-office is recorded in the activity log from inside its use case
Superseded in part by roles_permissions_users_api: user.change_role and user.transfer_ownership (security) join the users category, and invite.create and invite.accept carry the params role_id and role (the role name).
As the owner of a RenewCMS site As the owner of a RenewCMS site
I want articles, media, users and invites, and system tasks to leave an activity event I want articles, media, users and invites, and system tasks to leave an activity event
+5
View File
@@ -66,3 +66,8 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers
42. Real update helper on Linux systemd (or the Docker QA flow): check, download until `ready`, restart; one event each, real asset names absent. Skip in a container. 42. Real update helper on Linux systemd (or the Docker QA flow): check, download until `ready`, restart; one event each, real asset names absent. Skip in a container.
43. Leave the scheduled purge due and wait for the hourly tick: a single `system.media_purge` event, source `system`, no ip or user agent. 43. Leave the scheduled purge due and wait for the hourly tick: a single `system.media_purge` event, source `system`, no ip or user agent.
44. Remove the database file's write access during one article edit: edit result unchanged, process log names the audit write failure. 44. Remove the database file's write access during one article edit: edit result unchanged, process log names the audit write failure.
## Amendment from roles_permissions_users_api
- `user.change_role` and `user.transfer_ownership` (security true) are recorded in the `users` category by `ChangeUserRoleUseCase` and `TransferOwnershipUseCase`. A refusal and a no-op record nothing; a storage failure at the write records a failed event.
- `invite.create` and `invite.accept` carry the params `role_id` and `role` (the role name). `invite.renew` is unchanged. A refused role (unknown, locked, above the caller) on create or renew records nothing.
+1
View File
@@ -1,4 +1,5 @@
Feature: A logged-in user fetches their own public profile without triggering the verified-account gate Feature: A logged-in user fetches their own public profile without triggering the verified-account gate
Superseded in part by roles_permissions_users_api: GET /v1/auth/me has no top-level role key; the role is access.role only. The public user has seven fields: id, username, email, is_verified, created_at, updated_at and display_name.
As the maintainer of RenewCMS As the maintainer of RenewCMS
I want the SPA to ask "who is currently logged in" via a single endpoint that answers from the I want the SPA to ask "who is currently logged in" via a single endpoint that answers from the
+1
View File
@@ -1,5 +1,6 @@
Feature: A verified user can invite another person by email, and the users list shows users and open invites Feature: A verified user can invite another person by email, and the users list shows users and open invites
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Superseded in part by roles_permissions_users_api: the invite body gains an optional role_id (absent or 0 means the Writer role), the invite response and the invite items of the users list carry role (a RoleRef, keys {id, name, built_in, locked}), the key lists of those shapes gain role, and the inviter must cover the role (403 escalation, role_locked, invalid_role). A user created by accepting an invite takes the role of the invite instead of Admin.
As a member of the shared RenewCMS workspace As a member of the shared RenewCMS workspace
I want to invite someone by email and see who has an account or a pending invite I want to invite someone by email and see who has an account or a pending invite
+1
View File
@@ -1,5 +1,6 @@
Feature: The owner can cancel a pending invite and renew a pending or expired one Feature: The owner can cancel a pending invite and renew a pending or expired one
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Superseded in part by roles_permissions_users_api: invite items and the renew response carry role (a RoleRef), a renewed invite keeps its role, and a renew is refused with 403 escalation when the invite role is above the caller grants (cancelling needs no coverage). The key lists of those shapes gain role.
As the owner of the shared RenewCMS workspace As the owner of the shared RenewCMS workspace
I want to cancel an invite I no longer want, or renew one that is stale or expired I want to cancel an invite I no longer want, or renew one that is stale or expired
@@ -1,4 +1,5 @@
Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates
Superseded in part by roles_permissions_users_api: a role is resolved by role_id only. The lines about the legacy resolution ("else the built-in named by the legacy string", "no role_id and a legacy string other than owner or admin") are replaced by: role_id 0 or an unknown role_id is an unresolvable role, refused as missing_permission.
As the owner of a RenewCMS site As the owner of a RenewCMS site
I want every owner and admin gate replaced by a check of the caller's role permissions I want every owner and admin gate replaced by a check of the caller's role permissions
@@ -1,5 +1,6 @@
Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Superseded in part by roles_permissions_users_api: the legacy users.role string is no longer read. The scenarios about the fallback to the built-in role named by the legacy string, "users gain a nullable role_id; the legacy string is unchanged" and the BackfillUserRoleIDs backfills are replaced by the migration of roles_permissions_users_api (legacy backfill while the column exists, invites to Admin) and by the rule that role_id 0 or an unknown role_id is denied, never promoted.
As the owner of a RenewCMS site As the owner of a RenewCMS site
I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope) I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope)
@@ -1,4 +1,5 @@
Feature: Roles and permissions roles API: list, read, create, edit, delete and reassign roles, with write-time validation, no-escalation and activity Feature: Roles and permissions roles API: list, read, create, edit, delete and reassign roles, with write-time validation, no-escalation and activity
Superseded in part by roles_permissions_users_api: a user effective role is role_id only (user 10 is an Admin with a role_id, not a legacy admin), members count users plus open invites, and "Out of scope here" is now done: PUT /v1/user/{id}/role, invites with a role, pending-invite counting, role_has_members for invites and the ownership transfer exist.
As the owner of a RenewCMS site As the owner of a RenewCMS site
I want to manage roles over /v1/role with the checks the design asks for I want to manage roles over /v1/role with the checks the design asks for
@@ -0,0 +1,359 @@
Feature: Roles and permissions users API: assign a role to a user, invite with a role, transfer ownership, and retire the legacy role string
As the owner of a RenewCMS site
I want to give every user and every invite exactly one stored role, change it under the no-escalation rule,
and hand the Owner role to another member
So that Users shows real roles, nobody grants a role they do not cover, and the old owner/admin string is gone
Background:
Given the spec of record is docs/design/screens/roles.md (sections 5, 7, 8, 9 and the tables "No-escalation feedback" and "HTTP status to copy")
and docs/design/routes.md "Roles and permissions"
And features/roles_permissions_foundation.feature provides the catalogue and the built-in roles,
features/roles_permissions_enforcement.feature the 403 contract (body "missing_permission\n", header X-Missing-Permission),
features/roles_permissions_content_scope.feature the GrantSet, and features/roles_permissions_roles_api.feature
the role routes, GrantSet.Covers, the escalation contract (body "escalation", header X-Escalation-Key) and the activity conventions
And "403" means status 403, body "missing_permission\n" and the header X-Missing-Permission, nothing stored, no audit event
And every other refusal is plain text from http.Error, one snake_case code, and writes nothing, not even the deletion of an expired invite
And success bodies are JSON with snake_case fields and Cache-Control "no-store" on the new routes
And the new refusals are:
| situation | status | body | extra header |
| user id unknown | 404 | user_not_found (existing apperr) | |
| role_id unknown (PUT role, invite) | 400 | invalid_role | |
| the target user holds the Owner role, or the role to give is the Owner role | 403 | role_locked (existing) | |
| change the role of your own account | 403 | own_role (existing) | |
| the current or the new role is above the caller's grants | 403 | escalation (existing) | X-Escalation-Key: first offending key |
| the target's role changed between read and write | 409 | user_role_changed | |
| transfer target unknown, self, unverified, or not a user | 400 | invalid_transfer | |
| confirm_email differs from the target's email | 400 | confirmation_mismatch | |
| delete a role that still has members, users or open invites | 409 | role_has_members (existing) | |
| an API key on a session-only route | 403 | apperr.SessionRequiredErrorMessage | |
And an undecodable body is 400 apperr.BodyErrorMessage, a failed validate tag is 400 with the validator text, a non-numeric id is 400 apperr.IdUint32ErrorMessage
And the request structs carry validate tags: PUT role "role_id" required, transfer "user_id" required and "confirm_email" required.
The invite body keeps "email" required,inviteemail and gains the optional "role_id" (absent or 0 means the Writer role)
And the routes are:
| route | key (role.Key constant) | session only | mounted in |
| PUT /v1/user/{id}/role | users.change_role | yes | NewUserRouter |
| POST /v1/user/invite | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
| POST /v1/user/invite/{id}/renew | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
| DELETE /v1/user/invite/{id} | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
| GET /v1/user | users.view (unchanged) | no | NewUserRouter |
| POST /v1/site/transfer-ownership | system.transfer_ownership | yes | NewSiteRouter, session group |
And the permission gate runs before the id, the body and any lookup. An API key on a session-only route gets 403 session_required before the gate
And handlers receive use cases only: ChangeUserRoleUseCase, TransferOwnershipUseCase, and the amended CreateInviteUseCase, RenewInviteUseCase,
ListUsersAndInvitesUseCase. They depend on domain interfaces only (user.Repository, invite.Repository, role.Repository, contenttype.Repository, audit.Recorder)
And the transfer handler lives in its own package (api/handlers/ownership), built by NewHandler(TransferOwnershipUseCase) and mounted by NewSiteRouter
And RoleRef is the one role summary shape {id, name, built_in, locked}. It is used by /auth/me access.role, by user items, by invite items and by the responses below
And a user's role is role_id, and nothing else. There is no fallback on a string
And the built-in roles are found by a single helper builtInRole(roles, name): GetByName, then built_in must be true, else role.ErrRoleNotFound
And the order of checks on PUT /v1/user/{id}/role is: session, gate users.change_role, id (400), body (400), load the target user (404),
the target holds a Locked role (403 role_locked), load role_id (400 invalid_role), the new role is Locked (403 role_locked),
the target is the caller (403 own_role), the target's current role covered by the caller (403 escalation),
the new role covered by the caller (403 escalation), same role (200, no write), write (409 user_role_changed on a stale read)
And the order of checks on POST /v1/user/invite is: gate users.invite, rate limiter, body (400), load the inviter, load the role (default Writer, 400 invalid_role),
the role is Locked (403 role_locked), the role covered by the caller (403 escalation), email already has an account (409), pending invite (409),
create, send the mail (the existing rollback on a send failure is unchanged)
And the order of checks on POST /v1/user/invite/{id}/renew is: the existing steps up to 404 and 409 (already accepted),
then the invite's role covered by the caller (403 escalation), then renew. DELETE /v1/user/invite/{id} needs no coverage
And the order of checks on POST /v1/site/transfer-ownership is: session, gate system.transfer_ownership, body (400), the target (400 invalid_transfer),
confirm_email (400 confirmation_mismatch), write (409 user_role_changed when the caller no longer holds the Owner role)
And the roles and people of the world (all verified, valid jwt cookie, token_version 0, role_id set, unless noted):
| role | grants | users | open invites |
| Owner | built-in, Locked, 34 keys | user 3 marta | none |
| Admin | built-in, 29 keys | 5 jonas, 10 lea, 12 sam (unverified) | none |
| Writer | built-in, 9 keys | 9 ines | invite 1 |
| Contributor | content.read=all, content.create, content.update=own | 41, 42, 43, 44 | invite 2 (expired), invite 3 (pending) |
| Intern | content.read=own | 46 | none |
| Content lead | content.read=all, content.update=all, content.publish=all, content.delete=all | 45 | none |
| People manager | users.view, users.invite, users.change_role, content.read=all, content.create, content.update=own, content.publish=own, schema.view | 31 | none |
| Viewer | users.view, roles.view | 32 | none |
| Inviter | users.invite | 33 | none |
And invite 1 is "new1@renew.example" for Writer, invite 2 is "old2@renew.example" for Contributor and expired, invite 3 is "new3@renew.example" for Contributor and pending,
invite 4 is accepted (user 9). Accepted invites are history and never count as members
And members, as counted for GET /v1/role, are: Owner 1, Admin 3, Writer 2, Contributor 6, Intern 1, Content lead 1, People manager 1, Viewer 1, Inviter 1
Scenario Outline: The gate decides who changes roles, invites and transfers
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 3 Owner | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
| user 5 Admin | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
| user 31 People manager | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
| user 9 Writer | PUT /v1/user/41/role {"role_id": <Intern id>} | 403 users.change_role |
| user 9 Writer | PUT /v1/user/abc/role | 403 users.change_role (before the id is read) |
| user 32 Viewer | PUT /v1/user/999/role | 403 users.change_role (before any lookup) |
| user 5 Admin | PUT /v1/user/abc/role | 400 apperr.IdUint32ErrorMessage |
| API key created by user 5 | PUT /v1/user/41/role {"role_id": <Intern id>} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
| no cookie | PUT /v1/user/41/role | 401 apperr.UnauthorizedErrorMessage |
| user 12 unverified Admin | PUT /v1/user/41/role | 403 apperr.VerificationRequiredErrorMessage |
| user 3 Owner | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": <email of 5>} | 200 |
| user 5 Admin | POST /v1/site/transfer-ownership {"user_id": 10, "confirm_email": <email of 10>} | 403 system.transfer_ownership |
| user 9 Writer | POST /v1/site/transfer-ownership | 403 system.transfer_ownership (before the body) |
| API key created by user 3 | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": <email of 5>} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
| user 32 Viewer | POST /v1/user/invite {"email": "x@renew.example"} | 403 users.invite |
| user 33 Inviter | GET /v1/user | 403 users.view |
And the gate follows the role live: after Viewer gains users.change_role in the repository, the same cookie's next PUT passes the gate, with no re-login
Scenario Outline: Changing a user's role
When <caller> sends PUT /v1/user/<id>/role with {"role_id": <role>}
Then the result is <result>
Examples:
| caller | id | role | result |
| user 5 Admin | 41 | Writer | 200, the user item of user 41 with role Writer |
| user 5 Admin | 41 | Admin | 200 (an Admin covers Admin) |
| user 5 Admin | 10 | Contributor | 200 (demoting another Admin is allowed, Admin covers Admin) |
| user 3 Owner | 5 | Content lead | 200 |
| user 31 People manager | 41 | Intern | 200 (Contributor and Intern are both covered) |
| user 31 People manager | 41 | Writer | 403 escalation, X-Escalation-Key content.update (Writer updates at all, the caller at own) |
| user 31 People manager | 41 | Content lead | 403 escalation, X-Escalation-Key content.update |
| user 31 People manager | 45 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Content lead is not covered) |
| user 31 People manager | 5 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Admin is not covered) |
| user 5 Admin | 41 | Owner | 403 role_locked |
| user 3 Owner | 41 | Owner | 403 role_locked (one owner, transfer instead) |
| user 5 Admin | 3 | Admin | 403 role_locked (the owner cannot be changed) |
| user 3 Owner | 3 | Admin | 403 role_locked (the Owner role is checked before the own account) |
| user 5 Admin | 5 | Writer | 403 own_role |
| user 31 People manager | 31 | Intern | 403 own_role |
| user 5 Admin | 999| Writer | 404 user_not_found |
| user 5 Admin | 41 | 999 | 400 invalid_role |
| user 5 Admin | 41 | (no role_id) | 400 (validate tag required) |
| user 5 Admin | 41 | 0 | 400 (validate tag required) |
| user 5 Admin | 12 | Writer | 200 (an unverified user may be given a role) |
And a refused call changes no role_id, bumps no token_version and records no event
And the success body is the user item of GET /v1/user: id, kind "user", username, display_name, email, role (RoleRef), status "accepted", created_at
Scenario: Giving the same role again is a no-op
When user 5 sends PUT /v1/user/41/role with the Contributor id
Then the status is 200 and the body is the unchanged user item
And no write happens and no event is recorded
And a caller who does not cover the Contributor role (a Viewer-like role with users.change_role only) still gets 403 escalation, because the current role is checked first
Scenario: A role change takes effect at once and keeps sessions and keys
Given user 41 has a valid cookie and an API key, and holds the Contributor role
When user 5 moves user 41 to the Writer role
Then user 41's token_version is unchanged and the same cookie and the same API key still authenticate
And user 41's next request is decided by the Writer grants, with no re-login
And GET /v1/auth/me for user 41 shows access.role Writer
Scenario: Two concurrent changes on the same user give one winner
Given user 5 and user 3 both read user 41 on the Contributor role
When both send a PUT with a different new role at the same time
Then exactly one answers 200 and the other 409 user_role_changed
And the stored role is the winner's
And the repository UpdateRole(id, from, to) is a compare-and-swap on the role the use case read
Scenario: A role deleted meanwhile is an invalid role, not a dangling reference
Given the custom role "Temp" has no member and is deleted between the lookup and the write of a PUT
Then the answer is 400 invalid_role and user 41 keeps the Contributor role
Scenario: The Owner role always has exactly one verified holder
Given two registrants hold the Owner role and neither is verified
When the first one verifies
Then the second one cannot be verified while a verified Owner exists, and the answer is the existing verification refusal
And the repository performs the check and the verification in one transaction, so two concurrent verifications leave one winner
Then no call of this feature leaves zero or two verified holders of the Owner role
And PUT role to or from the Owner is 403 role_locked, DELETE and edit of the Owner role stay role_locked, reassign from or to the Owner role stays refused
And only POST /v1/site/transfer-ownership moves the Owner role, in one transaction
Scenario Outline: Inviting with a role
When <caller> sends POST /v1/user/invite with <body>
Then the result is <result>
Examples:
| caller | body | result |
| user 5 Admin | {"email": "a@renew.example"} | 201, role is the Writer RoleRef (default), one "invitation" mail sent |
| user 5 Admin | {"email": "a@renew.example", "role_id": 0} | 201, role Writer |
| user 5 Admin | {"email": "a@renew.example", "role_id": <Contributor id>} | 201, role Contributor |
| user 5 Admin | {"email": "a@renew.example", "role_id": <Admin id>} | 201, role Admin |
| user 3 Owner | {"email": "a@renew.example", "role_id": <Content lead id>} | 201 |
| user 5 Admin | {"email": "a@renew.example", "role_id": <Owner id>} | 403 role_locked, no invite, no mail |
| user 5 Admin | {"email": "a@renew.example", "role_id": 999} | 400 invalid_role, no invite, no mail |
| user 31 People manager | {"email": "a@renew.example", "role_id": <Intern id>} | 201 |
| user 31 People manager | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.update (the default Writer is above the caller) |
| user 31 People manager | {"email": "a@renew.example", "role_id": <Content lead id>} | 403 escalation, X-Escalation-Key content.update |
| user 33 Inviter | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.read |
| user 5 Admin | {"email": "old2@renew.example", "role_id": 999} | 400 invalid_role, and expired invite 2 is not deleted |
| user 5 Admin | {"email": "old2@renew.example", "role_id": <Writer id>} | 201, expired invite 2 is replaced by a new invite for Writer |
| user 5 Admin | {"email": "new3@renew.example"} | 409 apperr.PendingInviteErrorMessage (existing) |
| user 5 Admin | {"email": "ines@renew.example"} (user 9's address) | 409 apperr.EmailHasAccountErrorMessage (existing) |
| user 5 Admin | {"email": "not-an-email"} | 400 (validate tag inviteemail) |
And the invite response is {id, kind "invite", email, status, role (RoleRef), created_at, expires_at}
And an API key may still create invites, and the no-escalation check then uses the live role of the key's creator
Scenario: Renewing keeps the invite's role
Given invite 2 is expired and for Contributor
When user 5 sends POST /v1/user/invite/2/renew
Then the status is 200, the invite is pending again, its role is still Contributor and the response carries the role
And a new code is mailed as before and the old code no longer works
Scenario Outline: Renewing and cancelling against the no-escalation rule
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 31 People manager | POST /v1/user/invite/3/renew (Contributor) | 200 |
| user 31 People manager | POST /v1/user/invite/1/renew (Writer) | 403 escalation, X-Escalation-Key content.update, nothing renewed, no mail |
| user 31 People manager | DELETE /v1/user/invite/1 (Writer) | 204 (cancelling removes power, no coverage needed) |
| user 5 Admin | POST /v1/user/invite/999/renew | 404 apperr.InviteNotFoundErrorMessage (before any coverage check) |
| user 5 Admin | POST /v1/user/invite/4/renew (accepted) | 409 apperr.InviteAlreadyAcceptedErrorMessage |
Scenario Outline: Accepting an invite creates a user with the invite's role
Given a pending invite for "new3@renew.example" for <role>
When the client sends POST /v1/auth/invite/accept with its code, username "kim" and a valid password
Then the status is 200 and the stored user "kim" has the role_id of <role>
And the user is verified and has no legacy role string
And GET /v1/auth/me for "kim" shows access.role <role> with that role's grants
Examples:
| role |
| Writer |
| Contributor |
| Admin |
And an invite whose role_id is 0 or names a role that no longer exists is refused as apperr.InviteInvalidErrorMessage, no user is created, and a failed invite.accept event is recorded
And the invite's role is read at accept time, so editing the role's grants before the accept changes what the new user gets
Scenario: Open invites count as role members
When user 5 sends GET /v1/role
Then Writer has members 2 (user 9 and invite 1) and Contributor has members 6 (users 41-44, expired invite 2, pending invite 3)
And accepted invite 4 counts for nobody
And the members of a role are counted by role_id on users plus role_id on invites with accepted_at NULL, with no GetAll scan
Scenario Outline: Deleting and moving roles that hold invites
Given the custom role "Temp" has no user and one open invite (<state>)
When user 5 sends DELETE /v1/role/<Temp id>
Then the answer is 409 role_has_members and nothing is deleted
Examples:
| state |
| pending |
| expired |
And after POST /v1/role/<Temp id>/reassign {"target_role_id": <Intern id>} the answer is {"moved": 1}, the invite's role is Intern and DELETE answers 204
And moved counts users and invites together, so Contributor reassigned to Intern answers {"moved": 6}
And the reassign event params moved is that total
And an accepted invite that still names the role does not block the delete
Scenario: Transferring ownership
Given user 3 holds the Owner role and user 5 is a verified Admin with email "jonas@renew.example"
When user 3 sends POST /v1/site/transfer-ownership with {"user_id": 5, "confirm_email": "jonas@renew.example"}
Then the status is 200 and the body is {"owner": {"id": 5, "name": ...}, "former_owner": {"id": 3, "name": ..., "role": the Admin RoleRef}}
And user 5 holds the Owner role and user 3 holds the Admin role, in one transaction
And both users keep their token_version, cookies and API keys
And user 3's next request is decided by the Admin grants and user 5's by the Owner grants
And GET /v1/role shows Owner members 1 and Admin members 3 (jonas left Admin, marta joined it)
And a second POST by user 3 answers 403 missing_permission with X-Missing-Permission system.transfer_ownership
Scenario Outline: Transfer refusals
When user 3 sends POST /v1/site/transfer-ownership with <body>
Then the result is <result>
Examples:
| body | result |
| {} | 400 (validate tag required) |
| {"user_id": 5} | 400 (validate tag required on confirm_email) |
| {"user_id": 999, "confirm_email": "x@renew.example"} | 400 invalid_transfer |
| {"user_id": 3, "confirm_email": <email of 3>} | 400 invalid_transfer (yourself) |
| {"user_id": 12, "confirm_email": <email of 12>} | 400 invalid_transfer (unverified) |
| {"user_id": 5, "confirm_email": "JONAS@renew.example"} | 400 confirmation_mismatch (exact match) |
| {"user_id": 5, "confirm_email": ""} | 400 (validate tag required) |
And a refused call changes no role_id and records no event
And an invite id is never a valid target, only members can be chosen
Scenario: Two simultaneous transfers give one new owner
Given user 3 sends a transfer to user 5 and a transfer to user 10 at the same time
Then exactly one answers 200 and the other 409 user_role_changed
And exactly one verified user holds the Owner role afterwards
And the repository TransferOwnership(from, to, ownerRoleID, formerRoleID) updates both rows in one transaction and fails the whole transaction when from no longer holds the Owner role
Scenario: The list of users and invites shows roles
When user 5 sends GET /v1/user
Then user items have exactly the keys id, kind, username, display_name, email, role, status, created_at, and role is a RoleRef
And invite items have exactly the keys id, kind, email, role, status, created_at, and role is the invite's RoleRef
And user 3's role is {"id": <Owner id>, "name": "Owner", "built_in": true, "locked": true}
And the roles are read with one role List call, not one per row
And a user whose role cannot be resolved shows role null
And no item carries password, password_reset_code, verification_code, verification_expiration, password_reset_expiration, token_version, code, hashed_code, invited_by or accepted_at
Scenario: The public user shape has no role string
Then PublicUser is exactly id, username, email, is_verified, created_at, updated_at and display_name, in every response that includes a user
And GET /v1/auth/me keeps access.role as its only role field, and holds the keys id, username, email, is_verified, created_at, updated_at, display_name, author and access
And POST /v1/auth/login, /verify, /register and /invite/accept carry no role key
Scenario: The legacy role string is gone from the code
Then the type user.Role, the constants user.RoleOwner and user.RoleAdmin and the field user.User.Role do not exist
And resolveRole(role_id) is the only resolution: role_id 0 gives role.ErrRoleNotFound, and legacyBuiltInNames does not exist
And CheckPermission gives an empty GrantSet for role.ErrRoleNotFound, as before, and GetMyAccess returns the error
And ListUsersAndInvites, memberCounts, CreateUser, AcceptInvite, the GORM User model, the user mapper and the user repository read and write no role string
And userRepository.Create writes role_id, with NULL when the domain role_id is 0
And the builtin "user" content type keeps a field "role", now a text field that is not required and has no options
Scenario: Registration creates the Owner
Given the user repository is empty
When the client sends POST /v1/auth/register with a valid body
Then the stored user has the role_id of the built-in Owner role and is unverified, as before
And when the Owner role is not seeded the answer is 500 "Internal server error" and no user is created
Scenario: A user without a usable role is denied, never promoted
Given user 13 has role_id 0 and user 14 has role_id 99 (no such role)
Then a gated route answers 403 missing_permission for both
And GET /v1/auth/me answers 500 "Internal server error" for both
And neither is counted in any role's members and both show role null in GET /v1/user
Scenario Outline: The migration gives every user and every invite a role_id
Given a database that predates this slice, with a users.role column holding <legacy> and no role_id
When Migrate runs
Then the user has the role_id of <role>
And running Migrate again changes nothing
And a user created after the upgrade with no role_id is never promoted by a later Migrate, and neither is an invite without a role_id
And the legacy steps only touch rows whose legacy users.role value still decides them, and are skipped when the column is absent
Examples:
| legacy | role |
| "owner" (lowest id of the owners) | Owner |
| "owner" (any other id) | Admin |
| "admin" | Admin |
| "" (lowest id) | Owner |
| "" (any other id) | Admin |
| "something-else" | Writer |
And a user that already has a role_id keeps it, even when the legacy string disagrees
And every existing invite without a role_id gets the Admin role, accepted ones included
And the backfill of created_by attributes orphans to the lowest user id holding the Owner role (no longer "role = 'owner'")
And a fresh database never has a users.role column, and the legacy steps are skipped when the column is absent
And a database that still has the column keeps it, untouched and unused (never dropped by this slice)
And the steps run in this order: AutoMigrate, seed built-in roles, legacy role backfill, invite role backfill, created_by backfill, the rest
Scenario: The member counts are one bulk query
Then role.Repository gains MemberCounts() (map[uint32]int, error): users grouped by role_id plus invites with accepted_at NULL grouped by role_id, two statements for any number of roles
And roleViewer.views and DeleteRole use it, and no role use case calls user.Repository.GetAll
And Delete re-counts users and open invites inside its transaction and returns role.ErrRoleHasMembers
And ReassignMembers(from, to) moves users and open invites in one transaction and returns their sum
And the persistence repository keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL
Scenario: Activity events for role assignment and ownership
Given the audit recorder is enabled
When user 5 moves user 41 from Contributor to Writer
Then one user.change_role event is recorded in category users, outcome success, actor user 5, target {"type": "user", "id": "41", "name": the username}
And its changes hold {"field": "role", "kind": "text", "before": "Contributor", "after": "Writer"} and its params from and to hold the two role ids
When user 3 transfers the Owner role to user 5
Then one user.transfer_ownership event is recorded in category users, security true, actor user 3, target user 5, params from 3 and to 5
When user 5 creates an invite for Contributor and the invitee accepts it
Then invite.create and invite.accept carry the params role_id and role (the role name), and invite.renew is unchanged
And a refusal (403, 404, 400, 409) records nothing, a no-op records nothing
And a storage failure at the write records the same event with outcome failed and a failure reason, through recordFailure
And audit gains ActionUserChangeRole "user.change_role", ActionUserTransferOwnership "user.transfer_ownership", FieldRole "role", ParamRole "role" and ParamRoleID "role_id"
And GET /v1/activity?category=users returns the new events to a caller whose activity.view is all
Scenario: Layering holds
Then internal/domain/user and internal/domain/invite import the stdlib and other domain packages only
And the use cases import no persistence, mailer, models, gorm, net/http or api package
And api/router.go uses role.KeyUsersChangeRole and role.KeySystemTransferOwnership, and no string equal to a catalogue key
And no handler imports persistence, mailer, models or gorm
And the handlers validate with validate tags and validator.Validate.Struct, never by hand
Scenario: Out of scope here
Then there is no SPA change (slices 5-7), and the SPA that reads a role string is adapted there
And there is no DELETE /v1/user/{id}, "Remove user" has no route yet
And the physical users.role column is not dropped
And TestRouterRoutesAreInTheCatalogue was already failing and is reported, not fixed (the two new routes are only added to its exemption list)
+129
View File
@@ -0,0 +1,129 @@
# QA procedure: roles_permissions_users_api
Package: `test` (black-box). New fakes need unique names, for example `usersAPIRoleRepo`, `usersAPIUserRepo`, `usersAPIInviteRepo`, `usersAPIRecorder`.
Reuse `mintCookie`, `assertGate`, `assertRefusal`, `assertEscalation`, the API key minting helper, `grantsOf`, `openMigratedRolesDB`
and the fakes of `test/role_support_test.go`, `test/roles_api_world_test.go` and `test/roles_content_scope_world_test.go`.
Do not copy any scope-rank comparison into tests: call `GrantSet.Covers` and the exported helpers.
No live server, no real SQLite file, no real SMTP.
## Deferred to later slices
- 5-7: SPA (Users: role badges, Change role dialog, role picker in the invite dialog, Transfer ownership dialogs, profile role card, role-aware shell).
The SPA reacts to the status, the body code and the X-Missing-Permission / X-Escalation-Key headers.
- A user-removal route, which must refuse the Owner.
- Dropping the physical users.role column (a later release).
## Design notes for the user
- docs/design names POST /v1/invite and GET /v1/me. The code routes are POST /v1/user/invite and GET /v1/auth/me and stay. POST /v1/site/transfer-ownership is the design's route.
- docs/design names the events user.role_changed and site.ownership_transferred. The code convention is user.change_role and user.transfer_ownership (category users).
- The design copy for "Owner can't be changed or removed" and "There is one owner" maps onto the existing 403 role_locked. The copy of the change-role dialog should key on that code.
- New codes with no design copy: invalid_role, user_role_changed, invalid_transfer, confirmation_mismatch.
- The design shows "Assigned by Marta Alves" on the profile role card. This slice stores no assigner. It is a design question for slice 7.
- Agents never edit docs/design: these items are for you to relay to Claude Design.
## Inventory (route, key, session only, checks after the gate, Owner / Admin / Writer)
| Route | Key | Session only | After the gate | Owner / Admin / Writer |
|---|---|---|---|---|
| PUT /v1/user/{id}/role | users.change_role | yes | id, body, user 404, Locked current, role 400, Locked new, own account, covers current, covers new, no-op, CAS write | ok / ok / 403 |
| POST /v1/user/invite | users.invite | no | body, role (default Writer) 400, Locked, covers, email has account, pending, create, mail | ok / ok / 403 |
| POST /v1/user/invite/{id}/renew | users.invite | no | id, 404, 409, covers the invite's role, renew, mail | ok / ok / 403 |
| DELETE /v1/user/invite/{id} | users.invite | no | id, 404, delete | ok / ok / 403 |
| GET /v1/user | users.view | no | none, roles read once | ok / ok / 403 |
| POST /v1/site/transfer-ownership | system.transfer_ownership | yes | body, target 400, confirm_email 400, CAS transaction | ok / 403 / 403 |
| POST /v1/auth/invite/accept | public | no | invite's role read at accept | as before |
| POST /v1/auth/register | public | no | creates the Owner (role_id) | as before |
| GET /v1/auth/me | none | as before | no top-level role; access.role only | as before |
| GET /v1/role, DELETE /v1/role/{id}, POST /v1/role/{id}/reassign | as before | as before | members include open invites; reassign moves invites | as before |
## Test infrastructure first
1. Role fake (extends `mockRoleRepo`): adds `MemberCounts` (reads the shared user and invite fakes), `Delete` and `ReassignMembers` that count open invites, call counters for `List`, `Get`, `GetByName`, `MemberCounts`. Remove nothing else.
2. User fake: `GetAll`, `Create` (stores role_id), `UpdateRole(id, from, to)` compare-and-swap (`user.ErrRoleChanged`, `user.ErrUserNotFound`, `role.ErrRoleNotFound` when the new role is gone), `TransferOwnership(from, to, ownerRoleID, formerRoleID)` in one step, a hook between read and write for race tests, a switchable failure per method. Users 3, 5, 9, 10, 12 (unverified), 13 (role_id 0), 14 (role_id 99), 31, 32, 33, 41-46.
3. Invite fake: carries `RoleID`, `GetAll`, `Create`, `Renew`, `AcceptWithUser` (creates the user with the invite's role_id), invites 1-4 of the Background.
4. Content type fake with "events" and "venues", a recording `audit.Recorder`, a mail fake that records template, recipient and data.
5. HTTP harness: `validator.InitValidator()`, a fresh `chi.NewRouter()` mirroring `NewUserRouter`, the transfer route of `NewSiteRouter` and `NewAuthRouter` in api/router.go (Verifier, apiKeyAuth equivalent, AuditContext, Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireSession on session-only routes, `RequirePermission(...)` with the key constants), over real use cases and the fakes.
6. Helper `userItem(t, rec)` and `roleRefOf(t, body)` to assert shapes, and `seededUser(id, builtInName)` so fixtures set `RoleID` instead of a string.
## A. Domain (no fakes)
7. `user.User` has no `Role` field and `user.Role`, `RoleOwner`, `RoleAdmin` do not exist (hygiene test over the source tree). `invite.Invite` carries `RoleID`.
8. New sentinels exist and map with `errors.Is`: `user.ErrRoleChanged`, `user.ErrInvalidTransfer`, `invite.ErrInvalidRole` (or `role.ErrUnknownRole`), `user.ErrConfirmationMismatch`. Escalation is read with `errors.As` on `role.EscalationError`.
9. `internal/domain/user` and `internal/domain/invite` import only the stdlib and domain packages. `audit` constants `ActionUserChangeRole`, `ActionUserTransferOwnership`, `FieldRole`, `ParamRole`, `ParamRoleID` exist.
10. `GrantSet.Covers` rows used by this slice (People manager against Writer, Content lead, Admin, Intern, Contributor): first violation key `content.update` for the first three, none for Intern and Contributor.
## B. Use cases (fakes only)
11. `ChangeUserRole`: every row of the "Changing a user's role" Outline with `errors.Is` / `errors.As`. Assert the order with counters: for a refusal before the write, `UpdateRole` is never called. The no-op row: no write, no event. Race: the hook changes the target's role between read and write, expect `user.ErrRoleChanged`. Role deleted between lookup and write: `role.ErrRoleNotFound` maps to invalid_role. Events: change_role has changes role before/after names and params from/to; refusals and no-ops record nothing; a repository failure records a failed event.
12. `TransferOwnership`: happy path (former owner gets the Admin role found by `builtInRole`, new owner gets the Owner role id, event with security true), all refusals of the Outline, a CAS failure, a repository failure (failed event, nobody changed), exactly one Owner afterwards.
13. `CreateInvite`: every row of the "Inviting with a role" Outline. A refused call does not delete an expired invite and does not send a mail. The default role is the built-in Writer found by `builtInRole`. The stored invite has `RoleID`. invite.create params role_id and role. Existing failure paths (email has account, pending, mail failure rolls back) unchanged.
14. `RenewInvite`: keeps the role, covers check after 404 and 409, no mail on a refusal. `CancelInvite`: no coverage check.
15. `AcceptInvite`: creates the user with the invite's `RoleID` and no legacy string. `RoleID` 0 or unknown role gives `invite.ErrInviteInvalid`, no user, failed event. Grants edited before the accept are what the user gets.
16. `CreateUser`: registration stores the Owner `RoleID`. Unseeded Owner role: error and no user.
17. `ListUsersAndInvites`: role per row from one `List` call (counter), `null` for an unresolvable user, invite rows carry their role.
18. `GetMyAccess`, `CheckPermission`, `resolveRole`: `RoleID` 0 gives `ErrRoleNotFound`, empty GrantSet in `CheckPermission`, an error in `GetMyAccess`. No legacy fallback rows remain.
19. `ListRoles` / `GetRole` / `DeleteRole` / `UpdateRole`: members come from `MemberCounts` (counter 1, `GetAll` counter 0), include open invites, exclude accepted ones. `DeleteRole` is blocked by a pending or an expired invite. `ReassignRoleMembers` returns users plus invites and records `moved` with the total.
20. Activity: the new actions and params validate through `audit.Event.Validate()`.
## C. HTTP
21. Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key on session-only routes gets `apperr.SessionRequiredErrorMessage` before the gate. Live-role scenario with the same cookie.
22. Each Outline of the feature: change role, invite with a role, renew and cancel, accept, transfer refusals. Assert status, exact body text, headers (`X-Missing-Permission`, `X-Escalation-Key`), the stores after the call and the recorder.
23. Order: for a Writer, `PUT /v1/user/abc/role` and the transfer with no body are 403 before 400. For user 31 an unknown `role_id` is 400 before an escalation 403 on a higher role, and a Locked current role is 403 before an unknown `role_id`. For the invite, a refused role leaves expired invite 2 in place.
24. Shapes: PUT returns the user item (RoleRef), invite create returns role, transfer returns owner and former_owner, `GET /v1/user` keys exactly as in the feature, `PublicUser` has 7 keys in login, verify, register, accept and `/auth/me`, with `access.role` intact.
25. Live effect: after PUT, the target's next request on a gated route, and its API key, see the new grants with the same cookie. After the transfer, user 3 is refused `system.transfer_ownership` and holds the Admin grants; user 5's key holds the Owner grants.
26. Concurrency: the race scenarios with a hook (one 200, one 409 user_role_changed).
27. Loads per request: a gated request loads the user at most once and the role once, `MemberCounts` 0 times on the permission path.
28. Regression: the foundation, enforcement, content-scope and roles-api suites stay green after fixtures move to `RoleID`.
## D. Persistence and migration (`openMigratedRolesDB`)
29. `UserRepository`: `Create` writes `role_id` (NULL for 0), the mapper has no role string; `UpdateRole` is a compare-and-swap (two calls with the same `from`: one wins) and returns `ErrRoleChanged` / `ErrUserNotFound` / `role.ErrRoleNotFound`; `TransferOwnership` updates both rows in one transaction, rolls both back when `from` is no longer the owner or `to` is unverified or missing, and two concurrent transfers give one winner; failure paths (DB closed).
30. `InviteRepository`: `role_id` column on `invites` after `Migrate`, `Create` / `Renew` keep it, `AcceptWithUser` stores the user's `role_id` and marks the invite accepted in one transaction.
31. `RoleRepository.MemberCounts`: users (verified or not), pending and expired invites count, accepted invites do not, NULL role_id users do not; a role with none is absent or 0; at most 2 SQL statements for any number of roles (count with a GORM callback). `Delete` refuses with `ErrRoleHasMembers` for a user or an open invite and succeeds for an accepted one. `ReassignMembers` moves users and open invites and returns the sum.
32. Migration: build a legacy database with `ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT ''` plus rows for each Outline row, run `Migrate`: role_ids as in the feature, invites get Admin, `BackfillCreatedBy` finds the owner by role_id, a second `Migrate` is a no-op, the column is still there. A fresh database has no `users.role` column and `Migrate` succeeds. `Migrate` with no users succeeds.
33. `sqlite_schema_unchanged_test.go`: the expected schema gains `invites.role_id` and loses `users.role` on a fresh database.
## Existing tests that must change (recheck with `go vet -tags nofrontend ./test/...`)
| File (cluster) | Change |
|---|---|
| Every fixture that builds `user.User{Role: user.RoleOwner|RoleAdmin}` or `"owner"`/`"admin"`: 44 files, about 217 occurrences, for example `user_roles_test.go`, `invitations_test.go`, `invite_management_test.go`, `get_current_user_http_test.go`, `api_keys_*_test.go`, `media_purge_http_test.go`, `update_http_test.go`, `activity_log_backend_http_test.go`, `session_revocation_http_test.go`, `custom_content_types_support*_test.go`, `image_variants_env_test.go`, `author_support_test.go`, `seo_data_support_test.go`, `mysql_server_test.go`, `postgres_server_test.go` | Set `RoleID` to the seeded id through `seededUser`. Delete the `Role` field assignments. Assertions on a `role` string become assertions on `RoleRef` or are removed |
| `roles_permissions_foundation_test.go`, `roles_permissions_foundation_http_test.go` | Delete the "falls back to the built-in role named by the legacy role" rows and `BackfillUserRoleIDs` scenarios. Replace with the role_id 0 and role_id 99 denial rows and the new migration tests |
| `roles_permissions_enforcement*_test.go` (legacy rows, "no role_id and a legacy string other than owner or admin") | Replace with the role_id 0 rows |
| `roles_api_world_test.go`, `roles_api_usecases_test.go`, `roles_api_http_test.go`, `roles_api_http_write_test.go`, `roles_api_persistence_test.go`, `roles_api_hardening_test.go`, `roles_api_domain_test.go` | User 10 (role_id 0, legacy admin) gets `RoleID` Admin; Admin members stay correct; add `MemberCounts`, invites in `members`, and `GetAll` counter 0 |
| `role_support_test.go`, `roles_content_scope_world_test.go`, `roles_content_scope_support_test.go`, `role_repository_gaps_test.go`, `role_hardening_test.go` | `mockRoleRepo` gains `MemberCounts`; `Delete` and `ReassignMembers` include open invites; the user fake gains `UpdateRole` and `TransferOwnership`; the invite fake gains `RoleID` |
| `user_response_field_hygiene` tests (and any `publicuser` shape test) | Eight keys become seven (no `role`) |
| `user_display_name_test.go`, `get_current_user_http_test.go` | `/auth/me` top-level `role` assertions removed; `access.role` kept |
| `activity_usecases_test.go`, `activity_domain_test.go` | New actions, params and field |
| tests of the builtin `user` type (`schema_builtin_view` tests, `builtin_type` tests) | The `role` field is text, not required, no options |
| `sqlite_schema_unchanged_test.go` | See item 33 |
| `api_catalogue_drift_test.go:52` | Add `PUT /v1/user/{id}/role` and `POST /v1/site/transfer-ownership` to `routesNotYetInTheCatalogue`. The pre-existing failure of `TestRouterRoutesAreInTheCatalogue` is not this slice's: report, do not fix |
| `web/src` Vitest specs and kits that mock `role: 'owner'` | Untouched here (the SPA is slices 5-7). They stay green because `/v1` is mocked. Report them as "not run against the new API" |
## Existing features and docs to amend
- features/user_roles.feature: "role is exactly owner or admin", "AcceptInvite creates role admin", "/auth/me returns role", "users list role", "no endpoint transfers a role": add "superseded by roles_permissions_users_api" notes in place.
- features/invitations.feature and features/invite_management.feature: invite body, item and response keys gain `role`; the item key lists.
- features/get_current_user.feature and features/user_response_field_hygiene.feature: no `role` key; seven public fields.
- features/roles_permissions_foundation.feature: the legacy fallback scenarios, "users gain a nullable role_id; the legacy string is unchanged", the backfill scenarios.
- features/roles_permissions_enforcement.feature: lines on the legacy resolution and the unresolvable role.
- features/roles_permissions_roles_api.feature: the Background line "else the built-in named by the legacy owner/admin string", user 10 as legacy admin, and "Out of scope here" (pending-invite counting and role_has_members for invites: now done).
- features/schema_builtin_view.feature: the builtin user type's role field.
- features/activity-log-producers.feature and .qa.md: the two actions and the invite params.
- features/users_screen.feature and features/users_row_actions.feature (SPA): note that the `role` string they read becomes a RoleRef, to be handled in slices 5-7.
- docs/api.md: Users and invites table, new routes, shapes, codes, headers, activity, members of a role, `/auth/me` and the seven public fields. docs/architecture.md: repository methods. web/src/data/apiCatalogue.ts and the generated llms docs (`task build:llms`): the two new routes and the changed shapes.
- docs/configuration.md: no new env var.
- docs/design: for you to relay, not edited by agents (see Design notes).
## Manual verification
- Copy a real database from a pre-slice-4 build, run `task dev:api` on the copy: every user has a role_id, every invite an Admin role_id, the `users.role` column is still there, the activity log and created_by are intact.
- As the Owner: invite with the default (Writer) and with Contributor, accept in a real browser from the mailed link, check the new user's role and `/auth/me`. Real SMTP delivery is a manual item.
- As an Admin: change a Contributor to Writer and then to Admin, try Owner (role_locked) and your own role (own_role). As a People manager: try a Writer (escalation with the key in X-Escalation-Key).
- Transfer ownership between two real sessions: the old owner's tab keeps working as Admin without re-login, the new owner's tab gains Owner permissions, the old owner's API key is an Admin key.
- Delete a role that has only an expired invite (409), move members, delete (204).
- Run the migration on MySQL/MariaDB and PostgreSQL with Docker (the Docker QA steps run for real here): the legacy backfill, the invite `role_id`, the CAS update and the transfer transaction behave like SQLite.
- Known risk to check by hand: starting the previous binary on an upgraded database is not supported (post-upgrade users have `users.role` empty).
- No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET, SMTP_HOST).
+1
View File
@@ -1,4 +1,5 @@
Feature: Read-only view of a built-in content type Feature: Read-only view of a built-in content type
Superseded in part by roles_permissions_users_api: the builtin user type role field is a plain optional text field with no options (the role is served as a RoleRef by the users API).
As an owner or admin As an owner or admin
I want to open a built-in type from the Schema list and read its fields I want to open a built-in type from the Schema list and read its fields
So that I can see the rules RenewCMS applies without being able to change them So that I can see the rules RenewCMS applies without being able to change them
@@ -1,4 +1,5 @@
Feature: API responses never expose a user's secret fields Feature: API responses never expose a user's secret fields
Superseded in part by roles_permissions_users_api: the public user has seven fields (id, username, email, is_verified, created_at, updated_at, display_name); the role key is gone from every response that includes a user.
As the maintainer of RenewCMS As the maintainer of RenewCMS
I want every HTTP response that includes user data to carry only public fields, never a I want every HTTP response that includes user data to carry only public fields, never a
+1
View File
@@ -1,5 +1,6 @@
Feature: Users have a role, owner or admin, and only the owner can invite Feature: Users have a role, owner or admin, and only the owner can invite
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Superseded in part by roles_permissions_users_api: a user has one stored role, role_id, and the user.Role type with the "owner" and "admin" strings no longer exists. AcceptInvite creates the user with the role of the invite (Writer by default, set by the inviter), not Admin. GET /v1/auth/me carries no top-level role (access.role only), the users list shows role as a RoleRef {id, name, built_in, locked} or null, and PUT /v1/user/{id}/role and POST /v1/site/transfer-ownership now exist, so "no endpoint transfers a role" no longer holds.
As the person who registered the workspace As the person who registered the workspace
I want my account to be the owner and everyone I invite to be an admin I want my account to be the owner and everyone I invite to be an admin
+1
View File
@@ -1,4 +1,5 @@
Feature: Users row actions: Cancel invite and Renew invite Feature: Users row actions: Cancel invite and Renew invite
Note from roles_permissions_users_api: invite and user items carry role as a RoleRef, and renewing an invite above the caller grants is 403 escalation. The screen is adapted in the SPA slices 5-7.
As the owner of a RenewCMS workspace As the owner of a RenewCMS workspace
I want to cancel a pending invite and renew an expired one from the Users list I want to cancel a pending invite and renew an expired one from the Users list
+1
View File
@@ -1,4 +1,5 @@
Feature: Users screen and InviteUserModal Feature: Users screen and InviteUserModal
Note from roles_permissions_users_api: the role the users list returns is now a RoleRef {id, name, built_in, locked} (or null), not a string. The screen is adapted in the SPA slices 5-7.
As a verified RenewCMS user in the shared workspace As a verified RenewCMS user in the shared workspace
I want /users to list everyone who has an account or an open invite, and let me invite someone I want /users to list everyone who has an account or an open invite, and let me invite someone
+5
View File
@@ -103,6 +103,8 @@ const (
ActionUserRegister = "user.register" ActionUserRegister = "user.register"
ActionUserVerify = "user.verify" ActionUserVerify = "user.verify"
ActionUserUpdateProfile = "user.update_profile" ActionUserUpdateProfile = "user.update_profile"
ActionUserChangeRole = "user.change_role"
ActionUserTransferOwnership = "user.transfer_ownership"
ActionInviteCreate = "invite.create" ActionInviteCreate = "invite.create"
ActionInviteCancel = "invite.cancel" ActionInviteCancel = "invite.cancel"
ActionInviteRenew = "invite.renew" ActionInviteRenew = "invite.renew"
@@ -157,7 +159,10 @@ const (
ParamChanged = "changed" ParamChanged = "changed"
ParamMembers = "members" ParamMembers = "members"
ParamMoved = "moved" ParamMoved = "moved"
ParamRole = "role"
ParamRoleID = "role_id"
FieldTitle = "title" FieldTitle = "title"
FieldRole = "role"
FieldOnline = "online" FieldOnline = "online"
FieldBody = "body" FieldBody = "body"
FieldFilename = "filename" FieldFilename = "filename"
@@ -3,7 +3,6 @@ package builtintype
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/user"
"errors" "errors"
) )
@@ -136,6 +135,6 @@ func userFields() []contenttype.Field {
text("username", "Username").required(), text("username", "Username").required(),
text("display_name", "Display name"), text("display_name", "Display name"),
text("email", "Email").required(), text("email", "Email").required(),
of("role", "Role", contenttype.TypeSelect).required().options(string(user.RoleOwner), string(user.RoleAdmin)), text("role", "Role"),
) )
} }
+1
View File
@@ -31,6 +31,7 @@ type Invite struct {
HashedCode string `json:"hashed_code"` HashedCode string `json:"hashed_code"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
InvitedBy uint32 `json:"invited_by"` InvitedBy uint32 `json:"invited_by"`
RoleID uint32 `json:"role_id"`
AcceptedAt *time.Time `json:"accepted_at"` AcceptedAt *time.Time `json:"accepted_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
+1
View File
@@ -27,6 +27,7 @@ var (
ErrNotBuiltIn = errors.New("role is not built in") ErrNotBuiltIn = errors.New("role is not built in")
ErrInvalidReassign = errors.New("invalid reassign target") ErrInvalidReassign = errors.New("invalid reassign target")
ErrEscalation = errors.New("grants more than the caller holds") ErrEscalation = errors.New("grants more than the caller holds")
ErrUnknownRole = errors.New("unknown role")
) )
type Scope string type Scope string
@@ -9,4 +9,5 @@ type Repository interface {
Delete(id uint32) error Delete(id uint32) error
ReassignMembers(from uint32, to uint32) (int, error) ReassignMembers(from uint32, to uint32) (int, error)
RemoveTypedGrants(typeKey string) (int, error) RemoveTypedGrants(typeKey string) (int, error)
MemberCounts() (map[uint32]int, error)
} }
+3 -8
View File
@@ -19,6 +19,9 @@ var ErrUsernameTaken = errors.New("username taken")
var ErrInvalidLookup = errors.New("invalid user lookup") var ErrInvalidLookup = errors.New("invalid user lookup")
var ErrVerificationEmailNotSent = errors.New("verification email not sent") var ErrVerificationEmailNotSent = errors.New("verification email not sent")
var ErrPasswordTooLong = errors.New("password too long") var ErrPasswordTooLong = errors.New("password too long")
var ErrRoleChanged = errors.New("user role changed concurrently")
var ErrInvalidTransfer = errors.New("invalid ownership transfer")
var ErrConfirmationMismatch = errors.New("confirmation email does not match")
const MaxLookupIDs = 100 const MaxLookupIDs = 100
@@ -31,13 +34,6 @@ func ValidatePassword(password string) error {
return nil return nil
} }
type Role string
const (
RoleOwner Role = "owner"
RoleAdmin Role = "admin"
)
type User struct { type User struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
@@ -46,7 +42,6 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"` IsVerified bool `json:"is_verified"`
Role Role `json:"role"`
RoleID uint32 `json:"role_id"` RoleID uint32 `json:"role_id"`
VerificationCode string `json:"verification_code"` VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"` VerificationExpiration time.Time `json:"verification_expiration"`
@@ -16,4 +16,6 @@ type Repository interface {
ClearVerificationCode(userId uint32) (User, error) ClearVerificationCode(userId uint32) (User, error)
ResetPassword(userId uint32, hashedPassword string) (User, error) ResetPassword(userId uint32, hashedPassword string) (User, error)
UpdateDisplayName(userId uint32, displayName string) (User, error) UpdateDisplayName(userId uint32, displayName string) (User, error)
UpdateRole(id uint32, from uint32, to uint32) (User, error)
TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error
} }
@@ -1,7 +1,7 @@
package database package database
import ( import (
domainUser "RenewCMS/internal/domain/user" "RenewCMS/internal/domain/role"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm" "gorm.io/gorm"
@@ -9,8 +9,15 @@ import (
func BackfillCreatedBy(db *gorm.DB) error { func BackfillCreatedBy(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
ownerRoleID, seeded, err := builtInRoleID(tx, role.OwnerName)
if err != nil {
return err
}
if !seeded {
return nil
}
var owner entity.User var owner entity.User
result := tx.Where("role = ?", string(domainUser.RoleOwner)).Order("id asc").Limit(1).Find(&owner) result := tx.Where("role_id = ?", ownerRoleID).Order("id asc").Limit(1).Find(&owner)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
@@ -0,0 +1,28 @@
package database
import (
"RenewCMS/internal/domain/role"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func BackfillInviteRoles(db *gorm.DB) error {
if !legacyRoleColumnExists(db) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
done, err := markerApplied(tx, inviteRolesMarker)
if err != nil || done {
return err
}
adminID, err := requireBuiltInRoleID(tx, role.AdminName)
if err != nil {
return err
}
if err := tx.Model(&entity.Invite{}).Where("role_id IS NULL OR role_id = 0").UpdateColumn("role_id", adminID).Error; err != nil {
return err
}
return applyMarker(tx, inviteRolesMarker)
})
}
@@ -0,0 +1,94 @@
package database
import (
"RenewCMS/internal/domain/role"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
usersTable = "users"
legacyRoleColumn = "role"
legacyOwnerValue = "owner"
legacyAdminValue = "admin"
legacyUnsetValue = ""
unassignedRoleCond = "(role_id IS NULL OR role_id = 0)"
)
func legacyRoleColumnExists(db *gorm.DB) bool {
return db.Migrator().HasColumn(usersTable, legacyRoleColumn)
}
func requireBuiltInRoleID(tx *gorm.DB, name string) (uint32, error) {
id, found, err := builtInRoleID(tx, name)
if err != nil {
return 0, err
}
if !found {
return 0, role.ErrRoleNotFound
}
return id, nil
}
func BackfillLegacyUserRoles(db *gorm.DB) error {
if !legacyRoleColumnExists(db) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
done, err := markerApplied(tx, legacyUserRolesMarker)
if err != nil || done {
return err
}
ownerID, err := requireBuiltInRoleID(tx, role.OwnerName)
if err != nil {
return err
}
adminID, err := requireBuiltInRoleID(tx, role.AdminName)
if err != nil {
return err
}
writerID, err := requireBuiltInRoleID(tx, role.WriterName)
if err != nil {
return err
}
legacy := clause.Column{Name: legacyRoleColumn}
assign := func(roleID uint32, condition string, args ...any) error {
return tx.Table(usersTable).Where(unassignedRoleCond).Where(condition, args...).UpdateColumn("role_id", roleID).Error
}
var owners []uint32
if err := tx.Table(usersTable).Where(unassignedRoleCond).Where("? = ?", legacy, legacyOwnerValue).Order("id asc").Pluck("id", &owners).Error; err != nil {
return err
}
if len(owners) > 0 {
if err := assign(ownerID, "id = ?", owners[0]); err != nil {
return err
}
}
if err := assign(adminID, "? = ?", legacy, legacyOwnerValue); err != nil {
return err
}
if err := assign(adminID, "? = ?", legacy, legacyAdminValue); err != nil {
return err
}
if len(owners) == 0 {
var lowest []uint32
if err := tx.Model(&entity.User{}).Order("id asc").Limit(1).Pluck("id", &lowest).Error; err != nil {
return err
}
if len(lowest) > 0 {
if err := assign(ownerID, "? = ? AND id = ?", legacy, legacyUnsetValue, lowest[0]); err != nil {
return err
}
}
}
if err := assign(adminID, "? = ?", legacy, legacyUnsetValue); err != nil {
return err
}
if err := assign(writerID, "1 = 1"); err != nil {
return err
}
return applyMarker(tx, legacyUserRolesMarker)
})
}
@@ -7,7 +7,7 @@ import (
) )
func Migrate(db *gorm.DB, publicURL string) error { func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}) err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{})
if err != nil { if err != nil {
return err return err
} }
@@ -17,10 +17,10 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := SeedBuiltInRoles(db); err != nil { if err := SeedBuiltInRoles(db); err != nil {
return err return err
} }
if err := BackfillUserRoles(db); err != nil { if err := BackfillLegacyUserRoles(db); err != nil {
return err return err
} }
if err := BackfillUserRoleIDs(db); err != nil { if err := BackfillInviteRoles(db); err != nil {
return err return err
} }
if err := BackfillCreatedBy(db); err != nil { if err := BackfillCreatedBy(db); err != nil {
@@ -0,0 +1,25 @@
package database
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
)
const (
legacyUserRolesMarker = "legacy_user_roles"
inviteRolesMarker = "invite_roles"
)
func markerApplied(tx *gorm.DB, name string) (bool, error) {
var count int64
if err := tx.Model(&entity.MigrationMarker{}).Where("name = ?", name).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func applyMarker(tx *gorm.DB, name string) error {
return tx.Create(&entity.MigrationMarker{Name: name, AppliedAt: time.Now()}).Error
}
@@ -1,33 +0,0 @@
package database
import (
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func BackfillUserRoles(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
var firstUser entity.User
result := tx.Where("role = ''").Order("id asc").Limit(1).Find(&firstUser)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
var lowest entity.User
if err := tx.Order("id asc").First(&lowest).Error; err != nil {
return err
}
if lowest.Role == "" {
if err := tx.Model(&entity.User{}).Where("id = ?", lowest.ID).Update("role", string(domainUser.RoleOwner)).Error; err != nil {
return err
}
}
return tx.Model(&entity.User{}).Where("role = ''").Update("role", string(domainUser.RoleAdmin)).Error
})
}
@@ -1,35 +0,0 @@
package database
import (
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
var legacyRoleNames = map[domainUser.Role]string{
domainUser.RoleOwner: role.OwnerName,
domainUser.RoleAdmin: role.AdminName,
}
func BackfillUserRoleIDs(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for legacy, name := range legacyRoleNames {
roleID, found, err := builtInRoleID(tx, name)
if err != nil {
return err
}
if !found {
continue
}
err = tx.Model(&entity.User{}).
Where("role = ? AND (role_id IS NULL OR role_id = 0)", string(legacy)).
UpdateColumn("role_id", roleID).Error
if err != nil {
return err
}
}
return nil
})
}
@@ -12,6 +12,7 @@ func InviteToDomain(m models.Invite) invite.Invite {
HashedCode: m.HashedCode, HashedCode: m.HashedCode,
ExpiresAt: m.ExpiresAt, ExpiresAt: m.ExpiresAt,
InvitedBy: m.InvitedBy, InvitedBy: m.InvitedBy,
RoleID: nullableIDToDomain(m.RoleID),
AcceptedAt: m.AcceptedAt, AcceptedAt: m.AcceptedAt,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
} }
@@ -24,6 +25,7 @@ func InviteToModel(i invite.Invite) models.Invite {
HashedCode: i.HashedCode, HashedCode: i.HashedCode,
ExpiresAt: i.ExpiresAt, ExpiresAt: i.ExpiresAt,
InvitedBy: i.InvitedBy, InvitedBy: i.InvitedBy,
RoleID: DomainToNullableID(i.RoleID),
AcceptedAt: i.AcceptedAt, AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt, CreatedAt: i.CreatedAt,
} }
@@ -26,7 +26,7 @@ func nullableIDToDomain(value *uint32) uint32 {
return *value return *value
} }
func domainToNullableID(value uint32) *uint32 { func DomainToNullableID(value uint32) *uint32 {
if value == 0 { if value == 0 {
return nil return nil
} }
@@ -42,7 +42,6 @@ func UserToDomain(m models.User) user.User {
Email: m.Email, Email: m.Email,
DisplayName: m.DisplayName, DisplayName: m.DisplayName,
IsVerified: m.IsVerified, IsVerified: m.IsVerified,
Role: user.Role(m.Role),
RoleID: nullableIDToDomain(m.RoleID), RoleID: nullableIDToDomain(m.RoleID),
VerificationCode: nullableStringToDomain(m.VerificationCode), VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration, VerificationExpiration: m.VerificationExpiration,
@@ -61,8 +60,7 @@ func UserToModel(a user.User) models.User {
Email: a.Email, Email: a.Email,
DisplayName: a.DisplayName, DisplayName: a.DisplayName,
IsVerified: a.IsVerified, IsVerified: a.IsVerified,
Role: string(a.Role), RoleID: DomainToNullableID(a.RoleID),
RoleID: domainToNullableID(a.RoleID),
VerificationCode: DomainToNullableString(a.VerificationCode), VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration, VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration, PasswordResetExpiration: a.PasswordResetExpiration,
@@ -8,6 +8,7 @@ type Invite struct {
HashedCode string `gorm:"not null"` HashedCode string `gorm:"not null"`
ExpiresAt time.Time ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"` InvitedBy uint32 `gorm:"not null"`
RoleID *uint32 `gorm:"index"`
AcceptedAt *time.Time AcceptedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
} }
@@ -0,0 +1,8 @@
package models
import "time"
type MigrationMarker struct {
Name string `gorm:"primaryKey;size:64"`
AppliedAt time.Time `gorm:"not null"`
}
@@ -12,7 +12,6 @@ type User struct {
Email string `gorm:"unique;not null;size:255"` Email string `gorm:"unique;not null;size:255"`
DisplayName string `gorm:"not null;default:'';size:255"` DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"` IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:'';size:32"`
RoleID *uint32 `gorm:"index"` RoleID *uint32 `gorm:"index"`
VerificationCode *string `gorm:"unique;size:255"` VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time VerificationExpiration time.Time
@@ -145,8 +145,8 @@ func (r *RoleRepository) Delete(id uint32) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&entity.Role{}, id).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&entity.Role{}, id).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound) return mapNotFoundErr(err, role.ErrRoleNotFound)
} }
var members int64 members, err := countMembers(tx, id)
if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&members).Error; err != nil { if err != nil {
return err return err
} }
if members > 0 { if members > 0 {
@@ -159,24 +159,68 @@ func (r *RoleRepository) Delete(id uint32) error {
}) })
} }
func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) { func countMembers(tx *gorm.DB, id uint32) (int64, error) {
moved := r.db.Model(&entity.User{}). var users, invites int64
Where("role_id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", from, to). if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&users).Error; err != nil {
UpdateColumn("role_id", to)
if moved.Error != nil {
return 0, moved.Error
}
if moved.RowsAffected > 0 {
return int(moved.RowsAffected), nil
}
var targets int64
if err := r.db.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil {
return 0, err return 0, err
} }
if targets == 0 { if err := tx.Model(&entity.Invite{}).Where("role_id = ? AND accepted_at IS NULL", id).Count(&invites).Error; err != nil {
return 0, role.ErrInvalidReassign return 0, err
} }
return 0, nil return users + invites, nil
}
type memberRow struct {
RoleID uint32
Members int
}
func (r *RoleRepository) MemberCounts() (map[uint32]int, error) {
var users, invites []memberRow
if err := r.db.Model(&entity.User{}).Select("role_id, COUNT(*) AS members").Where("role_id IS NOT NULL").Group("role_id").Scan(&users).Error; err != nil {
return nil, err
}
if err := r.db.Model(&entity.Invite{}).Select("role_id, COUNT(*) AS members").Where("role_id IS NOT NULL AND accepted_at IS NULL").Group("role_id").Scan(&invites).Error; err != nil {
return nil, err
}
counts := map[uint32]int{}
for _, row := range append(users, invites...) {
if row.RoleID != 0 {
counts[row.RoleID] += row.Members
}
}
return counts, nil
}
func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) {
moved := 0
err := r.db.Transaction(func(tx *gorm.DB) error {
target := "EXISTS (SELECT 1 FROM roles WHERE id = ?)"
users := tx.Model(&entity.User{}).Where("role_id = ? AND "+target, from, to).UpdateColumn("role_id", to)
if users.Error != nil {
return users.Error
}
invites := tx.Model(&entity.Invite{}).Where("role_id = ? AND accepted_at IS NULL AND "+target, from, to).UpdateColumn("role_id", to)
if invites.Error != nil {
return invites.Error
}
moved = int(users.RowsAffected + invites.RowsAffected)
if moved > 0 {
return nil
}
var targets int64
if err := tx.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil {
return err
}
if targets == 0 {
return role.ErrInvalidReassign
}
return nil
})
if err != nil {
return 0, err
}
return moved, nil
} }
func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) { func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) {
@@ -1,12 +1,14 @@
package persistence package persistence
import ( import (
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type UserRepository struct { type UserRepository struct {
@@ -34,7 +36,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode), PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
Email: user.Email, Email: user.Email,
IsVerified: user.IsVerified, IsVerified: user.IsVerified,
Role: string(user.Role), RoleID: mappers.DomainToNullableID(user.RoleID),
VerificationCode: mappers.DomainToNullableString(user.VerificationCode), VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration, VerificationExpiration: user.VerificationExpiration,
} }
@@ -145,14 +147,17 @@ func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string, exp
func (u *UserRepository) ClearVerificationCode(userId uint32) (domainUser.User, error) { func (u *UserRepository) ClearVerificationCode(userId uint32) (domainUser.User, error) {
var localUser entity.User var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error err := u.db.Transaction(func(tx *gorm.DB) error {
if err != nil { if err := tx.Model(&entity.User{}).First(&localUser, userId).Error; err != nil {
return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound) return mapNotFoundErr(err, domainUser.ErrUserNotFound)
}
if err := refuseSecondVerifiedOwner(tx, localUser); err != nil {
return err
} }
localUser.IsVerified = true localUser.IsVerified = true
localUser.VerificationCode = mappers.DomainToNullableString("") localUser.VerificationCode = mappers.DomainToNullableString("")
err = u.db.Save(&localUser).Error return tx.Save(&localUser).Error
})
if err != nil { if err != nil {
return domainUser.User{}, err return domainUser.User{}, err
} }
@@ -160,6 +165,26 @@ func (u *UserRepository) ClearVerificationCode(userId uint32) (domainUser.User,
return mappers.UserToDomain(localUser), nil return mappers.UserToDomain(localUser), nil
} }
func refuseSecondVerifiedOwner(tx *gorm.DB, candidate entity.User) error {
if candidate.RoleID == nil {
return nil
}
var roles []entity.Role
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND built_in = ? AND name = ?", *candidate.RoleID, true, role.OwnerName).Find(&roles).Error
if err != nil || len(roles) == 0 {
return err
}
var others int64
err = tx.Model(&entity.User{}).Where("role_id = ? AND is_verified = ? AND id <> ?", *candidate.RoleID, true, candidate.ID).Count(&others).Error
if err != nil {
return err
}
if others > 0 {
return domainUser.ErrInvalidVerificationCode
}
return nil
}
func (u *UserRepository) ResetPassword(userId uint32, hashedPassword string) (domainUser.User, error) { func (u *UserRepository) ResetPassword(userId uint32, hashedPassword string) (domainUser.User, error) {
var localUser entity.User var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
@@ -195,4 +220,68 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d
return mappers.UserToDomain(localUser), nil return mappers.UserToDomain(localUser), nil
} }
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32) (domainUser.User, error) {
err := u.db.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to)
if from == 0 {
query = query.Where("(role_id IS NULL OR role_id = 0)")
} else {
query = query.Where("role_id = ?", from)
}
result := query.Update("role_id", to)
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
return nil
}
return u.explainRoleRefusal(tx, id, to)
})
if err != nil {
return domainUser.User{}, err
}
return u.Get(id)
}
func (u *UserRepository) explainRoleRefusal(tx *gorm.DB, id uint32, to uint32) error {
var users int64
if err := tx.Model(&entity.User{}).Where("id = ?", id).Count(&users).Error; err != nil {
return err
}
if users == 0 {
return domainUser.ErrUserNotFound
}
var roles int64
if err := tx.Model(&entity.Role{}).Where("id = ?", to).Count(&roles).Error; err != nil {
return err
}
if roles == 0 {
return role.ErrRoleNotFound
}
return domainUser.ErrRoleChanged
}
func (u *UserRepository) TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error {
if from == to {
return domainUser.ErrInvalidTransfer
}
return u.db.Transaction(func(tx *gorm.DB) error {
demoted := tx.Model(&entity.User{}).Where("id = ? AND role_id = ?", from, ownerRoleID).Update("role_id", formerRoleID)
if demoted.Error != nil {
return demoted.Error
}
if demoted.RowsAffected == 0 {
return domainUser.ErrRoleChanged
}
promoted := tx.Model(&entity.User{}).Where("id = ? AND is_verified = ?", to, true).Update("role_id", ownerRoleID)
if promoted.Error != nil {
return promoted.Error
}
if promoted.RowsAffected == 0 {
return domainUser.ErrInvalidTransfer
}
return nil
})
}
var _ domainUser.Repository = &UserRepository{} var _ domainUser.Repository = &UserRepository{}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"context" "context"
"errors" "errors"
@@ -14,6 +15,7 @@ import (
type AcceptInviteUseCase struct { type AcceptInviteUseCase struct {
userRepository user.Repository userRepository user.Repository
inviteRepository invite.Repository inviteRepository invite.Repository
roles role.Repository
hasher hash.Hasher hasher hash.Hasher
recorder audit.Recorder recorder audit.Recorder
} }
@@ -24,8 +26,16 @@ type AcceptInviteCommand struct {
Password string Password string
} }
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, recorder audit.Recorder) *AcceptInviteUseCase { func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, hasher hash.Hasher, recorder audit.Recorder) *AcceptInviteUseCase {
return &AcceptInviteUseCase{userRepository, inviteRepository, hasher, recorder} return &AcceptInviteUseCase{userRepository, inviteRepository, roles, hasher, recorder}
}
func (a *AcceptInviteUseCase) invitedRole(pending invite.Invite) (role.Role, error) {
assigned, err := assignableRole(a.roles, pending.RoleID)
if errors.Is(err, role.ErrUnknownRole) || errors.Is(err, role.ErrRoleLocked) {
return role.Role{}, invite.ErrInviteInvalid
}
return assigned, err
} }
func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptInviteCommand) (user.User, error) { func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptInviteCommand) (user.User, error) {
@@ -39,11 +49,18 @@ func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptIn
} }
event.Target = inviteTarget(pending) event.Target = inviteTarget(pending)
created, err := a.accept(command, pending, now) assigned, err := a.invitedRole(pending)
if err != nil { if err != nil {
a.recordRefusal(ctx, event, command, err) a.recordRefusal(ctx, event, command, err)
return user.User{}, err return user.User{}, err
} }
created, err := a.accept(command, pending, assigned, now)
if err != nil {
a.recordRefusal(ctx, event, command, err)
return user.User{}, err
}
event = inviteRoleParams(event, assigned)
event.Actor = audit.UserActor(created) event.Actor = audit.UserActor(created)
a.recorder.Record(ctx, event) a.recorder.Record(ctx, event)
return created, nil return created, nil
@@ -54,7 +71,7 @@ func (a *AcceptInviteUseCase) recordRefusal(ctx context.Context, event audit.Eve
recordFailure(ctx, a.recorder, event, cause) recordFailure(ctx, a.recorder, event, cause)
} }
func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, now time.Time) (user.User, error) { func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, assigned role.Role, now time.Time) (user.User, error) {
if a.emailHasAccount(pending.Email) { if a.emailHasAccount(pending.Email) {
if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil { if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil {
return user.User{}, err return user.User{}, err
@@ -80,7 +97,7 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
Password: hashedPassword, Password: hashedPassword,
Email: pending.Email, Email: pending.Email,
IsVerified: true, IsVerified: true,
Role: user.RoleAdmin, RoleID: assigned.ID,
}) })
if errors.Is(err, invite.ErrInviteNotFound) { if errors.Is(err, invite.ErrInviteNotFound) {
return user.User{}, invite.ErrInviteInvalid return user.User{}, invite.ErrInviteInvalid
@@ -0,0 +1,110 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type ChangeUserRoleUseCase struct {
users user.Repository
roles role.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
}
type ChangeUserRoleCommand struct {
Caller ContentCaller
TargetID uint32
RoleID uint32
}
func NewChangeUserRoleUseCase(users user.Repository, roles role.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *ChangeUserRoleUseCase {
return &ChangeUserRoleUseCase{users: users, roles: roles, contentTypes: contentTypes, recorder: recorder}
}
var roleChangeRefusals = []error{user.ErrRoleChanged, user.ErrUserNotFound, role.ErrRoleNotFound, role.ErrUnknownRole}
func (u *ChangeUserRoleUseCase) ChangeRole(ctx context.Context, command ChangeUserRoleCommand) (DirectoryEntry, error) {
target, err := u.users.Get(command.TargetID)
if err != nil {
return DirectoryEntry{}, err
}
current, held, err := u.heldRole(target)
if err != nil {
return DirectoryEntry{}, err
}
if held && current.Locked {
return DirectoryEntry{}, role.ErrRoleLocked
}
next, err := assignableRole(u.roles, command.RoleID)
if err != nil {
return DirectoryEntry{}, err
}
if err := u.checkCoverage(command, target, current, held, next); err != nil {
return DirectoryEntry{}, err
}
if held && current.ID == next.ID {
ref := NewRoleRef(next)
return userEntry(target, &ref), nil
}
return u.write(ctx, target, current, next)
}
func (u *ChangeUserRoleUseCase) heldRole(target user.User) (role.Role, bool, error) {
current, err := resolveRole(u.roles, target)
if errors.Is(err, role.ErrRoleNotFound) {
return role.Role{}, false, nil
}
if err != nil {
return role.Role{}, false, err
}
return current, true, nil
}
func (u *ChangeUserRoleUseCase) checkCoverage(command ChangeUserRoleCommand, target user.User, current role.Role, held bool, next role.Role) error {
if target.ID == command.Caller.UserID {
return role.ErrOwnRole
}
typeKeys, err := allowedTypeKeys(u.contentTypes)
if err != nil {
return err
}
if held {
if err := ensureCoveredBy(command.Caller, typeKeys, current); err != nil {
return err
}
}
return ensureCoveredBy(command.Caller, typeKeys, next)
}
func (u *ChangeUserRoleUseCase) write(ctx context.Context, target user.User, current role.Role, next role.Role) (DirectoryEntry, error) {
event := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
event.Target = audit.NewTarget(audit.TargetUser, target.ID, target.Username)
updated, err := u.users.UpdateRole(target.ID, target.RoleID, next.ID)
if errors.Is(err, role.ErrRoleNotFound) {
return DirectoryEntry{}, role.ErrUnknownRole
}
if err != nil {
u.recordWriteFailure(ctx, event, err)
return DirectoryEntry{}, err
}
event.Params[audit.ParamFrom] = current.ID
event.Params[audit.ParamTo] = next.ID
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldRole, current.Name, next.Name)
u.recorder.Record(ctx, event)
ref := NewRoleRef(next)
return userEntry(updated, &ref), nil
}
func (u *ChangeUserRoleUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
for _, refusal := range roleChangeRefusals {
if errors.Is(err, refusal) {
return
}
}
recordFailure(ctx, u.recorder, event, err)
}
@@ -2,9 +2,11 @@ package useCases
import ( import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail" "RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"context" "context"
"errors" "errors"
@@ -15,52 +17,98 @@ import (
type CreateInviteUseCase struct { type CreateInviteUseCase struct {
userRepository user.Repository userRepository user.Repository
inviteRepository invite.Repository inviteRepository invite.Repository
roles role.Repository
contentTypes contenttype.Repository
hasher hash.Hasher hasher hash.Hasher
mailer inviteMailer mailer inviteMailer
recorder audit.Recorder recorder audit.Recorder
} }
type CreateInviteCommand struct { type CreateInviteCommand struct {
Caller ContentCaller
Email string Email string
InvitedBy uint32 InvitedBy uint32
RoleID uint32
} }
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateInviteUseCase { func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, contentTypes contenttype.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateInviteUseCase {
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder} return &CreateInviteUseCase{userRepository, inviteRepository, roles, contentTypes, hasher, inviteMailer{mailRepository, publicURL}, recorder}
} }
func (g *CreateInviteUseCase) CreateInvite(ctx context.Context, command CreateInviteCommand) (invite.Invite, error) { var inviteRoleRefusals = []error{role.ErrUnknownRole, role.ErrRoleLocked, role.ErrEscalation}
func isInviteRoleRefusal(err error) bool {
for _, refusal := range inviteRoleRefusals {
if errors.Is(err, refusal) {
return true
}
}
return false
}
func inviteRoleParams(event audit.Event, assigned role.Role) audit.Event {
event.Params[audit.ParamRoleID] = assigned.ID
event.Params[audit.ParamRole] = assigned.Name
return event
}
func (g *CreateInviteUseCase) CreateInvite(ctx context.Context, command CreateInviteCommand) (InviteView, error) {
event := audit.NewEvent(audit.ActionInviteCreate, audit.CategoryUsers) event := audit.NewEvent(audit.ActionInviteCreate, audit.CategoryUsers)
created, err := g.create(command) created, assigned, err := g.create(command)
if err != nil { if err != nil {
if !isInviteRoleRefusal(err) {
recordFailure(ctx, g.recorder, event, err) recordFailure(ctx, g.recorder, event, err)
return invite.Invite{}, err }
return InviteView{}, err
} }
event.Target = inviteTarget(created) event.Target = inviteTarget(created)
g.recorder.Record(ctx, event) g.recorder.Record(ctx, inviteRoleParams(event, assigned))
return created, nil return InviteView{Invite: created, Role: NewRoleRef(assigned)}, nil
} }
func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite, error) { func (g *CreateInviteUseCase) assignedRole(command CreateInviteCommand) (role.Role, error) {
if command.RoleID == 0 {
return builtInRole(g.roles, role.WriterName)
}
return assignableRole(g.roles, command.RoleID)
}
func (g *CreateInviteUseCase) grantableRole(command CreateInviteCommand) (role.Role, error) {
assigned, err := g.assignedRole(command)
if err != nil {
return role.Role{}, err
}
if err := ensureCovered(command.Caller, g.contentTypes, assigned); err != nil {
return role.Role{}, err
}
return assigned, nil
}
func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite, role.Role, error) {
email := strings.ToLower(command.Email) email := strings.ToLower(command.Email)
now := time.Now() now := time.Now()
inviter, err := g.userRepository.Get(command.InvitedBy) inviter, err := g.userRepository.Get(command.InvitedBy)
if err != nil { if err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
}
assigned, err := g.grantableRole(command)
if err != nil {
return invite.Invite{}, role.Role{}, err
} }
if g.emailHasAccount(email) { if g.emailHasAccount(email) {
return invite.Invite{}, invite.ErrEmailHasAccount return invite.Invite{}, role.Role{}, invite.ErrEmailHasAccount
} }
if err := g.discardOpenInvite(email, now); err != nil { if err := g.discardOpenInvite(email, now); err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
} }
rawCode, hashedCode, err := newInviteCode(g.hasher) rawCode, hashedCode, err := newInviteCode(g.hasher)
if err != nil { if err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
} }
createdInvite, err := g.inviteRepository.Create(invite.Invite{ createdInvite, err := g.inviteRepository.Create(invite.Invite{
@@ -68,20 +116,21 @@ func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite
HashedCode: hashedCode, HashedCode: hashedCode,
ExpiresAt: now.Add(invite.Lifetime), ExpiresAt: now.Add(invite.Lifetime),
InvitedBy: command.InvitedBy, InvitedBy: command.InvitedBy,
RoleID: assigned.ID,
CreatedAt: now, CreatedAt: now,
}) })
if err != nil { if err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
} }
if sendErr := g.mailer.send(email, rawCode, inviter); sendErr != nil { if sendErr := g.mailer.send(email, rawCode, inviter); sendErr != nil {
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil { if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
return invite.Invite{}, errors.Join(sendErr, deleteErr) return invite.Invite{}, role.Role{}, errors.Join(sendErr, deleteErr)
} }
return invite.Invite{}, sendErr return invite.Invite{}, role.Role{}, sendErr
} }
return createdInvite, nil return createdInvite, assigned, nil
} }
func (g *CreateInviteUseCase) emailHasAccount(email string) bool { func (g *CreateInviteUseCase) emailHasAccount(email string) bool {
+10 -3
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail" "RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"context" "context"
"errors" "errors"
@@ -15,6 +16,7 @@ import (
type CreateUserUseCase struct { type CreateUserUseCase struct {
userRepository user.Repository userRepository user.Repository
roles role.Repository
hasher hash.Hasher hasher hash.Hasher
mailRepository mail.Repository mailRepository mail.Repository
publicURL string publicURL string
@@ -32,8 +34,8 @@ type MailValidationData struct {
VerificationCode string VerificationCode string
} }
func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateUserUseCase { func NewCreateUserUseCase(userRepository user.Repository, roles role.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateUserUseCase {
return &CreateUserUseCase{userRepository, hasher, mailRepository, publicURL, recorder} return &CreateUserUseCase{userRepository, roles, hasher, mailRepository, publicURL, recorder}
} }
func (g *CreateUserUseCase) CreateUser(ctx context.Context, createUser CreateUserCommand) (user.User, error) { func (g *CreateUserUseCase) CreateUser(ctx context.Context, createUser CreateUserCommand) (user.User, error) {
@@ -59,6 +61,11 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err
return user.User{}, err return user.User{}, err
} }
ownerRole, err := builtInRole(g.roles, role.OwnerName)
if err != nil {
return user.User{}, err
}
rawUuid := uuid.NewString() rawUuid := uuid.NewString()
hashedPassword, err := g.hasher.Hash(createUser.Password) hashedPassword, err := g.hasher.Hash(createUser.Password)
@@ -77,7 +84,7 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err
PasswordResetCode: "", PasswordResetCode: "",
Email: createUser.Email, Email: createUser.Email,
IsVerified: false, IsVerified: false,
Role: user.RoleOwner, RoleID: ownerRole.ID,
VerificationCode: hashedVerificationCode, VerificationCode: hashedVerificationCode,
VerificationExpiration: time.Now().Add(2 * time.Hour), VerificationExpiration: time.Now().Add(2 * time.Hour),
}) })
@@ -18,27 +18,11 @@ func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles} return &GetMyAccessUseCase{roles: roles}
} }
var legacyBuiltInNames = map[user.Role]string{
user.RoleOwner: role.OwnerName,
user.RoleAdmin: role.AdminName,
}
func resolveRole(roles role.Repository, account user.User) (role.Role, error) { func resolveRole(roles role.Repository, account user.User) (role.Role, error) {
if account.RoleID != 0 { if account.RoleID == 0 {
return role.Role{}, role.ErrRoleNotFound
}
return roles.Get(account.RoleID) return roles.Get(account.RoleID)
}
name, known := legacyBuiltInNames[account.Role]
if !known {
return role.Role{}, role.ErrRoleNotFound
}
found, err := roles.GetByName(name)
if err != nil {
return role.Role{}, err
}
if !found.BuiltIn {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
} }
func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) { func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
@@ -2,6 +2,7 @@ package useCases
import ( import (
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"sort" "sort"
"time" "time"
@@ -15,6 +16,7 @@ const (
type ListUsersAndInvitesUseCase struct { type ListUsersAndInvitesUseCase struct {
userRepository user.Repository userRepository user.Repository
inviteRepository invite.Repository inviteRepository invite.Repository
roles role.Repository
} }
type DirectoryEntry struct { type DirectoryEntry struct {
@@ -23,30 +25,38 @@ type DirectoryEntry struct {
Username string Username string
DisplayName string DisplayName string
Email string Email string
Role string Role *RoleRef
Status string Status string
CreatedAt time.Time CreatedAt time.Time
} }
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository) *ListUsersAndInvitesUseCase { func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository} return &ListUsersAndInvitesUseCase{userRepository, inviteRepository, roles}
} }
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry { func userEntry(account user.User, found *RoleRef) DirectoryEntry {
return DirectoryEntry{
ID: account.ID,
Kind: EntryKindUser,
Username: account.Username,
DisplayName: account.DisplayName,
Email: account.Email,
Role: found,
Status: string(invite.StatusAccepted),
CreatedAt: account.CreatedAt,
}
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, error) {
refs, err := roleRefsByID(g.roles)
if err != nil {
return nil, err
}
now := time.Now() now := time.Now()
entries := []DirectoryEntry{} entries := []DirectoryEntry{}
for _, existingUser := range g.userRepository.GetAll() { for _, existingUser := range g.userRepository.GetAll() {
entries = append(entries, DirectoryEntry{ entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID)))
ID: existingUser.ID,
Kind: EntryKindUser,
Username: existingUser.Username,
DisplayName: existingUser.DisplayName,
Email: existingUser.Email,
Role: string(existingUser.Role),
Status: string(invite.StatusAccepted),
CreatedAt: existingUser.CreatedAt,
})
} }
for _, existingInvite := range g.inviteRepository.GetAll() { for _, existingInvite := range g.inviteRepository.GetAll() {
@@ -58,6 +68,7 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
ID: existingInvite.ID, ID: existingInvite.ID,
Kind: EntryKindInvite, Kind: EntryKindInvite,
Email: existingInvite.Email, Email: existingInvite.Email,
Role: lookupRoleRef(refs, existingInvite.RoleID),
Status: string(status), Status: string(status),
CreatedAt: existingInvite.CreatedAt, CreatedAt: existingInvite.CreatedAt,
}) })
@@ -67,5 +78,5 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
return entries[i].CreatedAt.After(entries[j].CreatedAt) return entries[i].CreatedAt.After(entries[j].CreatedAt)
}) })
return entries return entries, nil
} }
+30 -14
View File
@@ -2,9 +2,11 @@ package useCases
import ( import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail" "RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"context" "context"
"errors" "errors"
@@ -14,61 +16,75 @@ import (
type RenewInviteUseCase struct { type RenewInviteUseCase struct {
userRepository user.Repository userRepository user.Repository
inviteRepository invite.Repository inviteRepository invite.Repository
roles role.Repository
contentTypes contenttype.Repository
hasher hash.Hasher hasher hash.Hasher
mailer inviteMailer mailer inviteMailer
recorder audit.Recorder recorder audit.Recorder
} }
type RenewInviteCommand struct { type RenewInviteCommand struct {
Caller ContentCaller
ID uint32 ID uint32
RenewedBy uint32 RenewedBy uint32
} }
func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RenewInviteUseCase { func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, contentTypes contenttype.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RenewInviteUseCase {
return &RenewInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder} return &RenewInviteUseCase{userRepository, inviteRepository, roles, contentTypes, hasher, inviteMailer{mailRepository, publicURL}, recorder}
} }
func (u *RenewInviteUseCase) RenewInvite(ctx context.Context, command RenewInviteCommand) (invite.Invite, error) { func (u *RenewInviteUseCase) coveredRole(caller ContentCaller, previous invite.Invite) (role.Role, error) {
return coveredAssignableRole(caller, u.roles, u.contentTypes, previous.RoleID)
}
func (u *RenewInviteUseCase) RenewInvite(ctx context.Context, command RenewInviteCommand) (InviteView, error) {
event := audit.NewEvent(audit.ActionInviteRenew, audit.CategoryUsers) event := audit.NewEvent(audit.ActionInviteRenew, audit.CategoryUsers)
renewed, err := u.renew(command) renewed, held, err := u.renew(command)
if err != nil { if err != nil {
if !isInviteRoleRefusal(err) {
recordFailure(ctx, u.recorder, event, err) recordFailure(ctx, u.recorder, event, err)
return invite.Invite{}, err }
return InviteView{}, err
} }
event.Target = inviteTarget(renewed) event.Target = inviteTarget(renewed)
u.recorder.Record(ctx, event) u.recorder.Record(ctx, event)
return renewed, nil return InviteView{Invite: renewed, Role: NewRoleRef(held)}, nil
} }
func (u *RenewInviteUseCase) renew(command RenewInviteCommand) (invite.Invite, error) { func (u *RenewInviteUseCase) renew(command RenewInviteCommand) (invite.Invite, role.Role, error) {
now := time.Now() now := time.Now()
renewer, err := u.userRepository.Get(command.RenewedBy) renewer, err := u.userRepository.Get(command.RenewedBy)
if err != nil { if err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
} }
previous, err := findManageableInvite(u.inviteRepository, command.ID) previous, err := findManageableInvite(u.inviteRepository, command.ID)
if err != nil { if err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
}
held, err := u.coveredRole(command.Caller, previous)
if err != nil {
return invite.Invite{}, role.Role{}, err
} }
rawCode, hashedCode, err := newInviteCode(u.hasher) rawCode, hashedCode, err := newInviteCode(u.hasher)
if err != nil { if err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
} }
renewed, err := u.inviteRepository.Renew(previous.ID, hashedCode, now.Add(invite.Lifetime), now) renewed, err := u.inviteRepository.Renew(previous.ID, hashedCode, now.Add(invite.Lifetime), now)
if err != nil { if err != nil {
return invite.Invite{}, err return invite.Invite{}, role.Role{}, err
} }
if sendErr := u.mailer.send(renewed.Email, rawCode, renewer); sendErr != nil { if sendErr := u.mailer.send(renewed.Email, rawCode, renewer); sendErr != nil {
if _, restoreErr := u.inviteRepository.Renew(previous.ID, previous.HashedCode, previous.ExpiresAt, previous.CreatedAt); restoreErr != nil { if _, restoreErr := u.inviteRepository.Renew(previous.ID, previous.HashedCode, previous.ExpiresAt, previous.CreatedAt); restoreErr != nil {
return invite.Invite{}, errors.Join(sendErr, restoreErr) return invite.Invite{}, role.Role{}, errors.Join(sendErr, restoreErr)
} }
return invite.Invite{}, sendErr return invite.Invite{}, role.Role{}, sendErr
} }
return renewed, nil return renewed, held, nil
} }
@@ -0,0 +1,103 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type TransferOwnershipUseCase struct {
users user.Repository
roles role.Repository
recorder audit.Recorder
}
type TransferOwnershipCommand struct {
Caller ContentCaller
UserID uint32
ConfirmEmail string
}
type OwnershipSuccessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type OwnershipPredecessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Role RoleRef `json:"role"`
}
type OwnershipTransfer struct {
Owner OwnershipSuccessor `json:"owner"`
FormerOwner OwnershipPredecessor `json:"former_owner"`
}
func NewTransferOwnershipUseCase(users user.Repository, roles role.Repository, recorder audit.Recorder) *TransferOwnershipUseCase {
return &TransferOwnershipUseCase{users: users, roles: roles, recorder: recorder}
}
var transferRefusals = []error{user.ErrRoleChanged, user.ErrInvalidTransfer, user.ErrUserNotFound}
func (u *TransferOwnershipUseCase) Transfer(ctx context.Context, command TransferOwnershipCommand) (OwnershipTransfer, error) {
successor, err := u.successor(command)
if err != nil {
return OwnershipTransfer{}, err
}
former, err := u.users.Get(command.Caller.UserID)
if err != nil {
return OwnershipTransfer{}, err
}
ownerRole, err := builtInRole(u.roles, role.OwnerName)
if err != nil {
return OwnershipTransfer{}, err
}
adminRole, err := builtInRole(u.roles, role.AdminName)
if err != nil {
return OwnershipTransfer{}, err
}
event := audit.NewEvent(audit.ActionUserTransferOwnership, audit.CategoryUsers)
event.Security = true
event.Target = audit.NewTarget(audit.TargetUser, successor.ID, successor.Username)
if err := u.users.TransferOwnership(former.ID, successor.ID, ownerRole.ID, adminRole.ID); err != nil {
u.recordWriteFailure(ctx, event, err)
return OwnershipTransfer{}, err
}
event.Params[audit.ParamFrom] = former.ID
event.Params[audit.ParamTo] = successor.ID
u.recorder.Record(ctx, event)
return OwnershipTransfer{
Owner: OwnershipSuccessor{ID: successor.ID, Name: successor.NameOrUsername()},
FormerOwner: OwnershipPredecessor{ID: former.ID, Name: former.NameOrUsername(), Role: NewRoleRef(adminRole)},
}, nil
}
func (u *TransferOwnershipUseCase) successor(command TransferOwnershipCommand) (user.User, error) {
successor, err := u.users.Get(command.UserID)
if errors.Is(err, user.ErrUserNotFound) {
return user.User{}, user.ErrInvalidTransfer
}
if err != nil {
return user.User{}, err
}
if successor.ID == command.Caller.UserID || !successor.IsVerified {
return user.User{}, user.ErrInvalidTransfer
}
if successor.Email != command.ConfirmEmail {
return user.User{}, user.ErrConfirmationMismatch
}
return successor, nil
}
func (u *TransferOwnershipUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
for _, refusal := range transferRefusals {
if errors.Is(err, refusal) {
return
}
}
recordFailure(ctx, u.recorder, event, err)
}
@@ -0,0 +1,42 @@
package useCases
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
)
type InviteView struct {
invite.Invite
Role RoleRef
}
type RoleRef struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
func NewRoleRef(found role.Role) RoleRef {
return RoleRef{ID: found.ID, Name: found.Name, BuiltIn: found.BuiltIn, Locked: found.Locked}
}
func roleRefsByID(roles role.Repository) (map[uint32]RoleRef, error) {
listed, err := roles.List()
if err != nil {
return nil, err
}
refs := make(map[uint32]RoleRef, len(listed))
for _, found := range listed {
refs[found.ID] = NewRoleRef(found)
}
return refs, nil
}
func lookupRoleRef(refs map[uint32]RoleRef, id uint32) *RoleRef {
ref, known := refs[id]
if !known {
return nil
}
return &ref
}
@@ -11,6 +11,17 @@ import (
"slices" "slices"
) )
func builtInRole(roles role.Repository, name string) (role.Role, error) {
found, err := roles.GetByName(name)
if err != nil {
return role.Role{}, err
}
if !found.BuiltIn {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
}
func roleTarget(found role.Role) audit.Target { func roleTarget(found role.Role) audit.Target {
return audit.NewTarget(audit.TargetRole, found.ID, found.Name) return audit.NewTarget(audit.TargetRole, found.ID, found.Name)
} }
@@ -33,9 +44,41 @@ func ensureCovered(caller ContentCaller, contentTypes contenttype.Repository, ca
if err != nil { if err != nil {
return err return err
} }
return ensureCoveredBy(caller, typeKeys, candidate)
}
func ensureCoveredBy(caller ContentCaller, typeKeys []string, candidate role.Role) error {
return caller.Grants.Covers(candidate.GrantSet(), typeKeys) return caller.Grants.Covers(candidate.GrantSet(), typeKeys)
} }
func assignableRole(roles role.Repository, id uint32) (role.Role, error) {
if id == 0 {
return role.Role{}, role.ErrUnknownRole
}
found, err := roles.Get(id)
if errors.Is(err, role.ErrRoleNotFound) {
return role.Role{}, role.ErrUnknownRole
}
if err != nil {
return role.Role{}, err
}
if found.Locked {
return role.Role{}, role.ErrRoleLocked
}
return found, nil
}
func coveredAssignableRole(caller ContentCaller, roles role.Repository, contentTypes contenttype.Repository, id uint32) (role.Role, error) {
found, err := assignableRole(roles, id)
if err != nil {
return role.Role{}, err
}
if err := ensureCovered(caller, contentTypes, found); err != nil {
return role.Role{}, err
}
return found, nil
}
func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error { func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error {
account, err := users.Get(caller.UserID) account, err := users.Get(caller.UserID)
if err != nil { if err != nil {
+2 -27
View File
@@ -4,7 +4,6 @@ import (
"RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"errors"
"slices" "slices"
"time" "time"
) )
@@ -43,7 +42,7 @@ func (v roleViewer) liveGrants(grants []role.Grant) ([]role.Grant, error) {
} }
func (v roleViewer) views(found []role.Role) ([]RoleView, error) { func (v roleViewer) views(found []role.Role) ([]RoleView, error) {
counts, err := memberCounts(v.roles, v.users.GetAll()) counts, err := v.roles.MemberCounts()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -75,7 +74,7 @@ func (v roleViewer) one(found role.Role, members int) (RoleView, error) {
} }
func (v roleViewer) members(roleID uint32) (int, error) { func (v roleViewer) members(roleID uint32) (int, error) {
counts, err := memberCounts(v.roles, v.users.GetAll()) counts, err := v.roles.MemberCounts()
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -117,27 +116,3 @@ func toRoleView(found role.Role, members int, editors map[uint32]string) RoleVie
} }
return view return view
} }
func memberCounts(roles role.Repository, accounts []user.User) (map[uint32]int, error) {
counts := map[uint32]int{}
legacyIDs := map[user.Role]uint32{}
for _, account := range accounts {
id := account.RoleID
if id == 0 {
resolved, cached := legacyIDs[account.Role]
if !cached {
found, err := resolveRole(roles, account)
if err != nil && !errors.Is(err, role.ErrRoleNotFound) {
return nil, err
}
resolved = found.ID
legacyIDs[account.Role] = resolved
}
id = resolved
}
if id != 0 {
counts[id]++
}
}
return counts, nil
}
+2 -1
View File
@@ -116,7 +116,7 @@ func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Rep
hasher := atomicPlainHasher{} hasher := atomicPlainHasher{}
handler := auth.NewAcceptInviteHandler( handler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(invites, hasher), useCases.NewGetInviteUseCase(invites, hasher),
useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), hasher, discardRecorder),
authtoken.New([]byte("test-secret")), authtoken.New([]byte("test-secret")),
true, true,
) )
@@ -143,6 +143,7 @@ func seedRealInvite(t *testing.T, db *gorm.DB) models.Invite {
HashedCode: "hashed:" + acceptCode, HashedCode: "hashed:" + acceptCode,
ExpiresAt: time.Now().Add(48 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour),
InvitedBy: 1, InvitedBy: 1,
RoleID: uaRoleIDPtr(writerRoleID),
} }
if err := db.Create(&pending).Error; err != nil { if err := db.Create(&pending).Error; err != nil {
t.Fatalf("seed invite: %v", err) t.Fatalf("seed invite: %v", err)
+6 -6
View File
@@ -55,20 +55,20 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
getUser := useCases.NewGetUserUseCase(f.users) getUser := useCases.NewGetUserUseCase(f.users)
acceptHandler := auth.NewAcceptInviteHandler( acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher), useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder), useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), f.hasher, discardRecorder),
f.token, f.token,
true, true,
) )
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
listHandler := userhandler.NewInviteHandler( listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
nil, nil,
nil, nil,
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
) )
registerHandler := auth.NewLoginHandler( registerHandler := auth.NewLoginHandler(
f.token, f.token,
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewCreateUserUseCase(f.users, newSeededMockRoleRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(f.users, f.hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(f.users, f.hasher, discardRecorder),
true, true,
) )
@@ -497,7 +497,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) { newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{} users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
invites.users = users invites.users = users
return useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), users, invites, hasher return useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), hasher, discardRecorder), users, invites, hasher
} }
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword} command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
@@ -571,7 +571,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
invites.invites[seeded.ID] = seeded invites.invites[seeded.ID] = seeded
users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail} users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail}
}} }}
uc := useCases.NewAcceptInviteUseCase(users, racing, hasher, discardRecorder) uc := useCases.NewAcceptInviteUseCase(users, racing, newSeededMockRoleRepo(), hasher, discardRecorder)
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) { if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err) t.Errorf("expected ErrInviteInvalid, got %v", err)
} }
+4 -4
View File
@@ -64,10 +64,10 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
token: authtoken.New([]byte("test-secret")), token: authtoken.New([]byte("test-secret")),
generator: hashing.NewSHA256KeyGenerator(), generator: hashing.NewSHA256KeyGenerator(),
} }
e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner} e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: ownerRoleID}
e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleAdmin} e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: adminRoleID}
e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor", RoleID: writerRoleID} e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: writerRoleID}
e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleOwner} e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, RoleID: ownerRoleID}
e.users.nextID = 4 e.users.nextID = 4
hasher := &stubHasher{} hasher := &stubHasher{}
+2 -2
View File
@@ -115,7 +115,7 @@ func (p *producersEnv) seedBanner(id uint32, filename string, hash string) {
} }
func (p *producersEnv) seedPendingInvite(email string) invite.Invite { func (p *producersEnv) seedPendingInvite(email string) invite.Invite {
return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()}) return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()})
} }
func paramsJSON(t *testing.T, event audit.Event) string { func paramsJSON(t *testing.T, event audit.Event) string {
@@ -770,7 +770,7 @@ func TestActivityLogProducersUsers(t *testing.T) {
func (p *producersEnv) seedAcceptedInvite(email string) { func (p *producersEnv) seedAcceptedInvite(email string) {
accepted := time.Now().Add(-time.Hour) accepted := time.Now().Add(-time.Hour)
p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)}) p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)})
} }
func TestActivityLogProducersSystem(t *testing.T) { func TestActivityLogProducersSystem(t *testing.T) {
+7 -7
View File
@@ -422,7 +422,7 @@ func TestActivityLogProducersAccounts(t *testing.T) {
t.Run("Registering is recorded with the new account as actor", func(t *testing.T) { t.Run("Registering is recorded with the new account as actor", func(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
uc := useCases.NewCreateUserUseCase(newUsers(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", h.recorder) uc := useCases.NewCreateUserUseCase(newUsers(), newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", h.recorder)
created, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}) created, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"})
@@ -454,7 +454,7 @@ func TestActivityLogProducersAccounts(t *testing.T) {
users := newUsers() users := newUsers()
mail := newMockMailRepo() mail := newMockMailRepo()
example.setup(users, mail) example.setup(users, mail)
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder) uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mail, "https://cms.example.com", h.recorder)
if _, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}); err == nil { if _, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}); err == nil {
t.Fatal("expected a refusal") t.Fatal("expected a refusal")
@@ -478,7 +478,7 @@ func TestActivityLogProducersAccounts(t *testing.T) {
mail.fail.failNext("Send") mail.fail.failNext("Send")
cleanupFailure := errors.New("cleanup failure") cleanupFailure := errors.New("cleanup failure")
users.fail.failNextWith("Delete", cleanupFailure) users.fail.failNextWith("Delete", cleanupFailure)
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder) uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mail, "https://cms.example.com", h.recorder)
_, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}) _, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"})
@@ -539,8 +539,8 @@ func TestActivityLogProducersAccounts(t *testing.T) {
users := newUsers() users := newUsers()
invites := newInviteFakeRepo() invites := newInviteFakeRepo()
invites.users = users invites.users = users
seeded := invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(time.Hour)}) seeded := invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(time.Hour)})
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder) uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, h.recorder)
created, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: "the-code", Username: "sam", Password: password}) created, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: "the-code", Username: "sam", Password: password})
@@ -578,8 +578,8 @@ func TestActivityLogProducersAccounts(t *testing.T) {
} }
invites := newInviteFakeRepo() invites := newInviteFakeRepo()
invites.users = users invites.users = users
invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(example.expiresIn)}) invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(example.expiresIn)})
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder) uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, h.recorder)
if _, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: example.code, Username: "sam", Password: password}); err == nil { if _, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: example.code, Username: "sam", Password: password}); err == nil {
t.Fatal("expected a refusal") t.Fatal("expected a refusal")
+3 -3
View File
@@ -38,9 +38,9 @@ func newActivityWorld(t *testing.T) *activityWorld {
keys: newMockAPIKeyRepo(), keys: newMockAPIKeyRepo(),
} }
w.probe.maxReads = 50 w.probe.maxReads = 50
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner} w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, RoleID: ownerRoleID}
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin} w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, RoleID: adminRoleID}
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"} w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true}
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites(), plainAuthors()) w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites(), plainAuthors())
w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock) w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock)
w.export = useCases.NewExportActivityUseCase(h.probe, h.clock) w.export = useCases.NewExportActivityUseCase(h.probe, h.clock)
+2
View File
@@ -79,10 +79,12 @@ var routesNotYetInTheCatalogue = []string{
"POST /v1/role", "POST /v1/role",
"POST /v1/role/{id}/reassign", "POST /v1/role/{id}/reassign",
"POST /v1/role/{id}/reset", "POST /v1/role/{id}/reset",
"POST /v1/site/transfer-ownership",
"PUT /v1/content-type/{key}", "PUT /v1/content-type/{key}",
"PUT /v1/content-type/{key}/entry/{id}", "PUT /v1/content-type/{key}/entry/{id}",
"PUT /v1/image-variants/{name}", "PUT /v1/image-variants/{name}",
"PUT /v1/role/{id}", "PUT /v1/role/{id}",
"PUT /v1/user/{id}/role",
} }
func TestRouterRoutesAreInTheCatalogue(t *testing.T) { func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
+11 -11
View File
@@ -93,10 +93,10 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}}, Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
}} }}
env.users.users[apiKeyOwnerId] = user.User{ID: apiKeyOwnerId, Username: "marta", Email: "marta@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleOwner} env.users.users[apiKeyOwnerId] = user.User{ID: apiKeyOwnerId, Username: "marta", Email: "marta@example.com", Password: "hashed:oldpassword1", IsVerified: true, RoleID: ownerRoleID}
env.users.users[apiKeyDevId] = user.User{ID: apiKeyDevId, Username: "dev", Email: "dev@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleAdmin, PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: env.now.Add(time.Hour)} env.users.users[apiKeyDevId] = user.User{ID: apiKeyDevId, Username: "dev", Email: "dev@example.com", Password: "hashed:oldpassword1", IsVerified: true, RoleID: adminRoleID, PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: env.now.Add(time.Hour)}
env.users.users[apiKeyPatId] = user.User{ID: apiKeyPatId, Username: "pat", Email: "pat@example.com", IsVerified: true, Role: user.RoleAdmin} env.users.users[apiKeyPatId] = user.User{ID: apiKeyPatId, Username: "pat", Email: "pat@example.com", IsVerified: true, RoleID: adminRoleID}
env.users.users[apiKeyQuinnId] = user.User{ID: apiKeyQuinnId, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleAdmin} env.users.users[apiKeyQuinnId] = user.User{ID: apiKeyQuinnId, Username: "quinn", Email: "quinn@example.com", IsVerified: false, RoleID: adminRoleID}
env.users.nextID = 4 env.users.nextID = 4
clock := func() time.Time { return env.now } clock := func() time.Time { return env.now }
@@ -119,19 +119,19 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles), CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, newSeededMockRoleRepo(), hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, "https://cms.example.com", recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, "https://cms.example.com", recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites, newSeededMockRoleRepo()),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher), GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder, imaging.NewMetadataStripper()), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
@@ -575,7 +575,7 @@ func TestAPIKeysHttp(t *testing.T) {
t.Run("A writer's key can not invite", func(t *testing.T) { t.Run("A writer's key can not invite", func(t *testing.T) {
env := newAPIKeyEnv(t) env := newAPIKeyEnv(t)
env.addUser(5, "", writerRoleID, true) env.addUser(5, writerRoleID, true)
raw, _ := env.issueKey(5, "ci") raw, _ := env.issueKey(5, "ci")
rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw)
@@ -657,7 +657,7 @@ func TestAPIKeysHttp(t *testing.T) {
t.Run("A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie", func(t *testing.T) { t.Run("A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie", func(t *testing.T) {
env := newAPIKeyEnv(t) env := newAPIKeyEnv(t)
env.addUser(5, "", writerRoleID, true) env.addUser(5, writerRoleID, true)
raw, _ := env.issueKey(5, "ci") raw, _ := env.issueKey(5, "ci")
invite := env.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", body: `{"email": "new@example.com"}`, bearer: raw, cookieUser: apiKeyOwnerId}) invite := env.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", body: `{"email": "new@example.com"}`, bearer: raw, cookieUser: apiKeyOwnerId})
+4 -4
View File
@@ -181,10 +181,10 @@ var (
func apiKeyUnitUsers() *mockUserRepo { func apiKeyUnitUsers() *mockUserRepo {
users := newMockUserRepo() users := newMockUserRepo()
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner} users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, RoleID: ownerRoleID}
users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 3} users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, RoleID: adminRoleID, TokenVersion: 3}
users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, Role: user.RoleAdmin} users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, RoleID: adminRoleID}
users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, Role: user.RoleAdmin} users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, RoleID: adminRoleID}
return users return users
} }
+4 -5
View File
@@ -340,7 +340,6 @@ var _ author.Repository = &mockAuthorRepo{}
const ( const (
authorEditorID = 5 authorEditorID = 5
authorOtherEditorID = 6 authorOtherEditorID = 6
authorEditorRole = user.Role("editor")
) )
type authorEnv struct { type authorEnv struct {
@@ -351,8 +350,8 @@ type authorEnv struct {
func newAuthorEnv(t *testing.T) *authorEnv { func newAuthorEnv(t *testing.T) *authorEnv {
t.Helper() t.Helper()
p := newProducersEnv(t) p := newProducersEnv(t)
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, RoleID: writerRoleID}
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, RoleID: writerRoleID}
p.users.nextID = authorOtherEditorID p.users.nextID = authorOtherEditorID
authors := newMockAuthorRepo(p.articles) authors := newMockAuthorRepo(p.articles)
sites := plainSites() sites := plainSites()
@@ -437,9 +436,9 @@ func joinNames(names []string) string {
} }
func userWithEmail(id uint32, username string, email string) user.User { func userWithEmail(id uint32, username string, email string) user.User {
return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} return user.User{ID: id, Username: username, Email: email, IsVerified: true, RoleID: writerRoleID}
} }
func userNamed(id uint32, username string, displayName string) user.User { func userNamed(id uint32, username string, displayName string) user.User {
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, RoleID: writerRoleID}
} }
+1 -1
View File
@@ -383,7 +383,7 @@ func TestAuthorsBackendCurrentUser(t *testing.T) {
t.Run("The existing fields are unchanged", func(t *testing.T) { t.Run("The existing fields are unchanged", func(t *testing.T) {
e := newAuthorEnv(t) e := newAuthorEnv(t)
body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")) body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", ""))
for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} { for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "access"} {
if _, present := body[key]; !present { if _, present := body[key]; !present {
t.Errorf("missing %s", key) t.Errorf("missing %s", key)
} }
@@ -19,7 +19,7 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
hasher := &stubHasher{} hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true, true,
) )
@@ -40,8 +40,8 @@ var supportFieldKeys = []string{"default", "key", "label", "max", "min", "option
func newSupportEnv(t *testing.T) *contentTypeEnv { func newSupportEnv(t *testing.T) *contentTypeEnv {
t.Helper() t.Helper()
env := newContentTypeEnv(t) env := newContentTypeEnv(t)
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner} env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, RoleID: ownerRoleID}
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, Role: user.RoleAdmin} env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, RoleID: adminRoleID}
return env return env
} }
@@ -332,7 +332,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
{"site", "site_name", "text", false, nil, float64(100), []any{}}, {"site", "site_name", "text", false, nil, float64(100), []any{}},
{"site", "title_template", "text", false, nil, float64(200), []any{}}, {"site", "title_template", "text", false, nil, float64(200), []any{}},
{"site", "default_description", "text", false, nil, float64(320), []any{}}, {"site", "default_description", "text", false, nil, float64(320), []any{}},
{"user", "role", "select", true, nil, nil, []any{"owner", "admin"}}, {"user", "role", "text", false, nil, nil, []any{}},
{"user", "email", "text", true, nil, nil, []any{}}, {"user", "email", "text", true, nil, nil, []any{}},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -412,7 +412,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
t.Run("Reading the built-in types never touches data", func(t *testing.T) { t.Run("Reading the built-in types never touches data", func(t *testing.T) {
validator.InitValidator() validator.InitValidator()
users := newMockUserRepo() users := newMockUserRepo()
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin} users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID}
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
now := func() time.Time { return time.Now() } now := func() time.Time { return time.Now() }
generator := hashing.NewSHA256KeyGenerator() generator := hashing.NewSHA256KeyGenerator()
@@ -445,6 +445,8 @@ type panickingContentTypeRepo struct{ contenttype.Repository }
type panickingEntryRepo struct{ entry.Repository } type panickingEntryRepo struct{ entry.Repository }
var catalogueFieldsServedAsReferences = map[string]struct{}{"user.role": {}}
func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) { func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) {
entities := map[string]any{ entities := map[string]any{
builtintype.KeyArticle: article.Article{}, builtintype.KeyArticle: article.Article{},
@@ -466,6 +468,9 @@ func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, field := range found.Fields { for _, field := range found.Fields {
if _, derived := catalogueFieldsServedAsReferences[key+"."+field.Key]; derived {
continue
}
if _, exists := object[field.Key]; !exists { if _, exists := object[field.Key]; !exists {
t.Errorf("catalogue field %s.%s does not exist in the entity JSON", key, field.Key) t.Errorf("catalogue field %s.%s does not exist in the entity JSON", key, field.Key)
} }
+5 -5
View File
@@ -257,11 +257,11 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
token: authtoken.New([]byte("test-secret")), token: authtoken.New([]byte("test-secret")),
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC), clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
} }
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner} env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, RoleID: ownerRoleID}
env.users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin} env.users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID}
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true} env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true}
env.users.users[ctUnverifiedId] = user.User{ID: ctUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin} env.users.users[ctUnverifiedId] = user.User{ID: ctUnverifiedId, Username: "quinn", IsVerified: false, RoleID: adminRoleID}
env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1} env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, RoleID: adminRoleID, TokenVersion: 1}
now := func() time.Time { return env.clock } now := func() time.Time { return env.clock }
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
@@ -273,7 +273,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types), GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now), UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()), DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo(), newSeededMockRoleRepo()),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(), ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(env.users), LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(env.users),
+6 -6
View File
@@ -67,7 +67,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
userRepo.users[7] = user.User{ userRepo.users[7] = user.User{
ID: 7, ID: 7,
Username: "alice", Username: "alice",
Role: user.RoleOwner, RoleID: ownerRoleID,
Email: "alice@example.com", Email: "alice@example.com",
IsVerified: true, IsVerified: true,
TokenVersion: 0, TokenVersion: 0,
@@ -98,7 +98,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
userRepo.users[8] = user.User{ userRepo.users[8] = user.User{
ID: 8, ID: 8,
Username: "bob", Username: "bob",
Role: user.RoleOwner, RoleID: ownerRoleID,
Email: "bob@example.com", Email: "bob@example.com",
IsVerified: false, IsVerified: false,
TokenVersion: 0, TokenVersion: 0,
@@ -126,7 +126,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("No jwt cookie at all is refused", func(t *testing.T) { t.Run("No jwt cookie at all is refused", func(t *testing.T) {
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil)
@@ -139,7 +139,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) { t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 1} userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 1}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
cookie := mintGetCurrentUserCookie(t, token, 7, 0) cookie := mintGetCurrentUserCookie(t, token, 7, 0)
@@ -156,7 +156,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) { t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: false, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: false, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
cookie := mintGetCurrentUserCookie(t, token, 7, 0) cookie := mintGetCurrentUserCookie(t, token, 7, 0)
@@ -177,7 +177,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) { t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
cookie := mintGetCurrentUserCookie(t, token, 7, 0) cookie := mintGetCurrentUserCookie(t, token, 7, 0)
+4 -4
View File
@@ -34,7 +34,7 @@ func (s *spyHasher) Compare(hashed string, value string) error {
func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) { func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
t.Run("stores hashed password and hashed verification code, returns raw code", func(t *testing.T) { t.Run("stores hashed password and hashed verification code, returns raw code", func(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice", Username: "alice",
@@ -59,7 +59,7 @@ func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
t.Run("a hashing failure persists nothing", func(t *testing.T) { t.Run("a hashing failure persists nothing", func(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice", Username: "alice",
@@ -177,7 +177,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true, true,
) )
@@ -195,7 +195,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true, true,
) )
+4 -4
View File
@@ -88,11 +88,11 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
usage: newMockVariantUsageRepo(), usage: newMockVariantUsageRepo(),
} }
env.tracker = usage.NewTracker(env.usage, func() time.Time { return env.now }) env.tracker = usage.NewTracker(env.usage, func() time.Time { return env.now })
env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner} env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, RoleID: ownerRoleID}
env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin} env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID}
env.users.users[variantPlainUserId] = user.User{ID: variantPlainUserId, Username: "pat", IsVerified: true} env.users.users[variantPlainUserId] = user.User{ID: variantPlainUserId, Username: "pat", IsVerified: true}
env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin} env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, RoleID: adminRoleID}
env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1} env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, RoleID: adminRoleID, TokenVersion: 1}
container := env.container(limits) container := env.container(limits)
admin := chi.NewRouter() admin := chi.NewRouter()
+19 -18
View File
@@ -111,7 +111,7 @@ func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.Us
var _ invite.Repository = &inviteFakeRepo{} var _ invite.Repository = &inviteFakeRepo{}
func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite { func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite {
return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, CreatedAt: createdAt, ExpiresAt: expiresAt} return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: createdAt, ExpiresAt: expiresAt}
} }
func pendingInvite(email string) invite.Invite { func pendingInvite(email string) invite.Invite {
@@ -126,7 +126,7 @@ func expiredInvite(email string) invite.Invite {
func inviteInviterRepo(displayName string) *mockUserRepo { func inviteInviterRepo(displayName string) *mockUserRepo {
repo := newMockUserRepo() repo := newMockUserRepo()
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner} repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID}
repo.nextID = 7 repo.nextID = 7
return repo return repo
} }
@@ -152,10 +152,10 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
} }
getUser := useCases.NewGetUserUseCase(f.users) getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler( handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
nil, nil,
nil, nil,
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
) )
f.router = chi.NewRouter() f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) { f.router.Route("/v1/user", func(r chi.Router) {
@@ -165,6 +165,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
r.Use(middleware.RequireVerifiedUser(getUser)) r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers) r.Get("/", handler.ListUsers)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite"))
r.Use(middleware.TrustedProxyRealIP(nil)) r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour)) r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Post("/invite", handler.CreateInvite) r.Post("/invite", handler.CreateInvite)
@@ -238,7 +239,7 @@ func TestInvitationsHttp(t *testing.T) {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
} }
decoded := decodeKeys(t, rec.Body.Bytes()) decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at") assertExactKeys(t, decoded, "id", "kind", "email", "role", "status", "created_at", "expires_at")
if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "sam.oliveira@renew.example" || decodedString(t, decoded, "status") != "pending" { if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "sam.oliveira@renew.example" || decodedString(t, decoded, "status") != "pending" {
t.Errorf("unexpected body: %s", rec.Body.String()) t.Errorf("unexpected body: %s", rec.Body.String())
} }
@@ -271,7 +272,7 @@ func TestInvitationsHttp(t *testing.T) {
t.Run("The email names the inviter by display name when there is one", func(t *testing.T) { t.Run("The email names the inviter by display name when there is one", func(t *testing.T) {
f := newInviteFixture(t, 100) f := newInviteFixture(t, 100)
f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true} f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true, RoleID: ownerRoleID}
rec := f.post(t, `{"email": "lea.martin@renew.example"}`) rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
@@ -493,7 +494,7 @@ func TestInvitationsHttp(t *testing.T) {
t.Errorf("item %d unexpected: %v", index, item) t.Errorf("item %d unexpected: %v", index, item)
} }
if want.kind == "invite" { if want.kind == "invite" {
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at") assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
} else { } else {
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at") assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
} }
@@ -536,7 +537,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
hasher := &stubHasher{} hasher := &stubHasher{}
handler := auth.NewLoginHandler( handler := auth.NewLoginHandler(
authtoken.New([]byte("test-secret")), authtoken.New([]byte("test-secret")),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder), useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), hasher, mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
true, true,
) )
@@ -601,12 +602,12 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
func TestCreateInviteUseCase(t *testing.T) { func TestCreateInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) { newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) {
users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{} users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{}
return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher return useCases.NewCreateInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher
} }
t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) { t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) {
uc, _, _, _, _ := newUseCase() uc, _, _, _, _ := newUseCase()
got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7}) got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "A@B.Example", InvitedBy: 7})
if err != nil || got.Email != "a@b.example" || got.ID == 0 { if err != nil || got.Email != "a@b.example" || got.ID == 0 {
t.Fatalf("unexpected result %+v, %v", got, err) t.Fatalf("unexpected result %+v, %v", got, err)
} }
@@ -614,7 +615,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("unknown inviter", func(t *testing.T) { t.Run("unknown inviter", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase() uc, _, invites, _, _ := newUseCase()
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99}) _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 99})
if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 { if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 {
t.Errorf("expected ErrUserNotFound, got %v", err) t.Errorf("expected ErrUserNotFound, got %v", err)
} }
@@ -623,7 +624,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("existing account is refused case-insensitively", func(t *testing.T) { t.Run("existing account is refused case-insensitively", func(t *testing.T) {
uc, users, _, _, _ := newUseCase() uc, users, _, _, _ := newUseCase()
users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"} users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"}
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7}) _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "jonas@renew.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrEmailHasAccount) { if !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err) t.Errorf("expected ErrEmailHasAccount, got %v", err)
} }
@@ -632,7 +633,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("pending invite is refused", func(t *testing.T) { t.Run("pending invite is refused", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase() uc, _, invites, _, _ := newUseCase()
invites.seed(pendingInvite("a@b.example")) invites.seed(pendingInvite("a@b.example"))
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrPendingInvite) { if !errors.Is(err, invite.ErrPendingInvite) {
t.Errorf("expected ErrPendingInvite, got %v", err) t.Errorf("expected ErrPendingInvite, got %v", err)
} }
@@ -645,7 +646,7 @@ func TestCreateInviteUseCase(t *testing.T) {
accepted.AcceptedAt = &acceptedAt accepted.AcceptedAt = &acceptedAt
invites.seed(accepted) invites.seed(accepted)
invites.seed(pendingInvite("other@b.example")) invites.seed(pendingInvite("other@b.example"))
if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil { if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}); err != nil {
t.Errorf("unexpected error %v", err) t.Errorf("unexpected error %v", err)
} }
if len(invites.invites) != 3 { if len(invites.invites) != 3 {
@@ -656,7 +657,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("hash failure stores and sends nothing", func(t *testing.T) { t.Run("hash failure stores and sends nothing", func(t *testing.T) {
uc, _, invites, mail, hasher := newUseCase() uc, _, invites, mail, hasher := newUseCase()
hasher.hashErr = errors.New("hash boom") hasher.hashErr = errors.New("hash boom")
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 { if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 {
t.Errorf("expected failure without side effects, got %v", err) t.Errorf("expected failure without side effects, got %v", err)
} }
@@ -666,7 +667,7 @@ func TestCreateInviteUseCase(t *testing.T) {
uc, _, invites, mail, _ := newUseCase() uc, _, invites, mail, _ := newUseCase()
invites.seed(expiredInvite("a@b.example")) invites.seed(expiredInvite("a@b.example"))
invites.fail.failNext("Delete") invites.fail.failNext("Delete")
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 { if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 {
t.Errorf("expected repository failure, got %v", err) t.Errorf("expected repository failure, got %v", err)
} }
@@ -676,7 +677,7 @@ func TestCreateInviteUseCase(t *testing.T) {
uc, _, invites, mail, _ := newUseCase() uc, _, invites, mail, _ := newUseCase()
mail.fail.failNextWith("Send", errors.New("smtp down")) mail.fail.failNextWith("Send", errors.New("smtp down"))
invites.fail.failNextWith("Delete", errors.New("cleanup down")) invites.fail.failNextWith("Delete", errors.New("cleanup down"))
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") { if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") {
t.Errorf("unexpected error %v", err) t.Errorf("unexpected error %v", err)
} }
@@ -742,7 +743,7 @@ func TestInvitationsHandlerUsesPublicUserSafeShape(t *testing.T) {
func TestListUsersAndInvitesUseCase(t *testing.T) { func TestListUsersAndInvitesUseCase(t *testing.T) {
t.Run("empty repositories give an empty non-nil list", func(t *testing.T) { t.Run("empty repositories give an empty non-nil list", func(t *testing.T) {
got := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo()).ListUsersAndInvites() got, _ := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo(), newSeededMockRoleRepo()).ListUsersAndInvites()
if got == nil || len(got) != 0 { if got == nil || len(got) != 0 {
t.Errorf("expected empty non-nil slice, got %#v", got) t.Errorf("expected empty non-nil slice, got %#v", got)
} }
+24 -24
View File
@@ -48,21 +48,21 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
token: authtoken.New([]byte("test-secret")), token: authtoken.New([]byte("test-secret")),
} }
f.invites.users = f.users f.invites.users = f.users
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, RoleID: writerRoleID} f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: writerRoleID}
f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin} f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, RoleID: adminRoleID}
f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2} f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, RoleID: ownerRoleID, TokenVersion: 2}
f.users.nextID = 12 f.users.nextID = 12
getUser := useCases.NewGetUserUseCase(f.users) getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler( handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewCancelInviteUseCase(f.invites, discardRecorder), useCases.NewCancelInviteUseCase(f.invites, discardRecorder),
useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewRenewInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
) )
acceptHandler := auth.NewAcceptInviteHandler( acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher), useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder), useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), f.hasher, discardRecorder),
f.token, f.token,
true, true,
) )
@@ -145,7 +145,7 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("A cancelled invite's link stops working and its address can be invited again", func(t *testing.T) { t.Run("A cancelled invite's link stops working and its address can be invited again", func(t *testing.T) {
f := newInviteManagementFixture(t, 100) f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
rec := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil) rec := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil)
@@ -223,7 +223,7 @@ func TestInviteManagementHttp(t *testing.T) {
for _, filler := range []string{"a@renew.example", "b@renew.example", "c@renew.example"} { for _, filler := range []string{"a@renew.example", "b@renew.example", "c@renew.example"} {
f.invites.seed(pendingInvite(filler)) f.invites.seed(pendingInvite(filler))
} }
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)}) f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, RoleID: writerRoleID, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)})
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/4/renew") rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/4/renew")
@@ -231,7 +231,7 @@ func TestInviteManagementHttp(t *testing.T) {
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Cache-Control")) t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Cache-Control"))
} }
decoded := decodeKeys(t, rec.Body.Bytes()) decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at") assertExactKeys(t, decoded, "id", "kind", "email", "role", "status", "created_at", "expires_at")
if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "lea.martin@renew.example" || decodedString(t, decoded, "status") != "pending" { if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "lea.martin@renew.example" || decodedString(t, decoded, "status") != "pending" {
t.Errorf("unexpected body %s", rec.Body.String()) t.Errorf("unexpected body %s", rec.Body.String())
} }
@@ -268,8 +268,8 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("A renewed pending invite moves to the top of the list and the old link dies", func(t *testing.T) { t.Run("A renewed pending invite moves to the top of the list and the old link dies", func(t *testing.T) {
f := newInviteManagementFixture(t, 100) f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)}) f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)})
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
@@ -288,8 +288,8 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("Accepting with a cancelled or replaced code is not found and creates no user", func(t *testing.T) { t.Run("Accepting with a cancelled or replaced code is not found and creates no user", func(t *testing.T) {
f := newInviteManagementFixture(t, 100) f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
f.asOwner(t, http.MethodDelete, "/v1/user/invite/2") f.asOwner(t, http.MethodDelete, "/v1/user/invite/2")
usersBefore := len(f.users.users) usersBefore := len(f.users.users)
@@ -363,7 +363,7 @@ func TestInviteManagementHttp(t *testing.T) {
t.Run("The old code of a pending invite works until it is renewed", func(t *testing.T) { t.Run("The old code of a pending invite works until it is renewed", func(t *testing.T) {
f := newInviteManagementFixture(t, 100) f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
if before := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); before.Code != http.StatusOK { if before := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); before.Code != http.StatusOK {
t.Fatalf("control expected 200, got %d", before.Code) t.Fatalf("control expected 200, got %d", before.Code)
@@ -480,9 +480,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo() mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example")) seeded := invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down")) mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder) renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded { if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err) t.Errorf("unexpected result %v", err)
@@ -495,9 +495,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo() mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example")) invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down")) mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL, discardRecorder) renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) { if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) {
t.Errorf("unexpected result %v", err) t.Errorf("unexpected result %v", err)
@@ -530,9 +530,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo() mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example")) seeded := invites.seed(pendingInvite("a@renew.example"))
invites.fail.failNextWith("Renew", errRepositoryFailure) invites.fail.failNextWith("Renew", errRepositoryFailure)
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder) renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded { if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err) t.Errorf("unexpected result %v", err)
@@ -544,9 +544,9 @@ func TestManageInviteUseCases(t *testing.T) {
invites := newInviteFakeRepo() invites := newInviteFakeRepo()
mail := newMockMailRepo() mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example")) invites.seed(pendingInvite("a@renew.example"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder) renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil { if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7}); err != nil {
t.Fatalf("unexpected %v", err) t.Fatalf("unexpected %v", err)
} }
if inviteMailData(t, mail).InvitedBy != "Marta L." { if inviteMailData(t, mail).InvitedBy != "Marta L." {
@@ -565,7 +565,7 @@ func TestInviteRepositoryRenew(t *testing.T) {
} }
repo := persistence.NewInviteRepository(db) repo := persistence.NewInviteRepository(db)
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)}) created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)})
if err != nil { if err != nil {
t.Fatalf("create failed: %v", err) t.Fatalf("create failed: %v", err)
} }
@@ -36,7 +36,7 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true, true,
) )
+1 -1
View File
@@ -19,7 +19,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
hasher := &stubHasher{} hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true, true,
) )
+1 -1
View File
@@ -15,7 +15,7 @@ const mediaGetEditorID = 9
func newMediaGetEnv(t *testing.T) *siteEnv { func newMediaGetEnv(t *testing.T) *siteEnv {
t.Helper() t.Helper()
e := newSiteEnvWithMain(t) e := newSiteEnvWithMain(t)
e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID} e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, RoleID: writerRoleID}
e.seedImageRecord(5) e.seedImageRecord(5)
return e return e
} }
+1 -1
View File
@@ -92,7 +92,7 @@ func newHardeningEnv(t *testing.T) *hardeningEnv {
users: newMockUserRepo(), users: newMockUserRepo(),
token: authtoken.New([]byte("test-secret")), token: authtoken.New([]byte("test-secret")),
} }
env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, Role: user.RoleAdmin} env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, RoleID: adminRoleID}
env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries) env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries)
env.storage = newMergeRecordingStorage(env.transactor) env.storage = newMergeRecordingStorage(env.transactor)
env.variants = newMergeRecordingVariantFiles(env.transactor) env.variants = newMergeRecordingVariantFiles(env.transactor)
+4 -4
View File
@@ -96,11 +96,11 @@ func purgeTime(value string) *time.Time {
func newPurgeEnv(t *testing.T) *mediaEnv { func newPurgeEnv(t *testing.T) *mediaEnv {
t.Helper() t.Helper()
env := newMediaEnv(t) env := newMediaEnv(t)
env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, Role: user.RoleOwner} env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, RoleID: ownerRoleID}
env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, Role: user.RoleAdmin} env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, RoleID: adminRoleID}
env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true, Role: user.Role("editor")} env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true}
env.users.users[purgeNoRoleId] = user.User{ID: purgeNoRoleId, Username: "norole", IsVerified: true} env.users.users[purgeNoRoleId] = user.User{ID: purgeNoRoleId, Username: "norole", IsVerified: true}
env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, Role: user.RoleOwner} env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, RoleID: ownerRoleID}
return env return env
} }
+6 -3
View File
@@ -15,16 +15,19 @@ import (
) )
func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) { func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) {
for _, role := range []string{"OWNER", "Admin", "superuser"} { for _, unusable := range []struct {
name string
roleID uint32
}{{"no role", 0}, {"missing role", 99}} {
for _, route := range []struct{ method, path string }{ for _, route := range []struct{ method, path string }{
{http.MethodGet, "/v1/media/purge-settings"}, {http.MethodGet, "/v1/media/purge-settings"},
{http.MethodPatch, "/v1/media/purge-settings"}, {http.MethodPatch, "/v1/media/purge-settings"},
{http.MethodGet, "/v1/media/purge-preview"}, {http.MethodGet, "/v1/media/purge-preview"},
{http.MethodPost, "/v1/media/purge"}, {http.MethodPost, "/v1/media/purge"},
} { } {
t.Run(role+" "+route.method+" "+route.path, func(t *testing.T) { t.Run(unusable.name+" "+route.method+" "+route.path, func(t *testing.T) {
env := newPurgeEnv(t) env := newPurgeEnv(t)
env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, Role: user.Role(role)} env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, RoleID: unusable.roleID}
rec := env.send(t, route.method, route.path, "", 20) rec := env.send(t, route.method, route.path, "", 20)
+9 -5
View File
@@ -72,8 +72,9 @@ func TestMySQLMigration(t *testing.T) {
} }
}) })
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) { t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) {
_, _, db := migratedMySQL(t) _, _, db := migratedMySQL(t)
addLegacyRoleColumn(t, db)
for _, name := range []string{"first", "second", "third"} { for _, name := range []string{"first", "second", "third"} {
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', 1, 0)", name, name+"@x.example") insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', 1, 0)", name, name+"@x.example")
if insert.Error != nil { if insert.Error != nil {
@@ -85,15 +86,18 @@ func TestMySQLMigration(t *testing.T) {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
roles := map[string]string{} roles := map[string]uint32{}
var users []models.User var users []models.User
db.Find(&users) db.Find(&users)
for _, user := range users { for _, stored := range users {
roles[user.Username] = user.Role roles[stored.Username] = roleIDOf(stored)
} }
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" { if roles["first"] != storedRole(t, db, "Owner").ID || roles["second"] != storedRole(t, db, "Admin").ID || roles["third"] != storedRole(t, db, "Admin").ID {
t.Errorf("unexpected roles %v", roles) t.Errorf("unexpected roles %v", roles)
} }
if !db.Migrator().HasColumn("users", "role") {
t.Errorf("the legacy column must stay")
}
}) })
} }
+1 -1
View File
@@ -135,7 +135,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true, true,
) )
+9 -5
View File
@@ -184,8 +184,9 @@ func TestPostgresMigration(t *testing.T) {
} }
}) })
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) { t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) {
_, _, db := migratedPostgres(t) _, _, db := migratedPostgres(t)
addLegacyRoleColumn(t, db)
for _, name := range []string{"first", "second", "third"} { for _, name := range []string{"first", "second", "third"} {
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example") insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example")
if insert.Error != nil { if insert.Error != nil {
@@ -197,15 +198,18 @@ func TestPostgresMigration(t *testing.T) {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
roles := map[string]string{} roles := map[string]uint32{}
var users []models.User var users []models.User
db.Find(&users) db.Find(&users)
for _, user := range users { for _, stored := range users {
roles[user.Username] = user.Role roles[stored.Username] = roleIDOf(stored)
} }
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" { if roles["first"] != storedRole(t, db, "Owner").ID || roles["second"] != storedRole(t, db, "Admin").ID || roles["third"] != storedRole(t, db, "Admin").ID {
t.Errorf("unexpected roles %v", roles) t.Errorf("unexpected roles %v", roles)
} }
if !db.Migrator().HasColumn("users", "role") {
t.Errorf("the legacy column must stay")
}
}) })
} }
@@ -42,7 +42,7 @@ func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, p
hasher := &stubHasher{} hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL, discardRecorder), useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, publicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true, true,
) )
@@ -218,7 +218,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
userRepo.nextID = 1 userRepo.nextID = 1
userRepo.fail.failNext("Create") userRepo.fail.failNext("Create")
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "bob", Username: "bob",
@@ -240,7 +240,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
userRepo.users[2] = user.User{ID: 2, Username: "bob", Email: "bob@example.com", IsVerified: false} userRepo.users[2] = user.User{ID: 2, Username: "bob", Email: "bob@example.com", IsVerified: false}
userRepo.nextID = 2 userRepo.nextID = 2
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "carol", Username: "carol",
@@ -268,7 +268,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
t.Run("A failure hashing the verification code persists nothing", func(t *testing.T) { t.Run("A failure hashing the verification code persists nothing", func(t *testing.T) {
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &sequentialHasher{}, mailRepo, "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &sequentialHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice", Username: "alice",
@@ -291,7 +291,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
mailRepo.fail.failNext("Send") mailRepo.fail.failNext("Send")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice", Username: "alice",
@@ -312,7 +312,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
mailRepo.fail.failNext("Send") mailRepo.fail.failNext("Send")
userRepo.fail.failNext("Delete") userRepo.fail.failNext("Delete")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice", Username: "alice",
@@ -18,7 +18,7 @@ import (
func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) { func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) {
token := authtoken.New([]byte("test-secret")) token := authtoken.New([]byte("test-secret"))
hasher := &stubHasher{} hasher := &stubHasher{}
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder) createUseCase := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder)
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true) loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true)
r := chi.NewRouter() r := chi.NewRouter()
@@ -162,7 +162,7 @@ func TestRegistrationStatusEndpointHttp(t *testing.T) {
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true} userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.nextID = 1 userRepo.nextID = 1
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
if uc.IsRegistrationOpen() { if uc.IsRegistrationOpen() {
t.Fatalf("expected IsRegistrationOpen to return false") t.Fatalf("expected IsRegistrationOpen to return false")
+1 -1
View File
@@ -129,7 +129,7 @@ func newContractRepositories(db *gorm.DB) contractRepositories {
} }
func contractUser(username string, email string) domainUser.User { func contractUser(username string, email string) domainUser.User {
return domainUser.User{Username: username, Password: "hashed", Email: email, Role: domainUser.RoleAdmin, IsVerified: true} return domainUser.User{Username: username, Password: "hashed", Email: email, RoleID: adminRoleID, IsVerified: true}
} }
func contractInvite(t *testing.T, repos contractRepositories, email string) domainInvite.Invite { func contractInvite(t *testing.T, repos contractRepositories, email string) domainInvite.Invite {
+1 -1
View File
@@ -281,7 +281,7 @@ func TestGrantSetTypedDecisions(t *testing.T) {
func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) { func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) {
db := openMigratedRolesDB(t) db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil) createUserRow(t, db, 3, ownerIDPtr(t, db))
createArticleRow(t, db, 10, 0) createArticleRow(t, db, 10, 0)
if err := db.Delete(&models.Article{}, 10).Error; err != nil { if err := db.Delete(&models.Article{}, 10).Error; err != nil {
t.Fatal(err) t.Fatal(err)
+10 -5
View File
@@ -119,16 +119,21 @@ func TestRoleRepositoryFailures(t *testing.T) {
}) })
} }
func TestBackfillUserRoleIDsWithoutBuiltInRoles(t *testing.T) { func TestBackfillWithoutBuiltInRoles(t *testing.T) {
db := openUnmigratedSQLite(t) db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}); err != nil { if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}, &models.Invite{}, &models.MigrationMarker{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
createUserRow(t, db, 1, "owner", nil) addLegacyRoleColumn(t, db)
if err := database.BackfillUserRoleIDs(db); err != nil { createUserRow(t, db, 1, nil)
t.Fatal(err) setLegacyRole(t, db, 1, "owner")
if err := database.BackfillLegacyUserRoles(db); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
} }
if userRow(t, db, 1).RoleID != nil { if userRow(t, db, 1).RoleID != nil {
t.Errorf("role_id must stay unset when no built-in role exists") t.Errorf("role_id must stay unset when no built-in role exists")
} }
if err := database.BackfillCreatedBy(db); err != nil {
t.Errorf("created_by backfill without roles must do nothing, got %v", err)
}
} }
+44 -6
View File
@@ -16,6 +16,7 @@ type mockRoleRepo struct {
nextID uint32 nextID uint32
fail failSwitch fail failSwitch
users *mockUserRepo users *mockUserRepo
invites *inviteFakeRepo
calls map[string]int calls map[string]int
beforeSave func() beforeSave func()
} }
@@ -127,18 +128,47 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
} }
func (m *mockRoleRepo) memberCount(roleID uint32) int { func (m *mockRoleRepo) memberCount(roleID uint32) int {
if m.users == nil {
return 0
}
count := 0 count := 0
if m.users != nil {
for _, member := range m.users.users { for _, member := range m.users.users {
if member.RoleID == roleID { if member.RoleID == roleID {
count++ count++
} }
} }
}
if m.invites != nil {
for _, pending := range m.invites.invites {
if pending.RoleID == roleID && pending.AcceptedAt == nil {
count++
}
}
}
return count return count
} }
func (m *mockRoleRepo) MemberCounts() (map[uint32]int, error) {
m.calls["MemberCounts"]++
if err := m.fail.trigger("MemberCounts"); err != nil {
return nil, err
}
counts := map[uint32]int{}
if m.users != nil {
for _, member := range m.users.users {
if member.RoleID != 0 {
counts[member.RoleID]++
}
}
}
if m.invites != nil {
for _, pending := range m.invites.invites {
if pending.RoleID != 0 && pending.AcceptedAt == nil {
counts[pending.RoleID]++
}
}
}
return counts, nil
}
func (m *mockRoleRepo) Delete(id uint32) error { func (m *mockRoleRepo) Delete(id uint32) error {
m.calls["Delete"]++ m.calls["Delete"]++
if err := m.fail.trigger("Delete"); err != nil { if err := m.fail.trigger("Delete"); err != nil {
@@ -159,10 +189,8 @@ func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
if err := m.fail.trigger("ReassignMembers"); err != nil { if err := m.fail.trigger("ReassignMembers"); err != nil {
return 0, err return 0, err
} }
if m.users == nil {
return 0, nil
}
moved := 0 moved := 0
if m.users != nil {
for id, member := range m.users.users { for id, member := range m.users.users {
if member.RoleID == from { if member.RoleID == from {
member.RoleID = to member.RoleID = to
@@ -170,6 +198,16 @@ func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
moved++ moved++
} }
} }
}
if m.invites != nil {
for id, pending := range m.invites.invites {
if pending.RoleID == from && pending.AcceptedAt == nil {
pending.RoleID = to
m.invites.invites[id] = pending
moved++
}
}
}
return moved, nil return moved, nil
} }
+3 -3
View File
@@ -21,7 +21,7 @@ func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
db := openMigratedRolesDB(t) db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db) repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
createUserRow(t, db, 1, "", &source.ID) createUserRow(t, db, 1, &source.ID)
moved, err := repo.ReassignMembers(source.ID, 9999) moved, err := repo.ReassignMembers(source.ID, 9999)
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 { if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 {
t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err) t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err)
@@ -81,7 +81,7 @@ func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
repo := persistence.NewRoleRepository(db) repo := persistence.NewRoleRepository(db)
kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own") gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
createUserRow(t, db, 1, "", &kept.ID) createUserRow(t, db, 1, &kept.ID)
if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) { if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Fatalf("expected ErrRoleHasMembers, got %v", err) t.Fatalf("expected ErrRoleHasMembers, got %v", err)
} }
@@ -165,7 +165,7 @@ func TestRolesAPIHardeningUseCases(t *testing.T) {
t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) { t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) {
world := newRolesAPIWorld(t) world := newRolesAPIWorld(t)
world.roles.fail.failNext("GetByName") world.roles.fail.failNext("MemberCounts")
err := world.remove(raAdmin, "Intern") err := world.remove(raAdmin, "Intern")
if !errors.Is(err, errRepositoryFailure) { if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err) t.Fatalf("expected the repository failure, got %v", err)
+4 -4
View File
@@ -144,7 +144,7 @@ func TestRoleRepositoryRolesAPI(t *testing.T) {
db := openMigratedRolesDB(t) db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db) repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
createUserRow(t, db, 1, "", &created.ID) createUserRow(t, db, 1, &created.ID)
if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) { if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Fatalf("expected ErrRoleHasMembers, got %v", err) t.Fatalf("expected ErrRoleHasMembers, got %v", err)
} }
@@ -165,9 +165,9 @@ func TestRoleRepositoryRolesAPI(t *testing.T) {
repo := persistence.NewRoleRepository(db) repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
writer := storedRole(t, db, "Writer") writer := storedRole(t, db, "Writer")
createUserRow(t, db, 1, "", &source.ID) createUserRow(t, db, 1, &source.ID)
createUserRow(t, db, 2, "", &source.ID) createUserRow(t, db, 2, &source.ID)
createUserRow(t, db, 3, "", &writer.ID) createUserRow(t, db, 3, &writer.ID)
before := userRow(t, db, 1) before := userRow(t, db, 1)
moved, err := repo.ReassignMembers(source.ID, writer.ID) moved, err := repo.ReassignMembers(source.ID, writer.ID)
if err != nil || moved != 2 { if err != nil || moved != 2 {
+1 -2
View File
@@ -3,7 +3,6 @@ package test
import ( import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"context" "context"
"errors" "errors"
@@ -878,7 +877,7 @@ func TestGetMyAccessTypedGrants(t *testing.T) {
t.Run("an unlocked role never lists an owner-only key", func(t *testing.T) { t.Run("an unlocked role never lists an owner-only key", func(t *testing.T) {
world := newRolesAPIWorld(t) world := newRolesAPIWorld(t)
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")}) hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")})
world.addUser(60, user.RoleAdmin, hand.ID, true) world.addUser(60, hand.ID, true)
access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60]) access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60])
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) { if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
t.Fatalf("unexpected permissions %v", access.Permissions) t.Fatalf("unexpected permissions %v", access.Permissions)
+7 -12
View File
@@ -73,11 +73,11 @@ func newRolesAPIWorld(t *testing.T) *rolesAPIWorld {
func (w *rolesAPIWorld) seedBuiltIns() { func (w *rolesAPIWorld) seedBuiltIns() {
w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID
w.addUser(raOwner, user.RoleOwner, ownerRoleID, true) w.addUser(raOwner, ownerRoleID, true)
w.addUser(raAdmin, user.RoleAdmin, adminRoleID, true) w.addUser(raAdmin, adminRoleID, true)
w.addUser(raLegacyAdmin, user.RoleAdmin, 0, true) w.addUser(raLegacyAdmin, adminRoleID, true)
w.addUser(raWriter, "", writerRoleID, true) w.addUser(raWriter, writerRoleID, true)
w.addUser(raUnverified, user.RoleAdmin, adminRoleID, false) w.addUser(raUnverified, adminRoleID, false)
} }
func (w *rolesAPIWorld) seedRoles() { func (w *rolesAPIWorld) seedRoles() {
@@ -85,7 +85,7 @@ func (w *rolesAPIWorld) seedRoles() {
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)}) created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
w.ids[spec.name] = created.ID w.ids[spec.name] = created.ID
for _, member := range spec.members { for _, member := range spec.members {
w.addUser(member, "", created.ID, true) w.addUser(member, created.ID, true)
} }
} }
} }
@@ -138,12 +138,7 @@ func (w *rolesAPIWorld) path(name string) string {
} }
func (w *rolesAPIWorld) caller(userID uint32) useCases.ContentCaller { func (w *rolesAPIWorld) caller(userID uint32) useCases.ContentCaller {
account := w.users.users[userID] return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[w.users.users[userID].RoleID].GrantSet()}
roleID := account.RoleID
if roleID == 0 {
roleID = w.ids[map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"}[account.Role]]
}
return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[roleID].GrantSet()}
} }
func (w *rolesAPIWorld) members(name string) []uint32 { func (w *rolesAPIWorld) members(name string) []uint32 {
+9 -6
View File
@@ -601,14 +601,17 @@ func TestContentScopeRequestLoads(t *testing.T) {
}) })
} }
t.Run("A legacy owner is resolved by one GetByName and no List call", func(t *testing.T) { t.Run("A user with role_id 0 is denied without any role load", func(t *testing.T) {
world := newContentScopeWorld(t) world := newContentScopeWorld(t)
legacy := world.users.users[csOwner] bare := world.users.users[csOwner]
legacy.RoleID = 0 bare.RoleID = 0
world.users.users[csOwner] = legacy world.users.users[csOwner] = bare
world.resetCounters() world.resetCounters()
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200)) rec := world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch)
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 { if rec.Code != http.StatusForbidden || rec.Body.String() != "missing_permission\n" {
t.Errorf("expected 403 missing_permission, got %d %q", rec.Code, rec.Body.String())
}
if world.roleRepo.touched() != 0 {
t.Errorf("unexpected role loads %+v", world.roleRepo) t.Errorf("unexpected role loads %+v", world.roleRepo)
} }
}) })
+3 -3
View File
@@ -91,11 +91,11 @@ func TestContentScopeCheckPermissionUseCase(t *testing.T) {
t.Errorf("unexpected loads %+v", counting) t.Errorf("unexpected loads %+v", counting)
} }
counting.gets = 0 counting.gets = 0
if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil { if _, err := check.Require(user.User{ID: 4, RoleID: adminRoleID}, "content.read"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if counting.gets != 0 || counting.getByNames != 1 || counting.lists != 0 { if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 {
t.Errorf("unexpected legacy loads %+v", counting) t.Errorf("unexpected loads %+v", counting)
} }
}) })
+6 -6
View File
@@ -179,15 +179,15 @@ func newContentScopeWorld(t *testing.T) *contentScopeWorld {
} }
func (w *contentScopeWorld) seedUsers() { func (w *contentScopeWorld) seedUsers() {
w.addUser(csOwner, user.RoleOwner, ownerRoleID, true) w.addUser(csOwner, ownerRoleID, true)
w.addUser(csAdmin, user.RoleAdmin, adminRoleID, true) w.addUser(csAdmin, adminRoleID, true)
w.addUser(csWriter, "", writerRoleID, true) w.addUser(csWriter, writerRoleID, true)
w.addUser(csUnverified, user.RoleAdmin, adminRoleID, false) w.addUser(csUnverified, adminRoleID, false)
w.addUser(csOrphan, "", 99, true) w.addUser(csOrphan, 99, true)
for _, spec := range contentScopeCustomRoles { for _, spec := range contentScopeCustomRoles {
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)}) created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
w.roleIDs[spec.user] = created.ID w.roleIDs[spec.user] = created.ID
w.addUser(spec.user, "", created.ID, true) w.addUser(spec.user, created.ID, true)
} }
} }
+16 -17
View File
@@ -3,10 +3,10 @@ package test
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -32,17 +32,17 @@ func (g gateOutcome) blocked() bool {
func newEnforcementEnv(t *testing.T) *apiKeyEnv { func newEnforcementEnv(t *testing.T) *apiKeyEnv {
t.Helper() t.Helper()
e := newAPIKeyEnv(t) e := newAPIKeyEnv(t)
e.addUser(enforcementOwnerID, user.RoleOwner, ownerRoleID, true) e.addUser(enforcementOwnerID, ownerRoleID, true)
e.addUser(enforcementAdminID, user.RoleAdmin, adminRoleID, true) e.addUser(enforcementAdminID, adminRoleID, true)
e.addUser(enforcementWriterID, "", writerRoleID, true) e.addUser(enforcementWriterID, writerRoleID, true)
e.addUser(enforcementUnverified, user.RoleAdmin, adminRoleID, false) e.addUser(enforcementUnverified, adminRoleID, false)
e.addUser(enforcementOrphanID, "", 99, true) e.addUser(enforcementOrphanID, 99, true)
return e return e
} }
func (e *apiKeyEnv) customRoleUser(id uint32, grants ...role.Grant) role.Role { func (e *apiKeyEnv) customRoleUser(id uint32, grants ...role.Grant) role.Role {
custom, _ := e.roles.Create(role.Role{Name: "Custom " + string(rune('a'+id)), Version: 1, Grants: grants}) custom, _ := e.roles.Create(role.Role{Name: "Custom " + string(rune('a'+id)), Version: 1, Grants: grants})
e.addUser(id, "", custom.ID, true) e.addUser(id, custom.ID, true)
return custom return custom
} }
@@ -370,26 +370,25 @@ func TestRolesPermissionsEnforcementHTTP(t *testing.T) {
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error") assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
}) })
t.Run("The legacy role string is no longer read for authorization", func(t *testing.T) { t.Run("Only role_id decides authorization, and role_id 0 is denied", func(t *testing.T) {
rows := []struct { rows := []struct {
id uint32 id uint32
legacy user.Role
roleID uint32 roleID uint32
method string method string
path string path string
key string key string
}{ }{
{14, user.RoleOwner, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"}, {14, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"},
{15, user.RoleAdmin, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"}, {15, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"},
{16, "", adminRoleID, http.MethodPost, "/v1/content-type", ""}, {16, adminRoleID, http.MethodPost, "/v1/content-type", ""},
{17, user.RoleOwner, 0, http.MethodPost, "/v1/user/invite", ""}, {17, 0, http.MethodPost, "/v1/user/invite", "users.invite"},
{18, user.RoleAdmin, 0, http.MethodPost, "/v1/content-type", ""}, {18, 0, http.MethodPost, "/v1/content-type", "schema.edit"},
{19, user.RoleAdmin, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"}, {19, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"},
} }
for _, row := range rows { for _, row := range rows {
t.Run(string(row.legacy)+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) { t.Run(strconv.Itoa(int(row.roleID))+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) {
e := newEnforcementEnv(t) e := newEnforcementEnv(t)
e.addUser(row.id, row.legacy, row.roleID, true) e.addUser(row.id, row.roleID, true)
assertGate(t, e.gateAs(t, row.id, row.method, row.path, ""), row.key) assertGate(t, e.gateAs(t, row.id, row.method, row.path, ""), row.key)
}) })
} }
+3 -5
View File
@@ -94,9 +94,7 @@ func TestCheckPermissionUseCase(t *testing.T) {
check, _ := newCheck() check, _ := newCheck()
for name, account := range map[string]user.User{ for name, account := range map[string]user.User{
"unknown role_id": {ID: 13, RoleID: 99}, "unknown role_id": {ID: 13, RoleID: 99},
"empty legacy and no id": {ID: 14, Role: ""}, "role id zero": {ID: 14},
"editor legacy and no id": {ID: 15, Role: "editor"},
"legacy owner string ignored when role_id names Writer": {ID: 16, Role: user.RoleOwner, RoleID: writerRoleID},
} { } {
_, err := check.Require(account, "users.invite") _, err := check.Require(account, "users.invite")
if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" { if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" {
@@ -107,10 +105,10 @@ func TestCheckPermissionUseCase(t *testing.T) {
t.Run("The legacy string resolves only owner and admin when there is no role_id", func(t *testing.T) { t.Run("The legacy string resolves only owner and admin when there is no role_id", func(t *testing.T) {
check, _ := newCheck() check, _ := newCheck()
if _, err := check.Require(user.User{ID: 17, Role: user.RoleOwner}, "system.apply_updates"); err != nil { if _, err := check.Require(user.User{ID: 17, RoleID: ownerRoleID}, "system.apply_updates"); err != nil {
t.Errorf("legacy owner: %v", err) t.Errorf("legacy owner: %v", err)
} }
if _, err := check.Require(user.User{ID: 18, Role: user.RoleAdmin}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) { if _, err := check.Require(user.User{ID: 18, RoleID: adminRoleID}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("legacy admin should lack owner-only keys, got %v", err) t.Errorf("legacy admin should lack owner-only keys, got %v", err)
} }
}) })
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more