package auth import ( "database/sql" "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/google/uuid" "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rs/zerolog/log" _ "modernc.org/sqlite" ) const maxLegacyRBACFileSize = 16 << 20 // 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 // migrationErr records a failed legacy import. The store stays usable so // the operator can still reach RBAC, including admin recovery. migrationErr error } // 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") } dataDir, err := securityutil.NormalizeStorageDir(cfg.DataDir) if err != nil { return nil, fmt.Errorf("normalize data directory: %w", err) } rbacDir, err := securityutil.JoinStorageLeaf(dataDir, "rbac") if err != nil { return nil, fmt.Errorf("resolve rbac directory: %w", err) } if err := securityutil.EnsureSecureStorageDir(rbacDir, 0700); err != nil { return nil, fmt.Errorf("failed to create rbac directory: %w", err) } dbPath, err := securityutil.JoinStorageLeaf(rbacDir, "rbac.db") if err != nil { return nil, fmt.Errorf("resolve rbac database path: %w", err) } // Open database with pragmas in DSN so every pool connection is configured dsn := dbPath + "?" + url.Values{ "_pragma": []string{ "busy_timeout(30000)", "journal_mode(WAL)", "synchronous(NORMAL)", "foreign_keys(ON)", "cache_size(-32000)", }, }.Encode() db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("failed to open rbac database: %w", err) } // SQLite works best with a single writer connection db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) db.SetConnMaxLifetime(0) 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. // // A failure here must not take the manager down with it. importLegacyRBAC // runs in a transaction and the legacy files are left in place, so a failed // import leaves the database un-migrated rather than half-migrated: users // are missing roles, which denies access rather than granting it. Returning // nil instead would fail GetManager for the whole org and 503 every RBAC // route, including ResetAdminRole, which is the operator's only way back. // The realistic trigger is a v5 install that recreated a role the v6 store // already has under the same ID, so this is reachable on ordinary upgrades. if cfg.MigrateFromFiles { if err := m.migrateFromFiles(dataDir); err != nil { m.migrationErr = fmt.Errorf("migrate legacy RBAC data: %w", err) log.Error(). Err(err). Str("dataDir", dataDir). Msg("Legacy RBAC import failed. The RBAC store is running without the legacy data; " + "the legacy files were left untouched. Resolve the conflict and restart to retry.") } } return m, nil } // MigrationError reports a failed legacy RBAC import, or nil when the import // succeeded or was not requested. The manager is usable either way; this exists // so callers can surface the degraded state instead of it living only in logs. func (m *SQLiteManager) MigrationError() error { m.mu.RLock() defer m.mu.RUnlock() return m.migrationErr } 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_users ( username TEXT PRIMARY KEY, display_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', provider_type TEXT NOT NULL DEFAULT '', provider_id TEXT NOT NULL DEFAULT '', last_login_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); 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 (username) REFERENCES rbac_users(username) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES rbac_roles(id) ON DELETE CASCADE ); -- Backfill the identity table for databases created before rbac_users -- existed. This preserves users when their last role is removed. INSERT OR IGNORE INTO rbac_users (username, updated_at) SELECT username, MAX(updated_at) FROM rbac_user_assignments GROUP BY username; -- 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); ` if _, err := m.db.Exec(schema); err != nil { return err } return m.ensureRBACUserIdentityColumns() } func (m *SQLiteManager) ensureRBACUserIdentityColumns() error { rows, err := m.db.Query("PRAGMA table_info(rbac_users)") if err != nil { return err } existing := make(map[string]struct{}) for rows.Next() { var cid int var name, columnType string var notNull, primaryKey int var defaultValue any if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { rows.Close() return err } existing[name] = struct{}{} } if err := rows.Err(); err != nil { rows.Close() return err } if err := rows.Close(); err != nil { return err } required := []struct { name string definition string }{ {name: "display_name", definition: "TEXT NOT NULL DEFAULT ''"}, {name: "email", definition: "TEXT NOT NULL DEFAULT ''"}, {name: "provider_type", definition: "TEXT NOT NULL DEFAULT ''"}, {name: "provider_id", definition: "TEXT NOT NULL DEFAULT ''"}, {name: "last_login_at", definition: "INTEGER NOT NULL DEFAULT 0"}, } for _, column := range required { if _, ok := existing[column.name]; ok { continue } statement := fmt.Sprintf("ALTER TABLE rbac_users ADD COLUMN %s %s", column.name, column.definition) if _, err := m.db.Exec(statement); err != nil { return fmt.Errorf("add rbac_users.%s: %w", column.name, err) } } return nil } 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 { roles, err := m.GetRolesWithError() if err != nil { log.Error().Err(err).Msg("Failed to query roles") return nil } return roles } // GetRolesWithError returns all roles and preserves storage errors. func (m *SQLiteManager) GetRolesWithError() ([]Role, error) { m.mu.RLock() defer m.mu.RUnlock() return m.getRolesUnsafe() } func (m *SQLiteManager) getRolesUnsafe() ([]Role, error) { 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 { return nil, err } // Collect roles first, then close rows before loading permissions // (avoids holding the connection during nested queries with MaxOpenConns=1) 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 { rows.Close() return nil, err } role.ParentID = parentID.String role.IsBuiltIn = isBuiltIn == 1 role.CreatedAt = time.Unix(createdAt, 0) role.UpdatedAt = time.Unix(updatedAt, 0) roles = append(roles, role) } if err := rows.Err(); err != nil { rows.Close() return nil, err } if err := rows.Close(); err != nil { return nil, err } // Load permissions after releasing the connection for i := range roles { permissions, err := m.loadRolePermissionsWithError(roles[i].ID) if err != nil { return nil, err } roles[i].Permissions = permissions } if roles == nil { roles = []Role{} } return roles, nil } func (m *SQLiteManager) loadRolePermissions(roleID string) []Permission { permissions, err := m.loadRolePermissionsWithError(roleID) if err != nil { log.Error().Err(err).Str("roleId", roleID).Msg("Failed to query permissions") return nil } return permissions } func (m *SQLiteManager) loadRolePermissionsWithError(roleID string) ([]Permission, error) { rows, err := m.db.Query(` SELECT action, resource, effect, conditions FROM rbac_permissions WHERE role_id = ? `, roleID) if err != nil { return nil, err } 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 { return nil, err } if conditions.Valid && conditions.String != "" { if err := json.Unmarshal([]byte(conditions.String), &perm.Conditions); err != nil { return nil, fmt.Errorf("parse conditions for role %s: %w", roleID, err) } } perms = append(perms, perm) } if err := rows.Err(); err != nil { return nil, err } if perms == nil { perms = []Permission{} } return perms, nil } // 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 func() { _ = 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 { assignments, err := m.GetUserAssignmentsWithError() if err != nil { log.Error().Err(err).Msg("Failed to query user assignments") return nil } return assignments } // GetUserAssignmentsWithError returns every known RBAC identity, including // identities that currently have no roles, and preserves storage errors. func (m *SQLiteManager) GetUserAssignmentsWithError() ([]UserRoleAssignment, error) { m.mu.RLock() defer m.mu.RUnlock() return m.getUserAssignmentsUnsafe() } func (m *SQLiteManager) getUserAssignmentsUnsafe() ([]UserRoleAssignment, error) { // Collect identities first, then close rows before nested queries // (avoids holding the connection during nested queries with MaxOpenConns=1) rows, err := m.db.Query("SELECT username FROM rbac_users ORDER BY username") if err != nil { return nil, err } var usernames []string for rows.Next() { var username string if err := rows.Scan(&username); err != nil { rows.Close() return nil, err } usernames = append(usernames, username) } if err := rows.Err(); err != nil { rows.Close() return nil, err } if err := rows.Close(); err != nil { return nil, err } var assignments []UserRoleAssignment for _, username := range usernames { assignment, _, err := m.getUserAssignmentWithErrorUnsafe(username) if err != nil { return nil, err } assignments = append(assignments, assignment) } if assignments == nil { assignments = []UserRoleAssignment{} } return assignments, nil } func (m *SQLiteManager) getUserAssignmentUnsafe(username string) UserRoleAssignment { assignment, _, err := m.getUserAssignmentWithErrorUnsafe(username) if err != nil { log.Error().Err(err).Str("username", username).Msg("Failed to query user assignment") return UserRoleAssignment{Username: username} } return assignment } func (m *SQLiteManager) getUserAssignmentWithErrorUnsafe(username string) (UserRoleAssignment, bool, error) { var displayName, email, providerType, providerID string var lastLoginAt, userUpdatedAt int64 err := m.db.QueryRow(` SELECT display_name, email, provider_type, provider_id, last_login_at, updated_at FROM rbac_users WHERE username = ? `, username).Scan(&displayName, &email, &providerType, &providerID, &lastLoginAt, &userUpdatedAt) if err == sql.ErrNoRows { return UserRoleAssignment{Username: username, RoleIDs: []string{}}, false, nil } if err != nil { return UserRoleAssignment{}, false, err } rows, err := m.db.Query(` SELECT role_id, updated_at FROM rbac_user_assignments WHERE username = ? ORDER BY role_id `, username) if err != nil { return UserRoleAssignment{}, false, err } defer rows.Close() roleIDs := []string{} latestUpdate := userUpdatedAt for rows.Next() { var roleID string var updatedAt int64 if err := rows.Scan(&roleID, &updatedAt); err != nil { return UserRoleAssignment{}, false, err } roleIDs = append(roleIDs, roleID) if updatedAt > latestUpdate { latestUpdate = updatedAt } } if err := rows.Err(); err != nil { return UserRoleAssignment{}, false, err } assignment := UserRoleAssignment{ Username: username, RoleIDs: roleIDs, DisplayName: displayName, Email: email, ProviderType: providerType, ProviderID: providerID, UpdatedAt: time.Unix(latestUpdate, 0), } if lastLoginAt > 0 { value := time.Unix(lastLoginAt, 0) assignment.LastLoginAt = &value } return assignment, true, nil } // GetUserAssignment returns the role assignment for a user. func (m *SQLiteManager) GetUserAssignment(username string) (UserRoleAssignment, bool) { assignment, ok, err := m.GetUserAssignmentWithError(username) if err != nil { log.Error().Err(err).Str("username", username).Msg("Failed to query user assignment") return UserRoleAssignment{}, false } return assignment, ok } // GetUserAssignmentWithError returns an assignment and preserves storage // errors. A known identity with zero roles still exists. func (m *SQLiteManager) GetUserAssignmentWithError(username string) (UserRoleAssignment, bool, error) { m.mu.RLock() defer m.mu.RUnlock() return m.getUserAssignmentWithErrorUnsafe(username) } // 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() tx, err := m.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.Exec(` INSERT INTO rbac_users (username, updated_at) VALUES (?, ?) ON CONFLICT(username) DO UPDATE SET updated_at = excluded.updated_at `, username, now); err != nil { return err } if _, err := tx.Exec(` INSERT OR IGNORE INTO rbac_user_assignments (username, role_id, updated_at) VALUES (?, ?, ?) `, username, roleID, now); err != nil { return err } return tx.Commit() } // 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 func() { _ = tx.Rollback() }() now := time.Now().Unix() if _, err = tx.Exec(` INSERT INTO rbac_users (username, updated_at) VALUES (?, ?) ON CONFLICT(username) DO UPDATE SET updated_at = excluded.updated_at `, username, now); err != nil { return err } // Delete existing assignments _, err = tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", username) if err != nil { return err } // Insert new assignments 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 := oldAssignment newAssignment.Username = username newAssignment.RoleIDs = roleIDs newAssignment.UpdatedAt = time.Unix(now, 0) newValueJSON, _ := json.Marshal(newAssignment) m.logChangeUnsafe(ActionUserRolesUpdate, "assignment", username, string(oldValueJSON), string(newValueJSON), byUser) return nil } // UpsertUserIdentity records mutable SSO presentation metadata without using // any claim as the durable authorization principal. func (m *SQLiteManager) UpsertUserIdentity(username string, metadata UserIdentityMetadata) error { m.mu.Lock() defer m.mu.Unlock() username = strings.TrimSpace(username) if username == "" { return fmt.Errorf("username is required") } metadata = NormalizeUserIdentityMetadata(metadata) if metadata.LastLoginAt.IsZero() { metadata.LastLoginAt = time.Now() } now := time.Now().Unix() _, err := m.db.Exec(` INSERT INTO rbac_users ( username, display_name, email, provider_type, provider_id, last_login_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(username) DO UPDATE SET display_name = excluded.display_name, email = excluded.email, provider_type = excluded.provider_type, provider_id = excluded.provider_id, last_login_at = excluded.last_login_at, updated_at = excluded.updated_at `, username, metadata.DisplayName, metadata.Email, metadata.ProviderType, metadata.ProviderID, metadata.LastLoginAt.Unix(), now) return err } // DeleteUser removes a known identity and all assignments attached to it. func (m *SQLiteManager) DeleteUser(username string) (bool, error) { return m.DeleteUserWithContext(username, "") } // DeleteUserWithContext removes a user and records the acting principal in the // RBAC changelog. The foreign key cascades role assignments atomically. func (m *SQLiteManager) DeleteUserWithContext(username, byUser string) (bool, error) { m.mu.Lock() defer m.mu.Unlock() username = strings.TrimSpace(username) if username == "" { return false, fmt.Errorf("username is required") } oldAssignment, exists, err := m.getUserAssignmentWithErrorUnsafe(username) if err != nil { return false, err } if !exists { return false, nil } result, err := m.db.Exec("DELETE FROM rbac_users WHERE username = ?", username) if err != nil { return false, err } deleted, err := result.RowsAffected() if err != nil { return false, err } if deleted == 0 { return false, nil } oldValueJSON, _ := json.Marshal(oldAssignment) m.logChangeUnsafe(ActionUserUnassigned, "assignment", username, string(oldValueJSON), "", byUser) return true, nil } // MigrateUserAssignment atomically moves a legacy identity alias to a // canonical principal. Conflicting canonical roles fail closed rather than // unioning grants and accidentally escalating access. func (m *SQLiteManager) MigrateUserAssignment(fromUsername, toUsername string) error { m.mu.Lock() defer m.mu.Unlock() fromUsername = strings.TrimSpace(fromUsername) toUsername = strings.TrimSpace(toUsername) if fromUsername == "" || toUsername == "" { return fmt.Errorf("source and destination usernames are required") } if fromUsername == toUsername { return nil } source, sourceExists, err := m.getUserAssignmentWithErrorUnsafe(fromUsername) if err != nil { return err } if !sourceExists { return nil } target, targetExists, err := m.getUserAssignmentWithErrorUnsafe(toUsername) if err != nil { return err } sourceRoleIDs := append([]string{}, source.RoleIDs...) targetRoleIDs := append([]string{}, target.RoleIDs...) sort.Strings(sourceRoleIDs) sort.Strings(targetRoleIDs) if targetExists && len(targetRoleIDs) > 0 && strings.Join(sourceRoleIDs, "\x00") != strings.Join(targetRoleIDs, "\x00") { return fmt.Errorf("canonical assignment for %q conflicts with legacy identity %q", toUsername, fromUsername) } tx, err := m.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() now := time.Now().Unix() sourceLastLoginAt := int64(0) if source.LastLoginAt != nil { sourceLastLoginAt = source.LastLoginAt.Unix() } if _, err := tx.Exec(` INSERT INTO rbac_users ( username, display_name, email, provider_type, provider_id, last_login_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(username) DO UPDATE SET display_name = CASE WHEN rbac_users.display_name = '' THEN excluded.display_name ELSE rbac_users.display_name END, email = CASE WHEN rbac_users.email = '' THEN excluded.email ELSE rbac_users.email END, provider_type = CASE WHEN rbac_users.provider_type = '' THEN excluded.provider_type ELSE rbac_users.provider_type END, provider_id = CASE WHEN rbac_users.provider_id = '' THEN excluded.provider_id ELSE rbac_users.provider_id END, last_login_at = MAX(rbac_users.last_login_at, excluded.last_login_at), updated_at = excluded.updated_at `, toUsername, source.DisplayName, source.Email, source.ProviderType, source.ProviderID, sourceLastLoginAt, now); err != nil { return err } if _, err := tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", toUsername); err != nil { return err } for _, roleID := range sourceRoleIDs { if _, err := tx.Exec(` INSERT INTO rbac_user_assignments (username, role_id, updated_at) VALUES (?, ?, ?) `, toUsername, roleID, now); err != nil { return err } } if _, err := tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", fromUsername); err != nil { return err } if _, err := tx.Exec("DELETE FROM rbac_users WHERE username = ?", fromUsername); err != nil { return err } if err := tx.Commit(); err != nil { return err } oldValueJSON, _ := json.Marshal(source) newAssignment := source newAssignment.Username = toUsername newAssignment.RoleIDs = sourceRoleIDs newAssignment.UpdatedAt = time.Unix(now, 0) newValueJSON, _ := json.Marshal(newAssignment) m.logChangeUnsafe( ActionUserRolesUpdate, "assignment", toUsername, string(oldValueJSON), string(newValueJSON), "identity-migration", ) 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 { permissions, err := m.GetUserPermissionsWithError(username) if err != nil { log.Error().Err(err).Str("username", username).Msg("Failed to query user permissions") return nil } return permissions } // GetUserPermissionsWithError returns effective permissions and preserves // storage failures. func (m *SQLiteManager) GetUserPermissionsWithError(username string) ([]Permission, error) { m.mu.RLock() defer m.mu.RUnlock() assignment, _, err := m.getUserAssignmentWithErrorUnsafe(username) if err != nil { return nil, err } if len(assignment.RoleIDs) == 0 { return []Permission{}, nil } // Collect unique permissions from all assigned roles permMap := make(map[string]Permission) for _, roleID := range assignment.RoleIDs { perms, err := m.loadRolePermissionsWithError(roleID) if err != nil { return nil, err } for _, perm := range perms { key := perm.Action + ":" + perm.Resource + ":" + perm.GetEffect() permMap[key] = perm } } perms := make([]Permission, 0, len(permMap)) for _, perm := range permMap { perms = append(perms, perm) } return perms, nil } // 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, err := securityutil.JoinStorageLeaf(dataDir, "rbac_roles.json") if err != nil { return fmt.Errorf("resolve legacy roles path: %w", err) } rolesBackup, err := securityutil.JoinStorageLeaf(dataDir, "rbac_roles.json.bak") if err != nil { return fmt.Errorf("resolve legacy roles backup path: %w", err) } assignmentsFile, err := securityutil.JoinStorageLeaf(dataDir, "rbac_assignments.json") if err != nil { return fmt.Errorf("resolve legacy assignments path: %w", err) } assignmentsBackup, err := securityutil.JoinStorageLeaf(dataDir, "rbac_assignments.json.bak") if err != nil { return fmt.Errorf("resolve legacy assignments backup path: %w", err) } roles, rolesExist, err := readLegacyRBACFile[Role](rolesFile, "roles") if err != nil { return err } assignments, assignmentsExist, err := readLegacyRBACFile[UserRoleAssignment](assignmentsFile, "assignments") if err != nil { return err } if !rolesExist && !assignmentsExist { return nil } if rolesExist { rolesBackup, err = availableLegacyBackupPath(rolesBackup) if err != nil { return err } } if assignmentsExist { assignmentsBackup, err = availableLegacyBackupPath(assignmentsBackup) if err != nil { return err } } if err := m.importLegacyRBAC(roles, assignments); err != nil { return err } // Source files remain untouched until the complete import transaction has // committed. A rename failure is returned so an operator can resolve it; // the next start safely verifies the imported records before retrying. if rolesExist { if err := securityutil.RenameSecureStorageFile(rolesFile, rolesBackup); err != nil { return fmt.Errorf("archive migrated legacy roles: %w", err) } } if assignmentsExist { if err := securityutil.RenameSecureStorageFile(assignmentsFile, assignmentsBackup); err != nil { return fmt.Errorf("archive migrated legacy assignments: %w", err) } } log.Info(). Int("roles", len(roles)). Int("assignments", len(assignments)). Msg("Migrated legacy RBAC data to SQLite") return nil } func readLegacyRBACFile[T any](path, kind string) ([]T, bool, error) { data, err := securityutil.ReadSecureStorageFile(path, maxLegacyRBACFileSize) if errors.Is(err, os.ErrNotExist) { return nil, false, nil } if err != nil { return nil, false, fmt.Errorf("read legacy RBAC %s: %w", kind, err) } var records []T if err := json.Unmarshal(data, &records); err != nil { return nil, true, fmt.Errorf("decode legacy RBAC %s: %w", kind, err) } if records == nil { records = []T{} } return records, true, nil } func availableLegacyBackupPath(path string) (string, error) { for index := 0; index < 1000; index++ { candidate := path if index > 0 { candidate = fmt.Sprintf("%s.%d", path, index) } _, err := os.Lstat(candidate) if errors.Is(err, os.ErrNotExist) { return candidate, nil } if err != nil { return "", fmt.Errorf("inspect legacy RBAC backup %s: %w", filepath.Base(candidate), err) } } return "", fmt.Errorf("too many legacy RBAC backups for %s", filepath.Base(path)) } func (m *SQLiteManager) importLegacyRBAC(roles []Role, assignments []UserRoleAssignment) error { tx, err := m.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() existingRoles, err := loadMigrationRoles(tx) if err != nil { return fmt.Errorf("load current roles: %w", err) } legacyRoles := make(map[string]Role, len(roles)) for _, role := range roles { if strings.TrimSpace(role.ID) == "" { return fmt.Errorf("legacy role has an empty ID") } if previous, duplicate := legacyRoles[role.ID]; duplicate { if canonicalRole(previous) != canonicalRole(role) { return fmt.Errorf("conflicting duplicate legacy role %q", role.ID) } continue } legacyRoles[role.ID] = role } parentByRole := make(map[string]string, len(existingRoles)+len(legacyRoles)) for id, role := range existingRoles { parentByRole[id] = role.ParentID } for id, role := range legacyRoles { if role.IsBuiltIn { existing, ok := existingRoles[id] if !ok || !existing.IsBuiltIn { return fmt.Errorf("legacy built-in role %q is not recognized", id) } if canonicalRole(existing) != canonicalRole(role) { return fmt.Errorf("legacy built-in role %q differs from the canonical definition", id) } continue } if existing, ok := existingRoles[id]; ok && canonicalRole(existing) != canonicalRole(role) { return fmt.Errorf("legacy role %q conflicts with current v6 data", id) } parentByRole[id] = role.ParentID } for id, parentID := range parentByRole { if parentID != "" { if _, ok := parentByRole[parentID]; !ok { return fmt.Errorf("role %q references missing parent %q", id, parentID) } } } if err := validateRoleParentGraph(parentByRole); err != nil { return err } newRoleIDs := make([]string, 0, len(legacyRoles)) for id, role := range legacyRoles { if role.IsBuiltIn { continue } if _, exists := existingRoles[id]; exists { continue } if strings.TrimSpace(role.Name) == "" { return fmt.Errorf("legacy role %q has an empty name", id) } createdAt := role.CreatedAt.Unix() updatedAt := role.UpdatedAt.Unix() now := time.Now().Unix() if role.CreatedAt.IsZero() { createdAt = now } if role.UpdatedAt.IsZero() { updatedAt = createdAt } if _, err := tx.Exec(` INSERT INTO rbac_roles (id, name, description, parent_id, is_built_in, priority, created_at, updated_at) VALUES (?, ?, ?, NULL, 0, ?, ?, ?) `, role.ID, role.Name, role.Description, role.Priority, createdAt, updatedAt); err != nil { return fmt.Errorf("insert legacy role %q: %w", role.ID, err) } for _, permission := range role.Permissions { if strings.TrimSpace(permission.Action) == "" || strings.TrimSpace(permission.Resource) == "" { return fmt.Errorf("legacy role %q has an invalid permission", role.ID) } effect := permission.GetEffect() if effect != EffectAllow && effect != EffectDeny { return fmt.Errorf("legacy role %q has invalid permission effect %q", role.ID, effect) } var conditions interface{} if len(permission.Conditions) > 0 { encoded, err := json.Marshal(permission.Conditions) if err != nil { return fmt.Errorf("encode legacy role %q conditions: %w", role.ID, err) } conditions = string(encoded) } if _, err := tx.Exec(` INSERT INTO rbac_permissions (role_id, action, resource, effect, conditions) VALUES (?, ?, ?, ?, ?) `, role.ID, permission.Action, permission.Resource, effect, conditions); err != nil { return fmt.Errorf("insert permission for legacy role %q: %w", role.ID, err) } } newRoleIDs = append(newRoleIDs, id) } for _, id := range newRoleIDs { if parentID := legacyRoles[id].ParentID; parentID != "" { if _, err := tx.Exec("UPDATE rbac_roles SET parent_id = ? WHERE id = ?", parentID, id); err != nil { return fmt.Errorf("set parent for legacy role %q: %w", id, err) } } } knownRoleIDs := make(map[string]struct{}, len(parentByRole)) for id := range parentByRole { knownRoleIDs[id] = struct{}{} } legacyAssignments := make(map[string][]string, len(assignments)) for _, assignment := range assignments { username := strings.TrimSpace(assignment.Username) if username == "" { return fmt.Errorf("legacy assignment has an empty username") } roleIDs, err := normalizedRoleIDs(assignment.RoleIDs, knownRoleIDs) if err != nil { return fmt.Errorf("legacy assignment for %q: %w", username, err) } if previous, duplicate := legacyAssignments[username]; duplicate { if strings.Join(previous, "\x00") != strings.Join(roleIDs, "\x00") { return fmt.Errorf("conflicting duplicate legacy assignment for %q", username) } continue } legacyAssignments[username] = roleIDs } usernames := make([]string, 0, len(legacyAssignments)) for username := range legacyAssignments { usernames = append(usernames, username) } sort.Strings(usernames) for _, username := range usernames { roleIDs := legacyAssignments[username] currentRoleIDs, exists, err := loadMigrationAssignment(tx, username) if err != nil { return fmt.Errorf("load current assignment for %q: %w", username, err) } if exists { if strings.Join(currentRoleIDs, "\x00") != strings.Join(roleIDs, "\x00") { return fmt.Errorf("legacy assignment for %q conflicts with current v6 data", username) } continue } now := time.Now().Unix() if _, err := tx.Exec( "INSERT INTO rbac_users (username, updated_at) VALUES (?, ?)", username, now, ); err != nil { return fmt.Errorf("insert legacy identity %q: %w", username, err) } for _, roleID := range roleIDs { if _, err := tx.Exec(` INSERT INTO rbac_user_assignments (username, role_id, updated_at) VALUES (?, ?, ?) `, username, roleID, now); err != nil { return fmt.Errorf("insert legacy assignment for %q: %w", username, err) } } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit legacy RBAC import: %w", err) } return nil } func loadMigrationRoles(tx *sql.Tx) (map[string]Role, error) { rows, err := tx.Query(` SELECT id, name, description, parent_id, is_built_in, priority FROM rbac_roles `) if err != nil { return nil, err } roles := make(map[string]Role) for rows.Next() { var role Role var parentID sql.NullString var isBuiltIn int if err := rows.Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority); err != nil { rows.Close() return nil, err } role.ParentID = parentID.String role.IsBuiltIn = isBuiltIn == 1 roles[role.ID] = role } if err := rows.Err(); err != nil { rows.Close() return nil, err } if err := rows.Close(); err != nil { return nil, err } permissionRows, err := tx.Query(` SELECT role_id, action, resource, effect, conditions FROM rbac_permissions ORDER BY role_id, id `) if err != nil { return nil, err } defer permissionRows.Close() for permissionRows.Next() { var roleID string var permission Permission var conditions sql.NullString if err := permissionRows.Scan(&roleID, &permission.Action, &permission.Resource, &permission.Effect, &conditions); err != nil { return nil, err } if conditions.Valid && conditions.String != "" { if err := json.Unmarshal([]byte(conditions.String), &permission.Conditions); err != nil { return nil, fmt.Errorf("decode conditions for role %q: %w", roleID, err) } } role, ok := roles[roleID] if !ok { return nil, fmt.Errorf("permission references missing role %q", roleID) } role.Permissions = append(role.Permissions, permission) roles[roleID] = role } if err := permissionRows.Err(); err != nil { return nil, err } return roles, nil } func loadMigrationAssignment(tx *sql.Tx, username string) ([]string, bool, error) { var count int if err := tx.QueryRow("SELECT COUNT(*) FROM rbac_users WHERE username = ?", username).Scan(&count); err != nil { return nil, false, err } if count == 0 { return nil, false, nil } rows, err := tx.Query(` SELECT role_id FROM rbac_user_assignments WHERE username = ? ORDER BY role_id `, username) if err != nil { return nil, false, err } defer rows.Close() roleIDs := []string{} for rows.Next() { var roleID string if err := rows.Scan(&roleID); err != nil { return nil, false, err } roleIDs = append(roleIDs, roleID) } if err := rows.Err(); err != nil { return nil, false, err } return roleIDs, true, nil } func normalizedRoleIDs(roleIDs []string, known map[string]struct{}) ([]string, error) { unique := make(map[string]struct{}, len(roleIDs)) for _, roleID := range roleIDs { if _, exists := known[roleID]; !exists { return nil, fmt.Errorf("references missing role %q", roleID) } unique[roleID] = struct{}{} } normalized := make([]string, 0, len(unique)) for roleID := range unique { normalized = append(normalized, roleID) } sort.Strings(normalized) return normalized, nil } func canonicalRole(role Role) string { permissions := make([]string, 0, len(role.Permissions)) for _, permission := range role.Permissions { var conditions []byte if len(permission.Conditions) > 0 { conditions, _ = json.Marshal(permission.Conditions) } permissions = append(permissions, strings.Join([]string{ permission.Action, permission.Resource, permission.GetEffect(), string(conditions), }, "\x00")) } sort.Strings(permissions) return strings.Join([]string{ role.ID, role.Name, role.Description, role.ParentID, fmt.Sprintf("%t", role.IsBuiltIn), fmt.Sprintf("%d", role.Priority), strings.Join(permissions, "\x01"), }, "\x02") } func validateRoleParentGraph(parentByRole map[string]string) error { const ( unvisited = iota visiting visited ) state := make(map[string]int, len(parentByRole)) var visit func(string) error visit = func(roleID string) error { switch state[roleID] { case visiting: return fmt.Errorf("role inheritance cycle contains %q", roleID) case visited: return nil } state[roleID] = visiting if parentID := parentByRole[roleID]; parentID != "" { if err := visit(parentID); err != nil { return err } } state[roleID] = visited return nil } for roleID := range parentByRole { if err := visit(roleID); err != nil { return err } } return nil } // Helper to convert empty strings to nil for nullable columns func nullString(s string) interface{} { if s == "" { return nil } return s }