267 lines
7.9 KiB
Go
267 lines
7.9 KiB
Go
package access
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// spec is a small helper to build a GroupSpec.
|
|
func spec(name, parent string, members map[string]Role) GroupSpec {
|
|
return GroupSpec{Name: name, Parent: parent, Members: members}
|
|
}
|
|
|
|
func TestUnconfiguredIsOpen(t *testing.T) {
|
|
// No roles assigned anywhere → fully open (everyone is admin), matching a
|
|
// fresh/dev deployment.
|
|
p := New("", nil)
|
|
for _, u := range []string{"", "alice", "carol"} {
|
|
if !p.CanAdmin(u) {
|
|
t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u)
|
|
}
|
|
if p.Level(u) != LevelWrite {
|
|
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoleLadder(t *testing.T) {
|
|
p := New("", []GroupSpec{
|
|
spec("public", "", map[string]Role{"adm": RoleAdmin}),
|
|
spec("ops", "", map[string]Role{
|
|
"viewer": RoleViewer,
|
|
"op": RoleOperator,
|
|
"logic": RoleLogic,
|
|
"aud": RoleAuditor,
|
|
}),
|
|
})
|
|
|
|
// Configured now → unlisted users (and anonymous) are viewers (read-only).
|
|
if p.Level("") != LevelRead {
|
|
t.Errorf("anonymous Level = %v, want read", p.Level(""))
|
|
}
|
|
if p.Level("nobody") != LevelRead {
|
|
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
|
|
}
|
|
|
|
cases := []struct {
|
|
user string
|
|
level Level
|
|
editLogic bool
|
|
audit bool
|
|
admin bool
|
|
}{
|
|
{"viewer", LevelRead, false, false, false},
|
|
{"op", LevelWrite, false, false, false},
|
|
{"logic", LevelWrite, true, false, false},
|
|
{"aud", LevelWrite, true, true, false},
|
|
{"adm", LevelWrite, true, true, true},
|
|
}
|
|
for _, c := range cases {
|
|
if p.Level(c.user) != c.level {
|
|
t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level)
|
|
}
|
|
if p.CanEditLogic(c.user) != c.editLogic {
|
|
t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic)
|
|
}
|
|
if p.CanViewAudit(c.user) != c.audit {
|
|
t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit)
|
|
}
|
|
if p.CanAdmin(c.user) != c.admin {
|
|
t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) {
|
|
p := New("", []GroupSpec{
|
|
spec("public", "", map[string]Role{"x": RoleViewer}),
|
|
spec("a", "", map[string]Role{"x": RoleOperator}),
|
|
spec("b", "", map[string]Role{"x": RoleAuditor}),
|
|
})
|
|
if got := p.EffectiveRole("x"); got != RoleAuditor {
|
|
t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got)
|
|
}
|
|
}
|
|
|
|
func TestNestingInheritsMembership(t *testing.T) {
|
|
// org → team-a → squad-1. A member of org is effectively in the descendants
|
|
// too (membership flows parent→child), surfaced via GroupsOf for panel ACLs.
|
|
p := New("", []GroupSpec{
|
|
spec("org", "", map[string]Role{"boss": RoleAdmin}),
|
|
spec("team-a", "org", nil),
|
|
spec("squad-1", "team-a", nil),
|
|
})
|
|
groups := p.GroupsOf("boss")
|
|
for _, want := range []string{"org", "team-a", "squad-1"} {
|
|
if !containsStr(groups, want) {
|
|
t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMutationsRoundTrip(t *testing.T) {
|
|
p := New("", []GroupSpec{
|
|
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
|
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
|
})
|
|
|
|
// Set a user's full membership set: dave operator in ops, admin in eng (new).
|
|
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") {
|
|
t.Errorf("GroupsOf(dave) = %v, want ops+eng", g)
|
|
}
|
|
if !p.CanAdmin("dave") { // admin in eng
|
|
t.Error("CanAdmin(dave) = false after admin role in eng")
|
|
}
|
|
|
|
// Replacing membership removes dave from ops.
|
|
if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if containsStr(p.GroupsOf("dave"), "ops") {
|
|
t.Error("dave still in ops after membership replaced")
|
|
}
|
|
|
|
// Rename carries dave's admin grant from eng → engineering.
|
|
if err := p.RenameGroup("eng", "engineering"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !p.CanAdmin("dave") {
|
|
t.Error("CanAdmin(dave) = false after eng renamed to engineering")
|
|
}
|
|
if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") {
|
|
t.Errorf("GroupNames after rename = %v", p.GroupNames())
|
|
}
|
|
|
|
// Delete drops the group; dave loses admin but root (in public) keeps it.
|
|
if err := p.DeleteGroup("engineering"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.CanAdmin("dave") {
|
|
t.Error("CanAdmin(dave) = true after engineering deleted")
|
|
}
|
|
if !p.CanAdmin("root") {
|
|
t.Error("CanAdmin(root) = false; root grant in public should survive")
|
|
}
|
|
if err := p.DeleteGroup("nope"); err != ErrNotFound {
|
|
t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestPublicGroupProtected(t *testing.T) {
|
|
p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})})
|
|
if err := p.DeleteGroup(PublicGroup); err == nil {
|
|
t.Error("DeleteGroup(public) = nil, want error")
|
|
}
|
|
if err := p.RenameGroup(PublicGroup, "other"); err == nil {
|
|
t.Error("RenameGroup(public) = nil, want error")
|
|
}
|
|
if !containsStr(p.GroupNames(), PublicGroup) {
|
|
t.Error("public group missing after protected mutations")
|
|
}
|
|
}
|
|
|
|
func TestParentCycleRejected(t *testing.T) {
|
|
p := New("", []GroupSpec{
|
|
spec("a", "", map[string]Role{"x": RoleViewer}),
|
|
spec("b", "a", nil),
|
|
})
|
|
// Making a's parent b would create a cycle a→b→a; it must be dropped to root.
|
|
if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap := p.Snapshot()
|
|
for _, g := range snap.Groups {
|
|
if g.Name == "a" && g.Parent != "" {
|
|
t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPersistenceRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
p := New("admin", []GroupSpec{
|
|
spec("public", "", map[string]Role{"alice": RoleLogic}),
|
|
spec("ops", "", map[string]Role{"carol": RoleAdmin}),
|
|
})
|
|
if err := p.EnablePersistence(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A mutation triggers the first write of access.json.
|
|
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil {
|
|
t.Fatalf("access.json not written: %v", err)
|
|
}
|
|
|
|
// A fresh policy loading the same dir should see the persisted state, not the
|
|
// (different) seed it was constructed with.
|
|
p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})})
|
|
if err := p2.EnablePersistence(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p2.ResolveUser("") != "admin" {
|
|
t.Errorf("default user = %q, want admin", p2.ResolveUser(""))
|
|
}
|
|
if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") {
|
|
t.Error("logic-editor role not persisted")
|
|
}
|
|
if !p2.CanAdmin("carol") {
|
|
t.Error("admin role not persisted")
|
|
}
|
|
if !containsStr(p2.GroupsOf("dave"), "ops") {
|
|
t.Error("dave membership not persisted")
|
|
}
|
|
if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead {
|
|
// seeduser came from the discarded seed; should be an unlisted viewer now.
|
|
t.Error("persisted state did not supersede the seed")
|
|
}
|
|
}
|
|
|
|
func TestConcurrentAccess(t *testing.T) {
|
|
p := New("", []GroupSpec{
|
|
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
|
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
|
})
|
|
if err := p.EnablePersistence(t.TempDir()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 100; j++ {
|
|
p.CanAdmin("carol")
|
|
p.Level("carol")
|
|
p.GroupsOf("carol")
|
|
p.Snapshot()
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 100; j++ {
|
|
p.SetMemberRole("ops", "carol", RoleAuditor)
|
|
p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin})
|
|
p.RemoveMember("ops", "carol")
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func containsStr(xs []string, v string) bool {
|
|
for _, x := range xs {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|