feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.

This commit is contained in:
Florian Sylvain committed 2026-10-27 00:45:24 +01:00
1 parent a043f7f41a
commit 094df63a7f
51 files changed
+4822 -115

No files matched your search

+91 -15
View File
@@ -12,13 +12,16 @@ const (
)
type mockRoleRepo struct {
roles map[uint32]role.Role
nextID uint32
fail failSwitch
roles map[uint32]role.Role
nextID uint32
fail failSwitch
users *mockUserRepo
calls map[string]int
beforeSave func()
}
func newMockRoleRepo() *mockRoleRepo {
return &mockRoleRepo{roles: map[uint32]role.Role{}}
return &mockRoleRepo{roles: map[uint32]role.Role{}, calls: map[string]int{}}
}
func newSeededMockRoleRepo() *mockRoleRepo {
@@ -44,6 +47,7 @@ func (m *mockRoleRepo) sortedIDs() []uint32 {
}
func (m *mockRoleRepo) List() ([]role.Role, error) {
m.calls["List"]++
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
@@ -55,6 +59,7 @@ func (m *mockRoleRepo) List() ([]role.Role, error) {
}
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
m.calls["Get"]++
if err := m.fail.trigger("Get"); err != nil {
return role.Role{}, err
}
@@ -66,6 +71,7 @@ func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
}
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
m.calls["GetByName"]++
if err := m.fail.trigger("GetByName"); err != nil {
return role.Role{}, err
}
@@ -82,18 +88,8 @@ func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
return *match, nil
}
func (m *mockRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
if err := m.fail.trigger("ListGrants"); err != nil {
return nil, err
}
found, ok := m.roles[roleID]
if !ok {
return nil, role.ErrRoleNotFound
}
return slices.Clone(found.Grants), nil
}
func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
m.calls["Create"]++
if err := m.fail.trigger("Create"); err != nil {
return role.Role{}, err
}
@@ -104,9 +100,15 @@ func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
}
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
m.calls["Save"]++
if err := m.fail.trigger("Save"); err != nil {
return role.Role{}, err
}
if m.beforeSave != nil {
hook := m.beforeSave
m.beforeSave = nil
hook()
}
stored, ok := m.roles[saved.ID]
if !ok {
return role.Role{}, role.ErrRoleNotFound
@@ -118,8 +120,82 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
stored.Description = saved.Description
stored.Version++
stored.Grants = slices.Clone(saved.Grants)
stored.UpdatedAt = saved.UpdatedAt
stored.UpdatedBy = saved.UpdatedBy
m.roles[saved.ID] = stored
return stored, nil
}
func (m *mockRoleRepo) memberCount(roleID uint32) int {
if m.users == nil {
return 0
}
count := 0
for _, member := range m.users.users {
if member.RoleID == roleID {
count++
}
}
return count
}
func (m *mockRoleRepo) Delete(id uint32) error {
m.calls["Delete"]++
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.roles[id]; !ok {
return role.ErrRoleNotFound
}
if m.memberCount(id) > 0 {
return role.ErrRoleHasMembers
}
delete(m.roles, id)
return nil
}
func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
m.calls["ReassignMembers"]++
if err := m.fail.trigger("ReassignMembers"); err != nil {
return 0, err
}
if m.users == nil {
return 0, nil
}
moved := 0
for id, member := range m.users.users {
if member.RoleID == from {
member.RoleID = to
m.users.users[id] = member
moved++
}
}
return moved, nil
}
func (m *mockRoleRepo) RemoveTypedGrants(typeKey string) (int, error) {
m.calls["RemoveTypedGrants"]++
if err := m.fail.trigger("RemoveTypedGrants"); err != nil {
return 0, err
}
doomed := role.TypedKeys(typeKey)
affected := 0
for id, stored := range m.roles {
kept := []role.Grant{}
for _, grant := range stored.Grants {
if !slices.Contains(doomed, grant.Key) {
kept = append(kept, grant)
}
}
if len(kept) == len(stored.Grants) {
continue
}
stored.Grants = kept
stored.Version++
m.roles[id] = stored
affected++
}
return affected, nil
}
var _ role.Repository = &mockRoleRepo{}