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

+11 -11
View File
@@ -583,8 +583,8 @@ func TestContentScopeRequestLoads(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 1 user Get, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
t.Errorf("expected at most 1 user Get, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
}
})
t.Run("An API key request loads the user at most twice: "+route[0]+" "+route[1], func(t *testing.T) {
@@ -595,30 +595,30 @@ func TestContentScopeRequestLoads(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
}
})
}
t.Run("A legacy owner is resolved by one GetByName and no ListGrants", func(t *testing.T) {
t.Run("A legacy owner is resolved by one GetByName and no List call", func(t *testing.T) {
world := newContentScopeWorld(t)
legacy := world.users.users[csOwner]
legacy.RoleID = 0
world.users.users[csOwner] = legacy
world.resetCounters()
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.listGrantsN != 0 {
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 {
t.Errorf("unexpected role loads %+v", world.roleRepo)
}
})
t.Run("GET /auth/me does not call ListGrants", func(t *testing.T) {
t.Run("GET /auth/me does not call List", func(t *testing.T) {
world := newContentScopeWorld(t)
world.resetCounters()
rec, _ := world.me(t, csWriter)
if rec.Code != http.StatusOK || world.roleRepo.listGrantsN != 0 {
t.Errorf("unexpected %d and %d ListGrants calls", rec.Code, world.roleRepo.listGrantsN)
if rec.Code != http.StatusOK || world.roleRepo.lists != 0 {
t.Errorf("unexpected %d and %d List calls", rec.Code, world.roleRepo.lists)
}
})
}
@@ -654,8 +654,8 @@ func TestContentScopeOwnerOnlyAndMe(t *testing.T) {
for _, grant := range me.Access.Permissions {
got[grant.Key] = string(grant.Scope)
}
if got["content.update"] != "own" || len(got) != 1 {
t.Errorf("expected only content.update at own, got %v", got)
if got["content.update"] != "own" || got["content.update:blog"] != "all" || len(got) != 2 {
t.Errorf("expected content.update at own and the typed entry (superseded by roles_permissions_roles_api), got %v", got)
}
})
}