feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.
This commit is contained in:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
+91
-15
@@ -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{}
|
||||
Reference in new issue
Block a user