package auth import ( "database/sql" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) func TestSQLiteManagerAddsIdentityMetadataColumnsOnUpgrade(t *testing.T) { tmpDir := t.TempDir() rbacDir := filepath.Join(tmpDir, "rbac") if err := os.MkdirAll(rbacDir, 0700); err != nil { t.Fatalf("create RBAC directory: %v", err) } db, err := sql.Open("sqlite", filepath.Join(rbacDir, "rbac.db")) if err != nil { t.Fatalf("open legacy database: %v", err) } if _, err := db.Exec(`CREATE TABLE rbac_users (username TEXT PRIMARY KEY, updated_at INTEGER NOT NULL)`); err != nil { t.Fatalf("create legacy users table: %v", err) } if _, err := db.Exec(`INSERT INTO rbac_users (username, updated_at) VALUES ('legacy-user', 1)`); err != nil { t.Fatalf("seed legacy user: %v", err) } if err := db.Close(); err != nil { t.Fatalf("close legacy database: %v", err) } manager, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) if err != nil { t.Fatalf("upgrade legacy database: %v", err) } defer manager.Close() if err := manager.UpsertUserIdentity("legacy-user", UserIdentityMetadata{ DisplayName: "Alice Example", Email: "alice@example.com", ProviderType: "OIDC", ProviderID: "okta", }); err != nil { t.Fatalf("write identity metadata after upgrade: %v", err) } assignment, ok := manager.GetUserAssignment("legacy-user") if !ok || assignment.DisplayName != "Alice Example" || assignment.Email != "alice@example.com" || assignment.ProviderType != "oidc" || assignment.ProviderID != "okta" || assignment.LastLoginAt == nil { t.Fatalf("upgraded identity metadata = %#v, exists=%v", assignment, ok) } } 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 TestSQLiteManagerMigrationPreservesZeroRoleIdentities(t *testing.T) { tmpDir := t.TempDir() fileManager, err := NewFileManager(tmpDir) if err != nil { t.Fatalf("NewFileManager: %v", err) } if err := fileManager.UpdateUserRoles("local-user", nil); err != nil { t.Fatalf("create empty local assignment: %v", err) } if err := fileManager.UpdateUserRoles("sso:oidc:okta:stable", []string{RoleViewer}); err != nil { t.Fatalf("create SSO assignment: %v", err) } manager, err := NewSQLiteManager(SQLiteManagerConfig{ DataDir: tmpDir, MigrateFromFiles: true, }) if err != nil { t.Fatalf("NewSQLiteManager: %v", err) } defer manager.Close() empty, ok := manager.GetUserAssignment("local-user") if !ok { t.Fatal("zero-role local identity was lost during migration") } if len(empty.RoleIDs) != 0 { t.Fatalf("zero-role identity has roles: %v", empty.RoleIDs) } sso, ok := manager.GetUserAssignment("sso:oidc:okta:stable") if !ok || len(sso.RoleIDs) != 1 || sso.RoleIDs[0] != RoleViewer { t.Fatalf("SSO assignment not migrated: %#v, exists=%v", sso, ok) } } func TestSQLiteManagerMigrationPreservesExistingBackups(t *testing.T) { tmpDir := t.TempDir() fileManager, err := NewFileManager(tmpDir) if err != nil { t.Fatalf("NewFileManager: %v", err) } if err := fileManager.SaveRole(Role{ ID: "legacy-role", Name: "Legacy role", Permissions: []Permission{{Action: "read", Resource: "*"}}, }); err != nil { t.Fatalf("create legacy role: %v", err) } if err := fileManager.UpdateUserRoles("legacy-user", []string{RoleViewer}); err != nil { t.Fatalf("create legacy assignment: %v", err) } for _, name := range []string{"rbac_roles.json.bak", "rbac_assignments.json.bak"} { if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("existing backup"), 0600); err != nil { t.Fatalf("write %s: %v", name, err) } } manager, err := NewSQLiteManager(SQLiteManagerConfig{ DataDir: tmpDir, MigrateFromFiles: true, }) if err != nil { t.Fatalf("NewSQLiteManager: %v", err) } defer manager.Close() for _, name := range []string{"rbac_roles.json.bak", "rbac_assignments.json.bak"} { original, err := os.ReadFile(filepath.Join(tmpDir, name)) if err != nil { t.Fatalf("read original %s: %v", name, err) } if string(original) != "existing backup" { t.Fatalf("existing backup %s was replaced", name) } if _, err := os.Stat(filepath.Join(tmpDir, name+".1")); err != nil { t.Fatalf("new migration backup %s.1 missing: %v", name, err) } } } func TestSQLiteManagerMigrationRejectsCorruptAndStaleData(t *testing.T) { tests := []struct { name string roles string assignments string wantError string }{ { name: "corrupt roles", roles: `[{"id":`, assignments: `[]`, wantError: "decode legacy RBAC roles", }, { name: "corrupt assignments rolls back roles", roles: mustJSON(t, []Role{{ ID: "legacy-viewer", Name: "Legacy Viewer", Permissions: []Permission{{Action: "read", Resource: "*"}}, }}), assignments: `[{"username":`, wantError: "decode legacy RBAC assignments", }, { name: "stale assignment", roles: mustJSON(t, []Role{{ ID: "legacy-viewer", Name: "Legacy Viewer", Permissions: []Permission{{Action: "read", Resource: "*"}}, }}), assignments: mustJSON(t, []UserRoleAssignment{{ Username: "stale-user", RoleIDs: []string{"deleted-role"}, }}), wantError: "references missing role", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() rolesPath := filepath.Join(tmpDir, "rbac_roles.json") assignmentsPath := filepath.Join(tmpDir, "rbac_assignments.json") if err := os.WriteFile(rolesPath, []byte(tt.roles), 0600); err != nil { t.Fatalf("write roles: %v", err) } if err := os.WriteFile(assignmentsPath, []byte(tt.assignments), 0600); err != nil { t.Fatalf("write assignments: %v", err) } manager, err := NewSQLiteManager(SQLiteManagerConfig{ DataDir: tmpDir, MigrateFromFiles: true, }) // A rejected import must not take the store down with it: the // manager still has to serve RBAC, including admin recovery. if err != nil { t.Fatalf("rejected migration must not fail manager construction: %v", err) } if manager == nil { t.Fatal("manager is nil after a rejected migration") } defer func() { _ = manager.Close() }() migrationErr := manager.MigrationError() if migrationErr == nil || !strings.Contains(migrationErr.Error(), tt.wantError) { t.Fatalf("migration error = %v, want substring %q", migrationErr, tt.wantError) } if roles := manager.GetRoles(); len(roles) == 0 { t.Fatal("store unusable after a rejected migration: no built-in roles") } if _, err := os.Stat(rolesPath); err != nil { t.Fatalf("legacy roles source was not preserved: %v", err) } if _, err := os.Stat(assignmentsPath); err != nil { t.Fatalf("legacy assignments source was not preserved: %v", err) } if _, err := os.Stat(rolesPath + ".bak"); !os.IsNotExist(err) { t.Fatalf("roles backup must not be created on failed migration: %v", err) } reopened, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) if err != nil { t.Fatalf("reopen after failed migration: %v", err) } defer reopened.Close() if _, ok := reopened.GetRole("legacy-viewer"); ok { t.Fatal("failed migration committed a partial role") } }) } } func TestSQLiteManagerMigrationRejectsCurrentStateConflicts(t *testing.T) { tmpDir := t.TempDir() current, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) if err != nil { t.Fatalf("create current manager: %v", err) } if err := current.SaveRole(Role{ ID: "existing", Name: "Current v6 role", Permissions: []Permission{{Action: "read", Resource: "nodes"}}, }); err != nil { t.Fatalf("save current role: %v", err) } if err := current.Close(); err != nil { t.Fatalf("close current manager: %v", err) } legacyRoles := []Role{{ ID: "existing", Name: "Legacy conflicting role", Permissions: []Permission{{Action: "admin", Resource: "*"}}, }} if err := os.WriteFile(filepath.Join(tmpDir, "rbac_roles.json"), []byte(mustJSON(t, legacyRoles)), 0600); err != nil { t.Fatalf("write legacy roles: %v", err) } manager, err := NewSQLiteManager(SQLiteManagerConfig{ DataDir: tmpDir, MigrateFromFiles: true, }) if err != nil { t.Fatalf("conflicting migration must not fail manager construction: %v", err) } if manager == nil { t.Fatal("manager is nil after a conflicting migration") } migrationErr := manager.MigrationError() if migrationErr == nil || !strings.Contains(migrationErr.Error(), "conflicts with current v6 data") { t.Fatalf("migration error = %v, want current-state conflict", migrationErr) } // The conflict is the realistic v5 upgrade case, so the operator must still // be able to reach RBAC rather than facing a store-wide outage. if roles := manager.GetRoles(); len(roles) == 0 { t.Fatal("store unusable after a conflicting migration: no built-in roles") } _ = manager.Close() reopened, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) if err != nil { t.Fatalf("reopen current manager: %v", err) } defer reopened.Close() role, ok := reopened.GetRole("existing") if !ok || role.Name != "Current v6 role" { t.Fatalf("current v6 role was changed: %#v, exists=%v", role, ok) } } func TestSQLiteManagerMigrationMergesDistinctLegacyAndV6State(t *testing.T) { tmpDir := t.TempDir() current, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) if err != nil { t.Fatalf("create current manager: %v", err) } if err := current.SaveRole(Role{ ID: "v6-role", Name: "V6 role", Permissions: []Permission{{Action: "write", Resource: "nodes"}}, }); err != nil { t.Fatalf("save v6 role: %v", err) } if err := current.UpdateUserRoles("v6-user", []string{"v6-role"}); err != nil { t.Fatalf("save v6 assignment: %v", err) } if err := current.Close(); err != nil { t.Fatalf("close v6 manager: %v", err) } legacyRoles := []Role{{ ID: "legacy-role", Name: "Legacy role", Permissions: []Permission{{Action: "read", Resource: "nodes"}}, }} legacyAssignments := []UserRoleAssignment{{ Username: "legacy-user", RoleIDs: []string{"legacy-role"}, }} if err := os.WriteFile(filepath.Join(tmpDir, "rbac_roles.json"), []byte(mustJSON(t, legacyRoles)), 0600); err != nil { t.Fatalf("write legacy roles: %v", err) } if err := os.WriteFile(filepath.Join(tmpDir, "rbac_assignments.json"), []byte(mustJSON(t, legacyAssignments)), 0600); err != nil { t.Fatalf("write legacy assignments: %v", err) } manager, err := NewSQLiteManager(SQLiteManagerConfig{ DataDir: tmpDir, MigrateFromFiles: true, }) if err != nil { t.Fatalf("migrate distinct states: %v", err) } defer manager.Close() for _, roleID := range []string{"v6-role", "legacy-role"} { if _, ok := manager.GetRole(roleID); !ok { t.Fatalf("role %q missing after merge", roleID) } } for username, roleID := range map[string]string{ "v6-user": "v6-role", "legacy-user": "legacy-role", } { assignment, ok := manager.GetUserAssignment(username) if !ok || len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != roleID { t.Fatalf("assignment for %q = %#v, exists=%v", username, assignment, ok) } } } func TestSQLiteManagerKeepsIdentityWhenRolesAreEmptyOrDeleted(t *testing.T) { manager, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: t.TempDir()}) if err != nil { t.Fatalf("NewSQLiteManager: %v", err) } defer manager.Close() if err := manager.UpdateUserRoles("empty-user", nil); err != nil { t.Fatalf("UpdateUserRoles empty: %v", err) } if assignment, ok := manager.GetUserAssignment("empty-user"); !ok || len(assignment.RoleIDs) != 0 { t.Fatalf("empty identity not retained: %#v, exists=%v", assignment, ok) } role := Role{ ID: "temporary-role", Name: "Temporary", Permissions: []Permission{{Action: "read", Resource: "nodes"}}, } if err := manager.SaveRole(role); err != nil { t.Fatalf("SaveRole: %v", err) } if err := manager.AssignRole("renamed-user", role.ID); err != nil { t.Fatalf("AssignRole: %v", err) } if err := manager.DeleteRole(role.ID); err != nil { t.Fatalf("DeleteRole: %v", err) } assignment, ok := manager.GetUserAssignment("renamed-user") if !ok || len(assignment.RoleIDs) != 0 { t.Fatalf("role deletion left a stale grant or removed identity: %#v, exists=%v", assignment, ok) } if permissions := manager.GetUserPermissions("renamed-user"); len(permissions) != 0 { t.Fatalf("deleted role still grants permissions: %#v", permissions) } assignments, err := manager.GetUserAssignmentsWithError() if err != nil { t.Fatalf("GetUserAssignmentsWithError: %v", err) } if len(assignments) != 2 { t.Fatalf("known identities = %d, want 2: %#v", len(assignments), assignments) } if _, err := manager.db.Exec(` INSERT INTO rbac_user_assignments (username, role_id, updated_at) VALUES ('orphaned-user', ?, ?) `, RoleViewer, time.Now().Unix()); err == nil { t.Fatal("assignment schema accepted an identity missing from rbac_users") } } func TestSQLiteManagerIdentityMetadataSurvivesRoleUpdatesAndDeprovisions(t *testing.T) { manager, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: t.TempDir()}) if err != nil { t.Fatalf("NewSQLiteManager: %v", err) } defer manager.Close() principal := "sso:oidc:okta:stable" loginAt := time.Unix(1_700_000_000, 0) if err := manager.UpsertUserIdentity(principal, UserIdentityMetadata{ DisplayName: "Alice Example", Email: "alice@example.com", ProviderType: "OIDC", ProviderID: "okta", LastLoginAt: loginAt, }); err != nil { t.Fatalf("UpsertUserIdentity: %v", err) } if err := manager.UpdateUserRoles(principal, []string{RoleViewer}); err != nil { t.Fatalf("UpdateUserRoles: %v", err) } assignment, ok := manager.GetUserAssignment(principal) if !ok || assignment.DisplayName != "Alice Example" || assignment.Email != "alice@example.com" || assignment.ProviderType != "oidc" || assignment.ProviderID != "okta" || assignment.LastLoginAt == nil || !assignment.LastLoginAt.Equal(loginAt) { t.Fatalf("identity metadata lost during role update: %#v, exists=%v", assignment, ok) } deleted, err := manager.DeleteUserWithContext(principal, "admin") if err != nil || !deleted { t.Fatalf("DeleteUserWithContext = %v, %v", deleted, err) } if _, ok := manager.GetUserAssignment(principal); ok { t.Fatal("deprovisioned identity still exists") } logs := manager.GetChangeLogsForEntity("assignment", principal) foundDeprovision := false for _, entry := range logs { if entry.Action == ActionUserUnassigned && entry.User == "admin" { foundDeprovision = true break } } if !foundDeprovision { t.Fatalf("deprovision changelog = %#v", logs) } } func TestSQLiteManagerIdentityMigrationRejectsConflictingGrant(t *testing.T) { manager, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: t.TempDir()}) if err != nil { t.Fatalf("NewSQLiteManager: %v", err) } defer manager.Close() if err := manager.UpdateUserRoles("legacy@example.com", []string{RoleAdmin}); err != nil { t.Fatalf("seed legacy assignment: %v", err) } if err := manager.UpdateUserRoles("sso:oidc:okta:stable", []string{RoleViewer}); err != nil { t.Fatalf("seed canonical assignment: %v", err) } err = manager.MigrateUserAssignment("legacy@example.com", "sso:oidc:okta:stable") if err == nil || !strings.Contains(err.Error(), "conflicts") { t.Fatalf("error = %v, want conflict", err) } legacy, legacyExists := manager.GetUserAssignment("legacy@example.com") canonical, canonicalExists := manager.GetUserAssignment("sso:oidc:okta:stable") if !legacyExists || len(legacy.RoleIDs) != 1 || legacy.RoleIDs[0] != RoleAdmin { t.Fatalf("legacy assignment changed after conflict: %#v, exists=%v", legacy, legacyExists) } if !canonicalExists || len(canonical.RoleIDs) != 1 || canonical.RoleIDs[0] != RoleViewer { t.Fatalf("canonical assignment changed after conflict: %#v, exists=%v", canonical, canonicalExists) } } func mustJSON(t *testing.T, value interface{}) string { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatalf("marshal test data: %v", err) } return string(data) } 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"}}} if err := m.SaveRole(roleA); err != nil { t.Fatalf("SaveRole: %v", err) } // Create role B with parent A roleB := Role{ID: "role-b", Name: "Role B", ParentID: "role-a", Permissions: []Permission{{Action: "read", Resource: "b"}}} if err := m.SaveRole(roleB); err != nil { t.Fatalf("SaveRole: %v", err) } // Create role C with parent B roleC := Role{ID: "role-c", Name: "Role C", ParentID: "role-b", Permissions: []Permission{{Action: "read", Resource: "c"}}} if err := m.SaveRole(roleC); err != nil { t.Fatalf("SaveRole: %v", err) } // 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}}, } if err := m1.SaveRole(role); err != nil { t.Fatalf("SaveRole: %v", err) } if err := m1.AssignRole("persist-user", "persist-role"); err != nil { t.Fatalf("AssignRole: %v", err) } 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"}}, } if err := m.SaveRole(role); err != nil { t.Fatalf("SaveRole: %v", err) } // 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") } } }) }