mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
- Add policy evaluator for fine-grained access control - Implement SQLite-backed auth manager for user/role persistence - Support role-based permissions evaluation
580 lines
16 KiB
Go
580 lines
16 KiB
Go
package auth
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSQLiteManager(t *testing.T) {
|
|
// Create temp directory for tests
|
|
tmpDir, err := os.MkdirTemp("", "rbac-sqlite-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
m, err := NewSQLiteManager(SQLiteManagerConfig{
|
|
DataDir: tmpDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
t.Run("Built-in roles exist", func(t *testing.T) {
|
|
roles := m.GetRoles()
|
|
if len(roles) < 4 {
|
|
t.Errorf("Expected at least 4 built-in roles, got %d", len(roles))
|
|
}
|
|
|
|
// Check admin role exists
|
|
admin, ok := m.GetRole(RoleAdmin)
|
|
if !ok {
|
|
t.Error("Admin role not found")
|
|
}
|
|
if !admin.IsBuiltIn {
|
|
t.Error("Admin role should be built-in")
|
|
}
|
|
})
|
|
|
|
t.Run("Cannot delete built-in role", func(t *testing.T) {
|
|
err := m.DeleteRole(RoleAdmin)
|
|
if err == nil {
|
|
t.Error("Expected error when deleting built-in role")
|
|
}
|
|
})
|
|
|
|
t.Run("Cannot modify built-in role", func(t *testing.T) {
|
|
admin, _ := m.GetRole(RoleAdmin)
|
|
admin.Name = "Modified Admin"
|
|
err := m.SaveRole(admin)
|
|
if err == nil {
|
|
t.Error("Expected error when modifying built-in role")
|
|
}
|
|
})
|
|
|
|
t.Run("Create custom role with conditions", func(t *testing.T) {
|
|
customRole := Role{
|
|
ID: "custom-abac",
|
|
Name: "Custom ABAC Role",
|
|
Description: "A custom role with ABAC conditions",
|
|
Permissions: []Permission{
|
|
{Action: "read", Resource: "nodes", Effect: EffectAllow},
|
|
{Action: "write", Resource: "nodes:production", Effect: EffectDeny},
|
|
{
|
|
Action: "read",
|
|
Resource: "nodes:*",
|
|
Effect: EffectAllow,
|
|
Conditions: map[string]string{"tag": "test", "owner": "${user}"},
|
|
},
|
|
},
|
|
}
|
|
if err := m.SaveRole(customRole); err != nil {
|
|
t.Errorf("Failed to save custom role: %v", err)
|
|
}
|
|
|
|
retrieved, ok := m.GetRole("custom-abac")
|
|
if !ok {
|
|
t.Error("Custom role not found after save")
|
|
}
|
|
if retrieved.Name != "Custom ABAC Role" {
|
|
t.Errorf("Expected name 'Custom ABAC Role', got '%s'", retrieved.Name)
|
|
}
|
|
if len(retrieved.Permissions) != 3 {
|
|
t.Errorf("Expected 3 permissions, got %d", len(retrieved.Permissions))
|
|
}
|
|
|
|
// Check permission with conditions
|
|
var foundCondPerm bool
|
|
for _, p := range retrieved.Permissions {
|
|
if p.Conditions != nil && p.Conditions["tag"] == "test" {
|
|
foundCondPerm = true
|
|
if p.Conditions["owner"] != "${user}" {
|
|
t.Errorf("Expected owner condition '${user}', got '%s'", p.Conditions["owner"])
|
|
}
|
|
}
|
|
}
|
|
if !foundCondPerm {
|
|
t.Error("Permission with conditions not found")
|
|
}
|
|
})
|
|
|
|
t.Run("Create role with inheritance", func(t *testing.T) {
|
|
// First create parent role
|
|
parentRole := Role{
|
|
ID: "parent-role",
|
|
Name: "Parent Role",
|
|
Description: "A parent role",
|
|
Permissions: []Permission{
|
|
{Action: "read", Resource: "settings"},
|
|
},
|
|
}
|
|
if err := m.SaveRole(parentRole); err != nil {
|
|
t.Errorf("Failed to save parent role: %v", err)
|
|
}
|
|
|
|
// Create child role
|
|
childRole := Role{
|
|
ID: "child-role",
|
|
Name: "Child Role",
|
|
Description: "A child role inheriting from parent",
|
|
ParentID: "parent-role",
|
|
Permissions: []Permission{
|
|
{Action: "write", Resource: "settings"},
|
|
},
|
|
}
|
|
if err := m.SaveRole(childRole); err != nil {
|
|
t.Errorf("Failed to save child role: %v", err)
|
|
}
|
|
|
|
// Get child with inheritance
|
|
role, effectivePerms, ok := m.GetRoleWithInheritance("child-role")
|
|
if !ok {
|
|
t.Error("Child role not found")
|
|
}
|
|
if role.ParentID != "parent-role" {
|
|
t.Errorf("Expected parent ID 'parent-role', got '%s'", role.ParentID)
|
|
}
|
|
|
|
// Should have both own permissions and inherited permissions
|
|
if len(effectivePerms) < 2 {
|
|
t.Errorf("Expected at least 2 effective permissions, got %d", len(effectivePerms))
|
|
}
|
|
|
|
var hasRead, hasWrite bool
|
|
for _, p := range effectivePerms {
|
|
if p.Action == "read" && p.Resource == "settings" {
|
|
hasRead = true
|
|
}
|
|
if p.Action == "write" && p.Resource == "settings" {
|
|
hasWrite = true
|
|
}
|
|
}
|
|
if !hasRead {
|
|
t.Error("Missing inherited read permission")
|
|
}
|
|
if !hasWrite {
|
|
t.Error("Missing own write permission")
|
|
}
|
|
})
|
|
|
|
t.Run("Assign roles to user", func(t *testing.T) {
|
|
if err := m.AssignRole("testuser", RoleViewer); err != nil {
|
|
t.Errorf("Failed to assign role: %v", err)
|
|
}
|
|
if err := m.AssignRole("testuser", "child-role"); err != nil {
|
|
t.Errorf("Failed to assign child role: %v", err)
|
|
}
|
|
|
|
assignment, ok := m.GetUserAssignment("testuser")
|
|
if !ok {
|
|
t.Error("User assignment not found")
|
|
}
|
|
if len(assignment.RoleIDs) != 2 {
|
|
t.Errorf("Expected 2 roles, got %d", len(assignment.RoleIDs))
|
|
}
|
|
})
|
|
|
|
t.Run("Get roles with inheritance for user", func(t *testing.T) {
|
|
roles := m.GetRolesWithInheritance("testuser")
|
|
// Should include viewer, child-role, and parent-role (inherited)
|
|
if len(roles) < 3 {
|
|
t.Errorf("Expected at least 3 roles with inheritance, got %d", len(roles))
|
|
}
|
|
|
|
var hasParent bool
|
|
for _, r := range roles {
|
|
if r.ID == "parent-role" {
|
|
hasParent = true
|
|
}
|
|
}
|
|
if !hasParent {
|
|
t.Error("Parent role should be included via inheritance")
|
|
}
|
|
})
|
|
|
|
t.Run("Get user permissions", func(t *testing.T) {
|
|
perms := m.GetUserPermissions("testuser")
|
|
if len(perms) == 0 {
|
|
t.Error("Expected permissions for user")
|
|
}
|
|
})
|
|
|
|
t.Run("Delete custom role", func(t *testing.T) {
|
|
if err := m.DeleteRole("custom-abac"); err != nil {
|
|
t.Errorf("Failed to delete custom role: %v", err)
|
|
}
|
|
|
|
_, ok := m.GetRole("custom-abac")
|
|
if ok {
|
|
t.Error("Custom role should not exist after delete")
|
|
}
|
|
})
|
|
|
|
t.Run("Changelog is recorded", func(t *testing.T) {
|
|
logs := m.GetChangeLogs(100, 0)
|
|
if len(logs) == 0 {
|
|
t.Error("Expected changelog entries")
|
|
}
|
|
|
|
// Should have entries for role creation
|
|
var hasRoleCreated bool
|
|
for _, l := range logs {
|
|
if l.Action == ActionRoleCreated {
|
|
hasRoleCreated = true
|
|
}
|
|
}
|
|
if !hasRoleCreated {
|
|
t.Error("Missing role_created changelog entry")
|
|
}
|
|
})
|
|
|
|
t.Run("Get changelog for entity", func(t *testing.T) {
|
|
logs := m.GetChangeLogsForEntity("role", "parent-role")
|
|
if len(logs) == 0 {
|
|
t.Error("Expected changelog entries for parent-role")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSQLiteManagerMigration(t *testing.T) {
|
|
// Create temp directory for tests
|
|
tmpDir, err := os.MkdirTemp("", "rbac-migration-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// First create file-based manager with some data
|
|
fileManager, err := NewFileManager(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create FileManager: %v", err)
|
|
}
|
|
|
|
// Add custom role and assignment
|
|
customRole := Role{
|
|
ID: "migrate-test",
|
|
Name: "Migration Test Role",
|
|
Description: "Should be migrated",
|
|
Permissions: []Permission{{Action: "read", Resource: "alerts"}},
|
|
}
|
|
if err := fileManager.SaveRole(customRole); err != nil {
|
|
t.Fatalf("Failed to save role in FileManager: %v", err)
|
|
}
|
|
if err := fileManager.AssignRole("migrateuser", "migrate-test"); err != nil {
|
|
t.Fatalf("Failed to assign role in FileManager: %v", err)
|
|
}
|
|
|
|
// Now create SQLite manager with migration enabled
|
|
sqliteManager, err := NewSQLiteManager(SQLiteManagerConfig{
|
|
DataDir: tmpDir,
|
|
MigrateFromFiles: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
|
}
|
|
defer sqliteManager.Close()
|
|
|
|
// Check custom role was migrated
|
|
t.Run("Custom role migrated", func(t *testing.T) {
|
|
role, ok := sqliteManager.GetRole("migrate-test")
|
|
if !ok {
|
|
t.Error("Custom role should be migrated to SQLite")
|
|
}
|
|
if role.Name != "Migration Test Role" {
|
|
t.Errorf("Expected 'Migration Test Role', got '%s'", role.Name)
|
|
}
|
|
})
|
|
|
|
// Check assignment was migrated
|
|
t.Run("User assignment migrated", func(t *testing.T) {
|
|
assignment, ok := sqliteManager.GetUserAssignment("migrateuser")
|
|
if !ok {
|
|
t.Error("User assignment should be migrated to SQLite")
|
|
}
|
|
hasRole := false
|
|
for _, rid := range assignment.RoleIDs {
|
|
if rid == "migrate-test" {
|
|
hasRole = true
|
|
}
|
|
}
|
|
if !hasRole {
|
|
t.Error("User should have migrated role")
|
|
}
|
|
})
|
|
|
|
// Check backup files created
|
|
t.Run("Backup files created", func(t *testing.T) {
|
|
rolesBackup := filepath.Join(tmpDir, "rbac_roles.json.bak")
|
|
if _, err := os.Stat(rolesBackup); os.IsNotExist(err) {
|
|
t.Error("Roles backup file should be created")
|
|
}
|
|
assignmentsBackup := filepath.Join(tmpDir, "rbac_assignments.json.bak")
|
|
if _, err := os.Stat(assignmentsBackup); os.IsNotExist(err) {
|
|
t.Error("Assignments backup file should be created")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSQLiteManagerCircularInheritance(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "rbac-circular-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
// Create role A
|
|
roleA := Role{ID: "role-a", Name: "Role A", Permissions: []Permission{{Action: "read", Resource: "a"}}}
|
|
m.SaveRole(roleA)
|
|
|
|
// Create role B with parent A
|
|
roleB := Role{ID: "role-b", Name: "Role B", ParentID: "role-a", Permissions: []Permission{{Action: "read", Resource: "b"}}}
|
|
m.SaveRole(roleB)
|
|
|
|
// Create role C with parent B
|
|
roleC := Role{ID: "role-c", Name: "Role C", ParentID: "role-b", Permissions: []Permission{{Action: "read", Resource: "c"}}}
|
|
m.SaveRole(roleC)
|
|
|
|
// Try to make A inherit from C (creating cycle)
|
|
roleA.ParentID = "role-c"
|
|
err = m.SaveRole(roleA)
|
|
if err == nil {
|
|
t.Error("Expected error when creating circular inheritance")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteManagerContextOperations(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "rbac-context-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
t.Run("SaveRoleWithContext records user", func(t *testing.T) {
|
|
role := Role{
|
|
ID: "context-role",
|
|
Name: "Context Test Role",
|
|
Permissions: []Permission{{Action: "read", Resource: "test"}},
|
|
}
|
|
if err := m.SaveRoleWithContext(role, "admin-user"); err != nil {
|
|
t.Errorf("Failed to save role with context: %v", err)
|
|
}
|
|
|
|
// Check changelog has user
|
|
logs := m.GetChangeLogsForEntity("role", "context-role")
|
|
if len(logs) == 0 {
|
|
t.Fatal("Expected changelog entry")
|
|
}
|
|
if logs[0].User != "admin-user" {
|
|
t.Errorf("Expected user 'admin-user', got '%s'", logs[0].User)
|
|
}
|
|
})
|
|
|
|
t.Run("DeleteRoleWithContext records user", func(t *testing.T) {
|
|
if err := m.DeleteRoleWithContext("context-role", "admin-user"); err != nil {
|
|
t.Errorf("Failed to delete role with context: %v", err)
|
|
}
|
|
|
|
logs := m.GetChangeLogsForEntity("role", "context-role")
|
|
var hasDelete bool
|
|
for _, l := range logs {
|
|
if l.Action == ActionRoleDeleted && l.User == "admin-user" {
|
|
hasDelete = true
|
|
}
|
|
}
|
|
if !hasDelete {
|
|
t.Error("Missing delete changelog with user")
|
|
}
|
|
})
|
|
|
|
t.Run("UpdateUserRolesWithContext records user", func(t *testing.T) {
|
|
if err := m.UpdateUserRolesWithContext("context-user", []string{RoleViewer}, "admin-user"); err != nil {
|
|
t.Errorf("Failed to update user roles with context: %v", err)
|
|
}
|
|
|
|
logs := m.GetChangeLogsForEntity("assignment", "context-user")
|
|
if len(logs) == 0 {
|
|
t.Fatal("Expected changelog entry")
|
|
}
|
|
if logs[0].User != "admin-user" {
|
|
t.Errorf("Expected user 'admin-user', got '%s'", logs[0].User)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSQLiteManagerPersistence(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "rbac-persist-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create manager and add data
|
|
m1, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
|
}
|
|
|
|
role := Role{
|
|
ID: "persist-role",
|
|
Name: "Persist Test",
|
|
ParentID: RoleViewer,
|
|
Permissions: []Permission{{Action: "write", Resource: "persist", Effect: EffectAllow}},
|
|
}
|
|
m1.SaveRole(role)
|
|
m1.AssignRole("persist-user", "persist-role")
|
|
m1.Close()
|
|
|
|
// Reopen and verify
|
|
m2, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
|
if err != nil {
|
|
t.Fatalf("Failed to reopen SQLiteManager: %v", err)
|
|
}
|
|
defer m2.Close()
|
|
|
|
t.Run("Role persisted", func(t *testing.T) {
|
|
r, ok := m2.GetRole("persist-role")
|
|
if !ok {
|
|
t.Error("Role should persist")
|
|
}
|
|
if r.ParentID != RoleViewer {
|
|
t.Errorf("Expected parent %s, got %s", RoleViewer, r.ParentID)
|
|
}
|
|
})
|
|
|
|
t.Run("Assignment persisted", func(t *testing.T) {
|
|
a, ok := m2.GetUserAssignment("persist-user")
|
|
if !ok {
|
|
t.Error("Assignment should persist")
|
|
}
|
|
if len(a.RoleIDs) != 1 {
|
|
t.Errorf("Expected 1 role, got %d", len(a.RoleIDs))
|
|
}
|
|
})
|
|
|
|
t.Run("Changelog persisted", func(t *testing.T) {
|
|
logs := m2.GetChangeLogs(100, 0)
|
|
if len(logs) == 0 {
|
|
t.Error("Changelog should persist")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSQLiteManagerDenyPermission(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "rbac-deny-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
// Create role with both allow and deny permissions
|
|
role := Role{
|
|
ID: "deny-test",
|
|
Name: "Deny Test Role",
|
|
Permissions: []Permission{
|
|
{Action: "read", Resource: "*", Effect: EffectAllow}, // Allow all reads
|
|
{Action: "read", Resource: "secrets", Effect: EffectDeny}, // But deny reading secrets
|
|
{Action: "write", Resource: "settings", Effect: EffectAllow}, // Allow writing settings
|
|
{Action: "write", Resource: "settings:admin", Effect: EffectDeny}, // But deny admin settings
|
|
},
|
|
}
|
|
if err := m.SaveRole(role); err != nil {
|
|
t.Fatalf("Failed to save role: %v", err)
|
|
}
|
|
|
|
retrieved, ok := m.GetRole("deny-test")
|
|
if !ok {
|
|
t.Fatal("Role not found")
|
|
}
|
|
|
|
// Verify deny effects are preserved
|
|
var denyCount, allowCount int
|
|
for _, p := range retrieved.Permissions {
|
|
if p.GetEffect() == EffectDeny {
|
|
denyCount++
|
|
} else {
|
|
allowCount++
|
|
}
|
|
}
|
|
if denyCount != 2 {
|
|
t.Errorf("Expected 2 deny permissions, got %d", denyCount)
|
|
}
|
|
if allowCount != 2 {
|
|
t.Errorf("Expected 2 allow permissions, got %d", allowCount)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteManagerChangeLogRetention(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "rbac-retention-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
// Create several roles to generate changelog entries
|
|
for i := 0; i < 5; i++ {
|
|
role := Role{
|
|
ID: "retention-role-" + string(rune('a'+i)),
|
|
Name: "Retention Test " + string(rune('a'+i)),
|
|
Permissions: []Permission{{Action: "read", Resource: "test"}},
|
|
}
|
|
m.SaveRole(role)
|
|
// Add slight delay to ensure different timestamps
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
t.Run("Pagination works", func(t *testing.T) {
|
|
// Get first 2
|
|
logs1 := m.GetChangeLogs(2, 0)
|
|
if len(logs1) != 2 {
|
|
t.Errorf("Expected 2 logs, got %d", len(logs1))
|
|
}
|
|
|
|
// Get next 2
|
|
logs2 := m.GetChangeLogs(2, 2)
|
|
if len(logs2) != 2 {
|
|
t.Errorf("Expected 2 logs, got %d", len(logs2))
|
|
}
|
|
|
|
// Ensure they're different
|
|
if len(logs1) > 0 && len(logs2) > 0 && logs1[0].ID == logs2[0].ID {
|
|
t.Error("Pagination returned same entries")
|
|
}
|
|
})
|
|
|
|
t.Run("Logs ordered by timestamp desc", func(t *testing.T) {
|
|
logs := m.GetChangeLogs(100, 0)
|
|
for i := 1; i < len(logs); i++ {
|
|
if logs[i].Timestamp.After(logs[i-1].Timestamp) {
|
|
t.Error("Logs should be ordered by timestamp descending")
|
|
}
|
|
}
|
|
})
|
|
}
|