This commit is contained in:
Daniel Noulet 2026-10-04 16:57:30 +02:00
parent 6319da650c
commit 3fdc8cb489
26 changed files with 2245 additions and 488 deletions

View file

@ -0,0 +1,242 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// rolesFixture creates a tenant owned by owner plus a developer member. The
// developer is the interesting actor: it administers members but does not hold
// roles.manage, so it is the boundary the escalation guards are tested against.
func rolesFixture(t *testing.T) (s *store.Store, ctx context.Context, owner, developer, tenantID string) {
t.Helper()
s, _, ctx = integrationStore(t)
owner = newUser(t, s, ctx, "owner@example.test")
tenantID = newTenant(t, s, ctx, owner, "nmbs").ID
developer = addMember(t, s, ctx, owner, tenantID, "developer@example.test", "developer")
return s, ctx, owner, developer, tenantID
}
func systemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string {
t.Helper()
id, err := s.SystemRoleID(ctx, slug)
if err != nil {
t.Fatalf("look up built-in role %q: %v", slug, err)
}
return id
}
func TestTenantCanDefineItsOwnRoleAndItIsEnforced(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenantID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
created, err := s.CreateRole(ctx, owner, tenantID, "release-manager",
"Release manager", "Prepares releases.",
[]string{string(role.ReadConsole), string(role.ManageContracts), string(role.ReadUsers)})
if err != nil {
t.Fatalf("create role: %v", err)
}
if created.System {
t.Error("a tenant role must not be marked as built in")
}
if created.TenantID == nil || *created.TenantID != tenantID {
t.Errorf("tenant id = %v, want %s", created.TenantID, tenantID)
}
if !contains(created.Permissions, string(role.ManageContracts)) {
t.Errorf("permissions = %v, want contracts.manage", created.Permissions)
}
// The role grants exactly what it says and nothing more.
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("assign the custom role: %v", err)
}
_, err = s.CreateAPIKey(ctx, member, application.ID, "key", "gl_prefix", secretHash("custom-denied"), nil)
assertPermission(t, "custom role issuing a key", err, false)
_, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil)
assertPermission(t, "custom role importing a contract", err, true)
}
func TestCustomRoleIsScopedToItsOwnTenant(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
other := newUser(t, s, ctx, "other-owner@example.test")
otherTenant := newTenant(t, s, ctx, other, "ypto").ID
created, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", []string{string(role.ReadConsole)})
if err != nil {
t.Fatalf("create role: %v", err)
}
roles, err := s.ListRoles(ctx, other, otherTenant)
if err != nil {
t.Fatalf("list roles: %v", err)
}
for _, item := range roles {
if item.ID == created.ID {
t.Fatal("a role leaked across tenants")
}
}
if err := s.DeleteRole(ctx, other, created.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant delete = %v, want ErrNotFound", err)
}
if _, err := s.UpdateRole(ctx, other, created.ID, "Hijacked", "", nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant update = %v, want ErrNotFound", err)
}
}
func TestBuiltInRolesCannotBeEditedOrDeleted(t *testing.T) {
s, ctx, owner, _, _ := rolesFixture(t)
ownerRole := systemRole(t, s, ctx, "owner")
if _, err := s.UpdateRole(ctx, owner, ownerRole, "Supreme", "", nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("editing a built-in role = %v, want ErrNotFound", err)
}
if err := s.DeleteRole(ctx, owner, ownerRole); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("deleting a built-in role = %v, want ErrNotFound", err)
}
}
func TestRoleInUseCannotBeDeleted(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
created, err := s.CreateRole(ctx, owner, tenantID, "support", "Support", "", []string{string(role.ReadConsole)})
if err != nil {
t.Fatalf("create role: %v", err)
}
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("assign role: %v", err)
}
if err := s.DeleteRole(ctx, owner, created.ID); !errors.Is(err, store.ErrRoleInUse) {
t.Fatalf("delete an assigned role = %v, want ErrRoleInUse", err)
}
// Once nobody holds it the delete goes through.
if err := s.SetUserAccess(ctx, owner, tenantID, member, systemRole(t, s, ctx, "viewer"), nil); err != nil {
t.Fatalf("move the member off the role: %v", err)
}
if err := s.DeleteRole(ctx, owner, created.ID); err != nil {
t.Fatalf("delete an unused role: %v", err)
}
}
// TestRoleAssignmentCannotEscalate is the guard that matters most now that
// developer and operator may manage members: they must not be able to hand
// somebody a role more powerful than their own, which would otherwise be a
// straightforward route to owner.
func TestRoleAssignmentCannotEscalate(t *testing.T) {
s, ctx, owner, developer, tenantID := rolesFixture(t)
victim := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer")
ownerRole := systemRole(t, s, ctx, "owner")
if err := s.SetUserAccess(ctx, developer, tenantID, victim, ownerRole, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer promoting a member to owner = %v, want ErrNotFound", err)
}
if _, err := s.InviteUser(ctx, developer, tenantID, "sneaky@example.test", "Sneaky", ownerRole, "hash"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer inviting as owner = %v, want ErrNotFound", err)
}
if _, err := s.CreateRole(ctx, developer, tenantID, "shadow-owner", "Shadow", "",
[]string{string(role.ReadConsole), string(role.ManageTenants)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating an owner-grade role = %v, want ErrNotFound", err)
}
// Nor may they define any role at all: roles.manage is not theirs to hold,
// however weak the role they were trying to mint.
if _, err := s.CreateRole(ctx, developer, tenantID, "teammate", "Teammate", "",
[]string{string(role.ReadConsole), string(role.ReadUsers)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating any role = %v, want ErrNotFound", err)
}
// The developer can still maintain members using the roles already offered.
if _, err := s.InviteUser(ctx, developer, tenantID, "teammate@example.test", "Teammate",
systemRole(t, s, ctx, "viewer"), "hash"); err != nil {
t.Fatalf("developer inviting a member: %v", err)
}
}
func TestRolesManageIsRequiredToDefineRoles(t *testing.T) {
s, ctx, _, developer, tenantID := rolesFixture(t)
if _, err := s.ListRoles(ctx, developer, tenantID); err != nil {
t.Fatalf("a developer may read the role catalog: %v", err)
}
if _, err := s.CreateRole(ctx, developer, tenantID, "nope", "Nope", "",
[]string{string(role.ReadConsole)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating a role = %v, want ErrNotFound", err)
}
}
func TestAssignableRolesExcludeAnythingAboveTheActor(t *testing.T) {
s, ctx, _, developer, tenantID := rolesFixture(t)
assignable, err := s.AssignableRoles(ctx, developer, tenantID)
if err != nil {
t.Fatalf("assignable roles: %v", err)
}
ownerRole := systemRole(t, s, ctx, "owner")
found := 0
for _, item := range assignable {
if !item.Assignable {
t.Errorf("role %q was returned but is not marked assignable", item.Slug)
}
if contains(item.Permissions, string(role.ManageTenants)) {
t.Errorf("developer may assign %q, which grants tenants.manage", item.Slug)
}
if item.ID == ownerRole {
t.Error("a developer must not be offered the owner role")
}
found++
}
if found == 0 {
t.Fatal("a developer should still be offered the roles within their authority")
}
}
func TestUnknownPermissionIsRejectedWhenDefiningARole(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
_, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "",
[]string{string(role.ReadConsole), "superpower"})
if err == nil {
t.Fatal("a role must not be creatable with an unknown permission")
}
}
func TestDuplicateRoleSlugIsRejected(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
if _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "",
[]string{string(role.ReadConsole)}); err != nil {
t.Fatalf("create role: %v", err)
}
_, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Another", "",
[]string{string(role.ReadConsole)})
if !errors.Is(err, store.ErrRoleSlugTaken) {
t.Fatalf("duplicate slug = %v, want ErrRoleSlugTaken", err)
}
}
// TestTenantKeepsAnOwner guards the demotion path: the last owner of a tenant
// must not be able to walk away from it and lock every administrator out.
func TestTenantKeepsAnOwner(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
admin := addMember(t, s, ctx, owner, tenantID, "admin@example.test", "admin")
adminRole := systemRole(t, s, ctx, "admin")
ownerRole := systemRole(t, s, ctx, "owner")
// The owner cannot simply step down while nobody else holds the role.
if err := s.SetUserAccess(ctx, owner, tenantID, owner, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
t.Fatalf("owner stepping down alone = %v, want ErrLastOwner", err)
}
// Handing the role over first makes the step down legal.
if err := s.SetUserAccess(ctx, owner, tenantID, admin, ownerRole, nil); err != nil {
t.Fatalf("promote the admin to owner: %v", err)
}
if err := s.SetUserAccess(ctx, admin, tenantID, owner, adminRole, nil); err != nil {
t.Fatalf("step down once a second owner exists: %v", err)
}
// The admin is now the only owner and must not be able to walk away from it.
if err := s.SetUserAccess(ctx, admin, tenantID, admin, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
t.Fatalf("demoting the last owner = %v, want ErrLastOwner", err)
}
}