diff --git a/pkg/auth/policy_evaluator.go b/pkg/auth/policy_evaluator.go new file mode 100644 index 000000000..a05423962 --- /dev/null +++ b/pkg/auth/policy_evaluator.go @@ -0,0 +1,204 @@ +package auth + +import ( + "context" + "strings" +) + +// PolicyEvaluator implements Authorizer with advanced policy evaluation. +// It supports: +// - Deny precedence (deny rules override allow rules) +// - Role inheritance +// - Attribute-based conditions +// - Resource wildcards +type PolicyEvaluator struct { + manager Manager +} + +// NewPolicyEvaluator creates a new policy evaluator. +func NewPolicyEvaluator(manager Manager) *PolicyEvaluator { + return &PolicyEvaluator{manager: manager} +} + +// Authorize checks if the user in the context can perform the action on the resource. +// Returns true if allowed, false if denied. +func (e *PolicyEvaluator) Authorize(ctx context.Context, action string, resource string) (bool, error) { + return e.AuthorizeWithAttributes(ctx, action, resource, nil) +} + +// AuthorizeWithAttributes checks authorization with additional attributes for ABAC. +func (e *PolicyEvaluator) AuthorizeWithAttributes(ctx context.Context, action string, resource string, attributes map[string]string) (bool, error) { + username := GetUser(ctx) + if username == "" { + return false, nil // No user in context = deny + } + + // Get all permissions for the user + permissions := e.getUserEffectivePermissions(username) + if len(permissions) == 0 { + return false, nil // No permissions = deny + } + + // Filter permissions that match the requested action and resource + matching := e.filterMatching(permissions, action, resource) + if len(matching) == 0 { + return false, nil // No matching permissions = deny + } + + // Evaluate conditions and apply deny precedence + return e.evaluateWithDenyPrecedence(matching, username, attributes), nil +} + +// getUserEffectivePermissions returns all permissions for a user including inherited ones. +func (e *PolicyEvaluator) getUserEffectivePermissions(username string) []Permission { + // Check if we have an extended manager with inheritance support + if em, ok := e.manager.(ExtendedManager); ok { + roles := em.GetRolesWithInheritance(username) + var allPerms []Permission + for _, role := range roles { + allPerms = append(allPerms, role.Permissions...) + } + return allPerms + } + + // Fall back to basic manager + return e.manager.GetUserPermissions(username) +} + +// filterMatching returns permissions that match the requested action and resource. +func (e *PolicyEvaluator) filterMatching(permissions []Permission, action, resource string) []Permission { + var matching []Permission + + for _, perm := range permissions { + // Check action match + if !MatchesAction(perm.Action, action) { + continue + } + + // Check resource match + if !MatchesResource(perm.Resource, resource) { + continue + } + + matching = append(matching, perm) + } + + return matching +} + +// evaluateWithDenyPrecedence evaluates permissions with deny taking precedence. +// Order: explicit deny > explicit allow > implicit deny +func (e *PolicyEvaluator) evaluateWithDenyPrecedence(permissions []Permission, username string, attributes map[string]string) bool { + var allowFound bool + + for _, perm := range permissions { + // Check conditions + if !e.evaluateConditions(perm, username, attributes) { + continue // Condition not met, skip this permission + } + + // Check effect + effect := perm.GetEffect() + if effect == EffectDeny { + return false // Explicit deny wins immediately + } + + if effect == EffectAllow { + allowFound = true + } + } + + return allowFound // Return true only if we found an allow +} + +// evaluateConditions checks if all conditions in a permission are satisfied. +func (e *PolicyEvaluator) evaluateConditions(perm Permission, username string, attributes map[string]string) bool { + if len(perm.Conditions) == 0 { + return true // No conditions = always matches + } + + for key, expectedValue := range perm.Conditions { + // Handle variable substitution + expectedValue = e.substituteVariables(expectedValue, username, attributes) + + // Get actual value from attributes + actualValue, exists := attributes[key] + if !exists { + return false // Required attribute missing + } + + if actualValue != expectedValue { + return false // Value doesn't match + } + } + + return true +} + +// substituteVariables replaces ${variable} placeholders in condition values. +func (e *PolicyEvaluator) substituteVariables(value, username string, attributes map[string]string) string { + // Replace ${user} with the current username + value = strings.ReplaceAll(value, "${user}", username) + + // Replace ${attr.key} with attribute values + for key, val := range attributes { + value = strings.ReplaceAll(value, "${attr."+key+"}", val) + } + + return value +} + +// SetAdminUser implements AdminConfigurable. +// The admin user always has full access regardless of roles. +func (e *PolicyEvaluator) SetAdminUser(username string) { + // Store admin user for bypass - not implemented in this basic version + // The FileManager handles this separately +} + +// RBACAuthorizer wraps PolicyEvaluator to implement Authorizer for the RBAC system. +type RBACAuthorizer struct { + evaluator *PolicyEvaluator + adminUser string +} + +// NewRBACAuthorizer creates a new RBAC authorizer. +func NewRBACAuthorizer(manager Manager) *RBACAuthorizer { + return &RBACAuthorizer{ + evaluator: NewPolicyEvaluator(manager), + } +} + +// Authorize checks if the user can perform the action on the resource. +func (a *RBACAuthorizer) Authorize(ctx context.Context, action string, resource string) (bool, error) { + username := GetUser(ctx) + + // Admin user bypass + if a.adminUser != "" && username == a.adminUser { + return true, nil + } + + return a.evaluator.Authorize(ctx, action, resource) +} + +// AuthorizeWithAttributes checks authorization with ABAC attributes. +func (a *RBACAuthorizer) AuthorizeWithAttributes(ctx context.Context, action string, resource string, attributes map[string]string) (bool, error) { + username := GetUser(ctx) + + // Admin user bypass + if a.adminUser != "" && username == a.adminUser { + return true, nil + } + + return a.evaluator.AuthorizeWithAttributes(ctx, action, resource, attributes) +} + +// SetAdminUser sets the admin user who has full access. +func (a *RBACAuthorizer) SetAdminUser(username string) { + a.adminUser = username +} + +// AttributeAuthorizer extends Authorizer with attribute-based authorization. +type AttributeAuthorizer interface { + Authorizer + AuthorizeWithAttributes(ctx context.Context, action string, resource string, attributes map[string]string) (bool, error) +} diff --git a/pkg/auth/policy_evaluator_test.go b/pkg/auth/policy_evaluator_test.go new file mode 100644 index 000000000..65eec4c84 --- /dev/null +++ b/pkg/auth/policy_evaluator_test.go @@ -0,0 +1,435 @@ +package auth + +import ( + "context" + "os" + "testing" +) + +func TestPolicyEvaluator(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "policy-eval-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() + + evaluator := NewPolicyEvaluator(m) + + // Setup test data + setupTestRoles(t, m) + + t.Run("Allow permission works", func(t *testing.T) { + ctx := WithUser(context.Background(), "allow-user") + m.UpdateUserRoles("allow-user", []string{"allow-role"}) + + allowed, err := evaluator.Authorize(ctx, "read", "nodes") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected access to be allowed") + } + }) + + t.Run("No matching permission denies", func(t *testing.T) { + ctx := WithUser(context.Background(), "allow-user") + + allowed, err := evaluator.Authorize(ctx, "delete", "nodes") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected access to be denied (no matching permission)") + } + }) + + t.Run("Deny takes precedence over allow", func(t *testing.T) { + ctx := WithUser(context.Background(), "deny-user") + m.UpdateUserRoles("deny-user", []string{"deny-role"}) + + // deny-role has allow on nodes:* but deny on nodes:production + allowed, err := evaluator.Authorize(ctx, "write", "nodes:test") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected write to nodes:test to be allowed") + } + + allowed, err = evaluator.Authorize(ctx, "write", "nodes:production") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected write to nodes:production to be denied") + } + }) + + t.Run("Multiple roles combined", func(t *testing.T) { + ctx := WithUser(context.Background(), "multi-user") + m.UpdateUserRoles("multi-user", []string{"allow-role", "extra-role"}) + + // allow-role grants read:nodes, extra-role grants write:alerts + allowed, err := evaluator.Authorize(ctx, "read", "nodes") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected read nodes allowed from allow-role") + } + + allowed, err = evaluator.Authorize(ctx, "write", "alerts") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected write alerts allowed from extra-role") + } + }) + + t.Run("No user in context denies", func(t *testing.T) { + ctx := context.Background() // No user + + allowed, err := evaluator.Authorize(ctx, "read", "nodes") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected access denied with no user in context") + } + }) + + t.Run("User with no roles denies", func(t *testing.T) { + ctx := WithUser(context.Background(), "no-role-user") + // Don't assign any roles + + allowed, err := evaluator.Authorize(ctx, "read", "nodes") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected access denied with no roles") + } + }) +} + +func TestPolicyEvaluatorWithAttributes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "policy-abac-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() + + evaluator := NewPolicyEvaluator(m) + + // Create role with conditions + condRole := Role{ + ID: "cond-role", + Name: "Conditional Role", + Permissions: []Permission{ + { + Action: "read", + Resource: "nodes:*", + Effect: EffectAllow, + Conditions: map[string]string{"env": "test"}, + }, + { + Action: "write", + Resource: "nodes:*", + Effect: EffectAllow, + Conditions: map[string]string{"owner": "${user}"}, + }, + }, + } + m.SaveRole(condRole) + m.UpdateUserRoles("cond-user", []string{"cond-role"}) + + t.Run("Condition matches", func(t *testing.T) { + ctx := WithUser(context.Background(), "cond-user") + + allowed, err := evaluator.AuthorizeWithAttributes(ctx, "read", "nodes:dev", map[string]string{"env": "test"}) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected access allowed when condition matches") + } + }) + + t.Run("Condition does not match", func(t *testing.T) { + ctx := WithUser(context.Background(), "cond-user") + + allowed, err := evaluator.AuthorizeWithAttributes(ctx, "read", "nodes:dev", map[string]string{"env": "prod"}) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected access denied when condition doesn't match") + } + }) + + t.Run("Variable substitution works", func(t *testing.T) { + ctx := WithUser(context.Background(), "cond-user") + + // ${user} should be replaced with "cond-user" + allowed, err := evaluator.AuthorizeWithAttributes(ctx, "write", "nodes:dev", map[string]string{"owner": "cond-user"}) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected access allowed when ${user} matches current user") + } + + // Different owner should fail + allowed, err = evaluator.AuthorizeWithAttributes(ctx, "write", "nodes:dev", map[string]string{"owner": "other-user"}) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected access denied when owner doesn't match current user") + } + }) + + t.Run("Missing attribute denies", func(t *testing.T) { + ctx := WithUser(context.Background(), "cond-user") + + // No env attribute provided + allowed, err := evaluator.AuthorizeWithAttributes(ctx, "read", "nodes:dev", nil) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected access denied when required attribute is missing") + } + }) +} + +func TestPolicyEvaluatorWithInheritance(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "policy-inherit-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() + + evaluator := NewPolicyEvaluator(m) + + // Create parent role + parentRole := Role{ + ID: "parent", + Name: "Parent", + Permissions: []Permission{{Action: "read", Resource: "base"}}, + } + m.SaveRole(parentRole) + + // Create child role + childRole := Role{ + ID: "child", + Name: "Child", + ParentID: "parent", + Permissions: []Permission{{Action: "write", Resource: "child"}}, + } + m.SaveRole(childRole) + + m.UpdateUserRoles("inherit-user", []string{"child"}) + + t.Run("Inherited permission works", func(t *testing.T) { + ctx := WithUser(context.Background(), "inherit-user") + + // Should have access via inheritance + allowed, err := evaluator.Authorize(ctx, "read", "base") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected inherited permission to grant access") + } + }) + + t.Run("Own permission works", func(t *testing.T) { + ctx := WithUser(context.Background(), "inherit-user") + + allowed, err := evaluator.Authorize(ctx, "write", "child") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected own permission to grant access") + } + }) +} + +func TestRBACAuthorizer(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "rbac-auth-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() + + authorizer := NewRBACAuthorizer(m) + + // Setup + setupTestRoles(t, m) + m.UpdateUserRoles("normal-user", []string{"allow-role"}) + + t.Run("Normal user authorization", func(t *testing.T) { + ctx := WithUser(context.Background(), "normal-user") + + allowed, err := authorizer.Authorize(ctx, "read", "nodes") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected normal user to have read access") + } + }) + + t.Run("Admin bypass", func(t *testing.T) { + authorizer.SetAdminUser("superadmin") + ctx := WithUser(context.Background(), "superadmin") + + // Admin should have access even without roles + allowed, err := authorizer.Authorize(ctx, "delete", "everything") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !allowed { + t.Error("Expected admin user to have full access") + } + }) + + t.Run("Non-admin still checks permissions", func(t *testing.T) { + ctx := WithUser(context.Background(), "normal-user") + + // normal-user doesn't have delete permission + allowed, err := authorizer.Authorize(ctx, "delete", "nodes") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if allowed { + t.Error("Expected non-admin to be denied") + } + }) +} + +func TestMatchesResource(t *testing.T) { + tests := []struct { + pattern string + resource string + expected bool + }{ + {"*", "anything", true}, + {"*", "nodes", true}, + {"nodes", "nodes", true}, + {"nodes", "alerts", false}, + {"nodes:pve1", "nodes:pve1", true}, + {"nodes:pve1", "nodes:pve2", false}, + {"nodes:*", "nodes:pve1", true}, + {"nodes:*", "nodes:pve2", true}, + {"nodes:*", "nodes", true}, + {"nodes:*", "alerts", false}, + {"settings:*", "settings:admin", true}, + {"settings:*", "settings", true}, + {"admin:*", "admin:users", true}, + } + + for _, tt := range tests { + t.Run(tt.pattern+"_"+tt.resource, func(t *testing.T) { + result := MatchesResource(tt.pattern, tt.resource) + if result != tt.expected { + t.Errorf("MatchesResource(%q, %q) = %v, expected %v", tt.pattern, tt.resource, result, tt.expected) + } + }) + } +} + +func TestMatchesAction(t *testing.T) { + tests := []struct { + permAction string + requestedAction string + expected bool + }{ + {"admin", "read", true}, + {"admin", "write", true}, + {"admin", "delete", true}, + {"admin", "admin", true}, + {"read", "read", true}, + {"read", "write", false}, + {"write", "write", true}, + {"write", "read", false}, + {"delete", "delete", true}, + {"delete", "read", false}, + } + + for _, tt := range tests { + t.Run(tt.permAction+"_"+tt.requestedAction, func(t *testing.T) { + result := MatchesAction(tt.permAction, tt.requestedAction) + if result != tt.expected { + t.Errorf("MatchesAction(%q, %q) = %v, expected %v", tt.permAction, tt.requestedAction, result, tt.expected) + } + }) + } +} + +// Helper function to setup test roles +func setupTestRoles(t *testing.T, m *SQLiteManager) { + // Allow role - basic read access + allowRole := Role{ + ID: "allow-role", + Name: "Allow Role", + Permissions: []Permission{ + {Action: "read", Resource: "nodes", Effect: EffectAllow}, + }, + } + if err := m.SaveRole(allowRole); err != nil { + t.Fatalf("Failed to create allow-role: %v", err) + } + + // Deny role - allow with specific deny + denyRole := Role{ + ID: "deny-role", + Name: "Deny Role", + Permissions: []Permission{ + {Action: "write", Resource: "nodes:*", Effect: EffectAllow}, + {Action: "write", Resource: "nodes:production", Effect: EffectDeny}, + }, + } + if err := m.SaveRole(denyRole); err != nil { + t.Fatalf("Failed to create deny-role: %v", err) + } + + // Extra role for multi-role tests + extraRole := Role{ + ID: "extra-role", + Name: "Extra Role", + Permissions: []Permission{ + {Action: "write", Resource: "alerts", Effect: EffectAllow}, + }, + } + if err := m.SaveRole(extraRole); err != nil { + t.Fatalf("Failed to create extra-role: %v", err) + } +} diff --git a/pkg/auth/sqlite_manager.go b/pkg/auth/sqlite_manager.go new file mode 100644 index 000000000..35a4b96a9 --- /dev/null +++ b/pkg/auth/sqlite_manager.go @@ -0,0 +1,954 @@ +package auth + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog/log" +) + +// SQLiteManagerConfig configures the SQLite RBAC manager. +type SQLiteManagerConfig struct { + DataDir string // Directory for rbac.db + MigrateFromFiles bool // Attempt to migrate from file-based storage + ChangeLogRetention int // Days to keep change logs (default: 90, 0 = forever) +} + +// SQLiteManager implements ExtendedManager with SQLite persistence. +type SQLiteManager struct { + mu sync.RWMutex + db *sql.DB + dbPath string + changeLogRetention int +} + +// NewSQLiteManager creates a new SQLite-backed RBAC manager. +func NewSQLiteManager(cfg SQLiteManagerConfig) (*SQLiteManager, error) { + if cfg.DataDir == "" { + return nil, fmt.Errorf("data directory is required") + } + + // Ensure directory exists + rbacDir := filepath.Join(cfg.DataDir, "rbac") + if err := os.MkdirAll(rbacDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create rbac directory: %w", err) + } + + dbPath := filepath.Join(rbacDir, "rbac.db") + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open rbac database: %w", err) + } + + // Configure SQLite for better concurrency and durability + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", + "PRAGMA cache_size=-32000", // 32MB cache + } + + for _, pragma := range pragmas { + if _, err := db.Exec(pragma); err != nil { + db.Close() + return nil, fmt.Errorf("failed to set pragma %s: %w", pragma, err) + } + } + + retention := cfg.ChangeLogRetention + if retention == 0 { + retention = 90 // Default 90 days + } + + m := &SQLiteManager{ + db: db, + dbPath: dbPath, + changeLogRetention: retention, + } + + // Initialize schema + if err := m.initSchema(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + + // Initialize built-in roles if they don't exist + if err := m.initBuiltInRoles(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to initialize built-in roles: %w", err) + } + + // Migrate from file-based storage if requested + if cfg.MigrateFromFiles { + if err := m.migrateFromFiles(cfg.DataDir); err != nil { + log.Warn().Err(err).Msg("Failed to migrate RBAC from files (may not exist)") + } + } + + return m, nil +} + +func (m *SQLiteManager) initSchema() error { + schema := ` + -- Roles table + CREATE TABLE IF NOT EXISTS rbac_roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + parent_id TEXT, + is_built_in INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (parent_id) REFERENCES rbac_roles(id) ON DELETE SET NULL + ); + + -- Permissions table (one-to-many with roles) + CREATE TABLE IF NOT EXISTS rbac_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role_id TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + effect TEXT NOT NULL DEFAULT 'allow', + conditions TEXT, + FOREIGN KEY (role_id) REFERENCES rbac_roles(id) ON DELETE CASCADE + ); + + -- User role assignments + CREATE TABLE IF NOT EXISTS rbac_user_assignments ( + username TEXT NOT NULL, + role_id TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (username, role_id), + FOREIGN KEY (role_id) REFERENCES rbac_roles(id) ON DELETE CASCADE + ); + + -- Change log + CREATE TABLE IF NOT EXISTS rbac_changelog ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + user TEXT, + timestamp INTEGER NOT NULL + ); + + -- Indexes + CREATE INDEX IF NOT EXISTS idx_rbac_perm_role ON rbac_permissions(role_id); + CREATE INDEX IF NOT EXISTS idx_rbac_assign_user ON rbac_user_assignments(username); + CREATE INDEX IF NOT EXISTS idx_rbac_changelog_time ON rbac_changelog(timestamp); + CREATE INDEX IF NOT EXISTS idx_rbac_changelog_entity ON rbac_changelog(entity_type, entity_id); + ` + + _, err := m.db.Exec(schema) + return err +} + +func (m *SQLiteManager) initBuiltInRoles() error { + now := time.Now().Unix() + + builtInRoles := []struct { + id string + name string + description string + permissions []Permission + }{ + { + id: RoleAdmin, + name: "Administrator", + description: "Full administrative access to all features", + permissions: []Permission{{Action: "admin", Resource: "*"}}, + }, + { + id: RoleOperator, + name: "Operator", + description: "Can manage nodes and perform operational tasks", + permissions: []Permission{ + {Action: "read", Resource: "*"}, + {Action: "write", Resource: "nodes"}, + {Action: "write", Resource: "vms"}, + {Action: "write", Resource: "containers"}, + {Action: "write", Resource: "alerts"}, + }, + }, + { + id: RoleViewer, + name: "Viewer", + description: "Read-only access to monitoring data", + permissions: []Permission{{Action: "read", Resource: "*"}}, + }, + { + id: RoleAuditor, + name: "Auditor", + description: "Access to audit logs and compliance data", + permissions: []Permission{ + {Action: "read", Resource: "audit_logs"}, + {Action: "read", Resource: "nodes"}, + {Action: "read", Resource: "alerts"}, + {Action: "read", Resource: "compliance"}, + }, + }, + } + + for _, r := range builtInRoles { + // Check if role already exists + var count int + err := m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE id = ?", r.id).Scan(&count) + if err != nil { + return err + } + + if count > 0 { + continue // Role already exists + } + + // Insert role + _, err = m.db.Exec(` + INSERT INTO rbac_roles (id, name, description, is_built_in, priority, created_at, updated_at) + VALUES (?, ?, ?, 1, 0, ?, ?) + `, r.id, r.name, r.description, now, now) + if err != nil { + return err + } + + // Insert permissions + for _, perm := range r.permissions { + _, err = m.db.Exec(` + INSERT INTO rbac_permissions (role_id, action, resource, effect) + VALUES (?, ?, ?, 'allow') + `, r.id, perm.Action, perm.Resource) + if err != nil { + return err + } + } + } + + return nil +} + +// Close closes the database connection. +func (m *SQLiteManager) Close() error { + return m.db.Close() +} + +// GetRoles returns all roles. +func (m *SQLiteManager) GetRoles() []Role { + m.mu.RLock() + defer m.mu.RUnlock() + + rows, err := m.db.Query(` + SELECT id, name, description, parent_id, is_built_in, priority, created_at, updated_at + FROM rbac_roles + ORDER BY name + `) + if err != nil { + log.Error().Err(err).Msg("Failed to query roles") + return nil + } + defer rows.Close() + + var roles []Role + for rows.Next() { + var role Role + var parentID sql.NullString + var createdAt, updatedAt int64 + var isBuiltIn int + + if err := rows.Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority, &createdAt, &updatedAt); err != nil { + log.Error().Err(err).Msg("Failed to scan role") + continue + } + + role.ParentID = parentID.String + role.IsBuiltIn = isBuiltIn == 1 + role.CreatedAt = time.Unix(createdAt, 0) + role.UpdatedAt = time.Unix(updatedAt, 0) + + // Load permissions + role.Permissions = m.loadRolePermissions(role.ID) + + roles = append(roles, role) + } + + return roles +} + +func (m *SQLiteManager) loadRolePermissions(roleID string) []Permission { + rows, err := m.db.Query(` + SELECT action, resource, effect, conditions + FROM rbac_permissions + WHERE role_id = ? + `, roleID) + if err != nil { + log.Error().Err(err).Str("roleId", roleID).Msg("Failed to query permissions") + return nil + } + defer rows.Close() + + var perms []Permission + for rows.Next() { + var perm Permission + var conditions sql.NullString + + if err := rows.Scan(&perm.Action, &perm.Resource, &perm.Effect, &conditions); err != nil { + log.Error().Err(err).Msg("Failed to scan permission") + continue + } + + if conditions.Valid && conditions.String != "" { + json.Unmarshal([]byte(conditions.String), &perm.Conditions) + } + + perms = append(perms, perm) + } + + return perms +} + +// GetRole returns a role by ID. +func (m *SQLiteManager) GetRole(id string) (Role, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + var role Role + var parentID sql.NullString + var createdAt, updatedAt int64 + var isBuiltIn int + + err := m.db.QueryRow(` + SELECT id, name, description, parent_id, is_built_in, priority, created_at, updated_at + FROM rbac_roles + WHERE id = ? + `, id).Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority, &createdAt, &updatedAt) + + if err == sql.ErrNoRows { + return Role{}, false + } + if err != nil { + log.Error().Err(err).Str("roleId", id).Msg("Failed to query role") + return Role{}, false + } + + role.ParentID = parentID.String + role.IsBuiltIn = isBuiltIn == 1 + role.CreatedAt = time.Unix(createdAt, 0) + role.UpdatedAt = time.Unix(updatedAt, 0) + role.Permissions = m.loadRolePermissions(role.ID) + + return role, true +} + +// SaveRole creates or updates a role. +func (m *SQLiteManager) SaveRole(role Role) error { + return m.SaveRoleWithContext(role, "") +} + +// SaveRoleWithContext creates or updates a role with audit context. +func (m *SQLiteManager) SaveRoleWithContext(role Role, username string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if it's a built-in role + var isBuiltIn int + err := m.db.QueryRow("SELECT is_built_in FROM rbac_roles WHERE id = ?", role.ID).Scan(&isBuiltIn) + if err == nil && isBuiltIn == 1 { + return fmt.Errorf("cannot modify built-in role: %s", role.ID) + } + + // Check for circular inheritance + if role.ParentID != "" { + if err := m.checkCircularInheritanceUnsafe(role.ID, role.ParentID); err != nil { + return err + } + } + + // Get old value for changelog + oldRole, exists := m.getRoleUnsafe(role.ID) + var oldValueJSON string + if exists { + if data, err := json.Marshal(oldRole); err == nil { + oldValueJSON = string(data) + } + } + + now := time.Now() + role.UpdatedAt = now + if role.CreatedAt.IsZero() { + role.CreatedAt = now + } + + tx, err := m.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Upsert role + _, err = tx.Exec(` + INSERT INTO rbac_roles (id, name, description, parent_id, is_built_in, priority, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + parent_id = excluded.parent_id, + priority = excluded.priority, + updated_at = excluded.updated_at + `, role.ID, role.Name, role.Description, nullString(role.ParentID), role.Priority, role.CreatedAt.Unix(), role.UpdatedAt.Unix()) + if err != nil { + return err + } + + // Delete existing permissions and insert new ones + _, err = tx.Exec("DELETE FROM rbac_permissions WHERE role_id = ?", role.ID) + if err != nil { + return err + } + + for _, perm := range role.Permissions { + var conditionsJSON *string + if len(perm.Conditions) > 0 { + if data, err := json.Marshal(perm.Conditions); err == nil { + s := string(data) + conditionsJSON = &s + } + } + + effect := perm.GetEffect() + _, err = tx.Exec(` + INSERT INTO rbac_permissions (role_id, action, resource, effect, conditions) + VALUES (?, ?, ?, ?, ?) + `, role.ID, perm.Action, perm.Resource, effect, conditionsJSON) + if err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + // Log change + action := ActionRoleCreated + if exists { + action = ActionRoleUpdated + } + newValueJSON, _ := json.Marshal(role) + m.logChangeUnsafe(action, "role", role.ID, oldValueJSON, string(newValueJSON), username) + + return nil +} + +func (m *SQLiteManager) getRoleUnsafe(id string) (Role, bool) { + var role Role + var parentID sql.NullString + var createdAt, updatedAt int64 + var isBuiltIn int + + err := m.db.QueryRow(` + SELECT id, name, description, parent_id, is_built_in, priority, created_at, updated_at + FROM rbac_roles + WHERE id = ? + `, id).Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority, &createdAt, &updatedAt) + + if err != nil { + return Role{}, false + } + + role.ParentID = parentID.String + role.IsBuiltIn = isBuiltIn == 1 + role.CreatedAt = time.Unix(createdAt, 0) + role.UpdatedAt = time.Unix(updatedAt, 0) + role.Permissions = m.loadRolePermissions(role.ID) + + return role, true +} + +// DeleteRole removes a role by ID. +func (m *SQLiteManager) DeleteRole(id string) error { + return m.DeleteRoleWithContext(id, "") +} + +// DeleteRoleWithContext removes a role with audit context. +func (m *SQLiteManager) DeleteRoleWithContext(id string, username string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if it's a built-in role + var isBuiltIn int + err := m.db.QueryRow("SELECT is_built_in FROM rbac_roles WHERE id = ?", id).Scan(&isBuiltIn) + if err == sql.ErrNoRows { + return nil // Already deleted + } + if err != nil { + return err + } + if isBuiltIn == 1 { + return fmt.Errorf("cannot delete built-in role: %s", id) + } + + // Get old value for changelog + oldRole, _ := m.getRoleUnsafe(id) + oldValueJSON, _ := json.Marshal(oldRole) + + // Delete role (cascades to permissions) + _, err = m.db.Exec("DELETE FROM rbac_roles WHERE id = ?", id) + if err != nil { + return err + } + + // Log change + m.logChangeUnsafe(ActionRoleDeleted, "role", id, string(oldValueJSON), "", username) + + return nil +} + +// GetUserAssignments returns all user role assignments. +func (m *SQLiteManager) GetUserAssignments() []UserRoleAssignment { + m.mu.RLock() + defer m.mu.RUnlock() + + // Get unique usernames + rows, err := m.db.Query("SELECT DISTINCT username FROM rbac_user_assignments") + if err != nil { + log.Error().Err(err).Msg("Failed to query user assignments") + return nil + } + defer rows.Close() + + var assignments []UserRoleAssignment + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + continue + } + + assignment := m.getUserAssignmentUnsafe(username) + if len(assignment.RoleIDs) > 0 { + assignments = append(assignments, assignment) + } + } + + return assignments +} + +func (m *SQLiteManager) getUserAssignmentUnsafe(username string) UserRoleAssignment { + rows, err := m.db.Query(` + SELECT role_id, updated_at + FROM rbac_user_assignments + WHERE username = ? + `, username) + if err != nil { + return UserRoleAssignment{Username: username} + } + defer rows.Close() + + var roleIDs []string + var latestUpdate int64 + for rows.Next() { + var roleID string + var updatedAt int64 + if err := rows.Scan(&roleID, &updatedAt); err != nil { + continue + } + roleIDs = append(roleIDs, roleID) + if updatedAt > latestUpdate { + latestUpdate = updatedAt + } + } + + return UserRoleAssignment{ + Username: username, + RoleIDs: roleIDs, + UpdatedAt: time.Unix(latestUpdate, 0), + } +} + +// GetUserAssignment returns the role assignment for a user. +func (m *SQLiteManager) GetUserAssignment(username string) (UserRoleAssignment, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + assignment := m.getUserAssignmentUnsafe(username) + return assignment, len(assignment.RoleIDs) > 0 +} + +// AssignRole adds a role to a user. +func (m *SQLiteManager) AssignRole(username string, roleID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Verify role exists + var count int + if err := m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE id = ?", roleID).Scan(&count); err != nil { + return err + } + if count == 0 { + return fmt.Errorf("role not found: %s", roleID) + } + + now := time.Now().Unix() + _, err := m.db.Exec(` + INSERT OR IGNORE INTO rbac_user_assignments (username, role_id, updated_at) + VALUES (?, ?, ?) + `, username, roleID, now) + + return err +} + +// UpdateUserRoles replaces all roles for a user. +func (m *SQLiteManager) UpdateUserRoles(username string, roleIDs []string) error { + return m.UpdateUserRolesWithContext(username, roleIDs, "") +} + +// UpdateUserRolesWithContext replaces all roles for a user with audit context. +func (m *SQLiteManager) UpdateUserRolesWithContext(username string, roleIDs []string, byUser string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Verify all roles exist + for _, roleID := range roleIDs { + var count int + if err := m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE id = ?", roleID).Scan(&count); err != nil { + return err + } + if count == 0 { + return fmt.Errorf("role not found: %s", roleID) + } + } + + // Get old assignment for changelog + oldAssignment := m.getUserAssignmentUnsafe(username) + oldValueJSON, _ := json.Marshal(oldAssignment) + + tx, err := m.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete existing assignments + _, err = tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", username) + if err != nil { + return err + } + + // Insert new assignments + now := time.Now().Unix() + for _, roleID := range roleIDs { + _, err = tx.Exec(` + INSERT INTO rbac_user_assignments (username, role_id, updated_at) + VALUES (?, ?, ?) + `, username, roleID, now) + if err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + // Log change + newAssignment := UserRoleAssignment{Username: username, RoleIDs: roleIDs, UpdatedAt: time.Unix(now, 0)} + newValueJSON, _ := json.Marshal(newAssignment) + m.logChangeUnsafe(ActionUserRolesUpdate, "assignment", username, string(oldValueJSON), string(newValueJSON), byUser) + + return nil +} + +// RemoveRole removes a role from a user. +func (m *SQLiteManager) RemoveRole(username string, roleID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + _, err := m.db.Exec(` + DELETE FROM rbac_user_assignments + WHERE username = ? AND role_id = ? + `, username, roleID) + + return err +} + +// GetUserPermissions returns the effective permissions for a user. +func (m *SQLiteManager) GetUserPermissions(username string) []Permission { + m.mu.RLock() + defer m.mu.RUnlock() + + assignment := m.getUserAssignmentUnsafe(username) + if len(assignment.RoleIDs) == 0 { + return nil + } + + // Collect unique permissions from all assigned roles + permMap := make(map[string]Permission) + for _, roleID := range assignment.RoleIDs { + perms := m.loadRolePermissions(roleID) + for _, perm := range perms { + key := perm.Action + ":" + perm.Resource + ":" + perm.GetEffect() + permMap[key] = perm + } + } + + var perms []Permission + for _, perm := range permMap { + perms = append(perms, perm) + } + + return perms +} + +// GetRoleWithInheritance returns a role and all inherited permissions. +func (m *SQLiteManager) GetRoleWithInheritance(id string) (Role, []Permission, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + role, exists := m.getRoleUnsafe(id) + if !exists { + return Role{}, nil, false + } + + // Collect all permissions including inherited + allPerms := m.collectInheritedPermissions(id, make(map[string]bool)) + + return role, allPerms, true +} + +func (m *SQLiteManager) collectInheritedPermissions(roleID string, visited map[string]bool) []Permission { + if visited[roleID] { + return nil // Circular reference protection + } + visited[roleID] = true + + role, exists := m.getRoleUnsafe(roleID) + if !exists { + return nil + } + + var allPerms []Permission + + // First, get parent permissions (inherited) + if role.ParentID != "" { + allPerms = m.collectInheritedPermissions(role.ParentID, visited) + } + + // Then add role's own permissions (can override parent) + allPerms = append(allPerms, role.Permissions...) + + return allPerms +} + +// checkCircularInheritanceUnsafe checks if setting parentID on roleID would create a cycle. +// Must be called with lock held. +func (m *SQLiteManager) checkCircularInheritanceUnsafe(roleID, parentID string) error { + const maxDepth = 10 + visited := make(map[string]bool) + visited[roleID] = true // The role we're updating + + // Walk up the parent chain from parentID + current := parentID + for depth := 0; depth < maxDepth && current != ""; depth++ { + if visited[current] { + return fmt.Errorf("circular inheritance detected: %s -> %s creates a cycle", roleID, parentID) + } + visited[current] = true + + // Get parent of current + var parent *string + err := m.db.QueryRow("SELECT parent_id FROM rbac_roles WHERE id = ?", current).Scan(&parent) + if err != nil { + break // Role doesn't exist or has no parent + } + if parent == nil { + break + } + current = *parent + } + + return nil +} + +// GetRolesWithInheritance returns user's roles with full inheritance chain. +func (m *SQLiteManager) GetRolesWithInheritance(username string) []Role { + m.mu.RLock() + defer m.mu.RUnlock() + + assignment := m.getUserAssignmentUnsafe(username) + if len(assignment.RoleIDs) == 0 { + return nil + } + + var roles []Role + visited := make(map[string]bool) + + for _, roleID := range assignment.RoleIDs { + m.collectRoleChain(roleID, &roles, visited) + } + + return roles +} + +func (m *SQLiteManager) collectRoleChain(roleID string, roles *[]Role, visited map[string]bool) { + if visited[roleID] { + return // Circular reference protection + } + visited[roleID] = true + + role, exists := m.getRoleUnsafe(roleID) + if !exists { + return + } + + // First collect parent + if role.ParentID != "" { + m.collectRoleChain(role.ParentID, roles, visited) + } + + *roles = append(*roles, role) +} + +// GetChangeLogs returns recent change logs. +func (m *SQLiteManager) GetChangeLogs(limit int, offset int) []RBACChangeLog { + m.mu.RLock() + defer m.mu.RUnlock() + + if limit <= 0 { + limit = 100 + } + + rows, err := m.db.Query(` + SELECT id, action, entity_type, entity_id, old_value, new_value, user, timestamp + FROM rbac_changelog + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + `, limit, offset) + if err != nil { + log.Error().Err(err).Msg("Failed to query change logs") + return nil + } + defer rows.Close() + + return m.scanChangeLogs(rows) +} + +// GetChangeLogsForEntity returns change logs for a specific entity. +func (m *SQLiteManager) GetChangeLogsForEntity(entityType, entityID string) []RBACChangeLog { + m.mu.RLock() + defer m.mu.RUnlock() + + rows, err := m.db.Query(` + SELECT id, action, entity_type, entity_id, old_value, new_value, user, timestamp + FROM rbac_changelog + WHERE entity_type = ? AND entity_id = ? + ORDER BY timestamp DESC + `, entityType, entityID) + if err != nil { + log.Error().Err(err).Msg("Failed to query change logs") + return nil + } + defer rows.Close() + + return m.scanChangeLogs(rows) +} + +func (m *SQLiteManager) scanChangeLogs(rows *sql.Rows) []RBACChangeLog { + var logs []RBACChangeLog + for rows.Next() { + var entry RBACChangeLog + var oldValue, newValue, user sql.NullString + var timestamp int64 + + if err := rows.Scan(&entry.ID, &entry.Action, &entry.EntityType, &entry.EntityID, + &oldValue, &newValue, &user, ×tamp); err != nil { + log.Error().Err(err).Msg("Failed to scan change log") + continue + } + + entry.OldValue = oldValue.String + entry.NewValue = newValue.String + entry.User = user.String + entry.Timestamp = time.Unix(timestamp, 0) + + logs = append(logs, entry) + } + + return logs +} + +func (m *SQLiteManager) logChangeUnsafe(action, entityType, entityID, oldValue, newValue, user string) { + _, err := m.db.Exec(` + INSERT INTO rbac_changelog (id, action, entity_type, entity_id, old_value, new_value, user, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, uuid.New().String(), action, entityType, entityID, nullString(oldValue), nullString(newValue), nullString(user), time.Now().Unix()) + + if err != nil { + log.Error().Err(err).Str("action", action).Msg("Failed to log RBAC change") + } +} + +// migrateFromFiles migrates data from file-based storage. +func (m *SQLiteManager) migrateFromFiles(dataDir string) error { + rolesFile := filepath.Join(dataDir, "rbac_roles.json") + assignmentsFile := filepath.Join(dataDir, "rbac_assignments.json") + + // Check if migration is needed + var roleCount int + m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE is_built_in = 0").Scan(&roleCount) + if roleCount > 0 { + return nil // Already have custom roles, skip migration + } + + // Migrate roles + if data, err := os.ReadFile(rolesFile); err == nil { + var roles []Role + if err := json.Unmarshal(data, &roles); err == nil { + for _, role := range roles { + if !role.IsBuiltIn { + if err := m.SaveRoleWithContext(role, "migration"); err != nil { + log.Warn().Err(err).Str("roleId", role.ID).Msg("Failed to migrate role") + } + } + } + log.Info().Int("count", len(roles)).Msg("Migrated roles from file") + + // Rename old file + os.Rename(rolesFile, rolesFile+".bak") + } + } + + // Migrate assignments + if data, err := os.ReadFile(assignmentsFile); err == nil { + var assignments []UserRoleAssignment + if err := json.Unmarshal(data, &assignments); err == nil { + for _, a := range assignments { + if err := m.UpdateUserRolesWithContext(a.Username, a.RoleIDs, "migration"); err != nil { + log.Warn().Err(err).Str("username", a.Username).Msg("Failed to migrate assignment") + } + } + log.Info().Int("count", len(assignments)).Msg("Migrated assignments from file") + + // Rename old file + os.Rename(assignmentsFile, assignmentsFile+".bak") + } + } + + return nil +} + +// Helper to convert empty strings to nil for nullable columns +func nullString(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/pkg/auth/sqlite_manager_test.go b/pkg/auth/sqlite_manager_test.go new file mode 100644 index 000000000..ebcb161d4 --- /dev/null +++ b/pkg/auth/sqlite_manager_test.go @@ -0,0 +1,579 @@ +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") + } + } + }) +}