diff --git a/backend/internal/repository/rule_runs.go b/backend/internal/repository/rule_runs.go new file mode 100644 index 0000000..1c27ef2 --- /dev/null +++ b/backend/internal/repository/rule_runs.go @@ -0,0 +1,194 @@ +// Package repository - Rule runs repository +package repository + +import ( + "database/sql" + "time" + + "github.com/Grace-Solutions/OrchestrAD/internal/models" + "github.com/google/uuid" +) + +// RuleRunRepository handles rule run persistence +type RuleRunRepository struct { + db *sql.DB +} + +// NewRuleRunRepository creates a new RuleRunRepository +func NewRuleRunRepository(db *sql.DB) *RuleRunRepository { + return &RuleRunRepository{db: db} +} + +// Create inserts a new rule run record +func (r *RuleRunRepository) Create(run *models.RuleRun) error { + if run.ID == "" { + run.ID = uuid.New().String() + } + now := time.Now().UTC() + if run.CreatedUTC.IsZero() { + run.CreatedUTC = now + } + if run.StartedUTC.IsZero() { + run.StartedUTC = now + } + + var completed *string + if run.CompletedUTC != nil { + s := formatTime(*run.CompletedUTC) + completed = &s + } + + _, err := r.db.Exec(` + INSERT INTO rule_runs ( + id, rule_id, status, started_utc, completed_utc, duration_ms, + objects_matched, objects_processed, actions_executed, actions_failed, + error_message, execution_mode, triggered_by, created_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + run.ID, run.RuleID, run.Status, formatTime(run.StartedUTC), completed, run.DurationMS, + run.ObjectsMatched, run.ObjectsProcessed, run.ActionsExecuted, run.ActionsFailed, + run.ErrorMessage, run.ExecutionMode, run.TriggeredBy, formatTime(run.CreatedUTC), + ) + return err +} + +// Update updates an existing rule run +func (r *RuleRunRepository) Update(run *models.RuleRun) error { + var completed *string + if run.CompletedUTC != nil { + s := formatTime(*run.CompletedUTC) + completed = &s + } + + _, err := r.db.Exec(` + UPDATE rule_runs SET + status = ?, completed_utc = ?, duration_ms = ?, + objects_matched = ?, objects_processed = ?, + actions_executed = ?, actions_failed = ?, + error_message = ? + WHERE id = ? + `, + run.Status, completed, run.DurationMS, + run.ObjectsMatched, run.ObjectsProcessed, + run.ActionsExecuted, run.ActionsFailed, + run.ErrorMessage, run.ID, + ) + return err +} + +// GetByID retrieves a rule run by ID +func (r *RuleRunRepository) GetByID(id string) (*models.RuleRun, error) { + run := &models.RuleRun{} + var completed sql.NullString + + err := r.db.QueryRow(` + SELECT id, rule_id, status, started_utc, completed_utc, duration_ms, + objects_matched, objects_processed, actions_executed, actions_failed, + error_message, execution_mode, triggered_by, created_utc + FROM rule_runs WHERE id = ? + `, id).Scan( + &run.ID, &run.RuleID, &run.Status, &run.StartedUTC, &completed, &run.DurationMS, + &run.ObjectsMatched, &run.ObjectsProcessed, &run.ActionsExecuted, &run.ActionsFailed, + &run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &run.CreatedUTC, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + run.CompletedUTC = parseNullTime(completed) + return run, nil +} + +// ListByRule retrieves runs for a specific rule with pagination +func (r *RuleRunRepository) ListByRule(ruleID string, offset, limit int) ([]models.RuleRun, int, error) { + var total int + err := r.db.QueryRow(`SELECT COUNT(*) FROM rule_runs WHERE rule_id = ?`, ruleID).Scan(&total) + if err != nil { + return nil, 0, err + } + + rows, err := r.db.Query(` + SELECT id, rule_id, status, started_utc, completed_utc, duration_ms, + objects_matched, objects_processed, actions_executed, actions_failed, + error_message, execution_mode, triggered_by, created_utc + FROM rule_runs + WHERE rule_id = ? + ORDER BY started_utc DESC + LIMIT ? OFFSET ? + `, ruleID, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var runs []models.RuleRun + for rows.Next() { + var run models.RuleRun + var completed sql.NullString + if err := rows.Scan( + &run.ID, &run.RuleID, &run.Status, &run.StartedUTC, &completed, &run.DurationMS, + &run.ObjectsMatched, &run.ObjectsProcessed, &run.ActionsExecuted, &run.ActionsFailed, + &run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &run.CreatedUTC, + ); err != nil { + return nil, 0, err + } + run.CompletedUTC = parseNullTime(completed) + runs = append(runs, run) + } + + return runs, total, rows.Err() +} + +// CreateAction inserts a rule run action detail record +func (r *RuleRunRepository) CreateAction(action *models.RuleRunAction) error { + if action.ID == "" { + action.ID = uuid.New().String() + } + if action.CreatedUTC.IsZero() { + action.CreatedUTC = time.Now().UTC() + } + + _, err := r.db.Exec(` + INSERT INTO rule_run_actions ( + id, rule_run_id, rule_action_id, object_dn, action_type, + status, details_json, error_message, duration_ms, created_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + action.ID, action.RuleRunID, action.RuleActionID, action.ObjectDN, action.ActionType, + action.Status, action.DetailsJSON, action.ErrorMessage, action.DurationMS, + formatTime(action.CreatedUTC), + ) + return err +} + +// ListActionsByRun retrieves action details for a rule run +func (r *RuleRunRepository) ListActionsByRun(runID string) ([]models.RuleRunAction, error) { + rows, err := r.db.Query(` + SELECT id, rule_run_id, rule_action_id, object_dn, action_type, + status, details_json, error_message, duration_ms, created_utc + FROM rule_run_actions + WHERE rule_run_id = ? + ORDER BY created_utc ASC + `, runID) + if err != nil { + return nil, err + } + defer rows.Close() + + var actions []models.RuleRunAction + for rows.Next() { + var a models.RuleRunAction + if err := rows.Scan( + &a.ID, &a.RuleRunID, &a.RuleActionID, &a.ObjectDN, &a.ActionType, + &a.Status, &a.DetailsJSON, &a.ErrorMessage, &a.DurationMS, &a.CreatedUTC, + ); err != nil { + return nil, err + } + actions = append(actions, a) + } + + return actions, rows.Err() +} diff --git a/backend/internal/rules/engine/actions.go b/backend/internal/rules/engine/actions.go new file mode 100644 index 0000000..da7b065 --- /dev/null +++ b/backend/internal/rules/engine/actions.go @@ -0,0 +1,217 @@ +// Package engine - Rule action execution +package engine + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap" + "github.com/Grace-Solutions/OrchestrAD/internal/models" + "github.com/Grace-Solutions/OrchestrAD/internal/rules/variables" + "github.com/Grace-Solutions/OrchestrAD/internal/types" +) + +// actionExecutor encapsulates action execution against an LDAP client +type actionExecutor struct { + client *ldap.Client + expander *variables.Expander +} + +func newActionExecutor(client *ldap.Client) *actionExecutor { + return &actionExecutor{ + client: client, + expander: variables.NewExpander(), + } +} + +// execute performs a single action against a matched object and returns +// a structured outcome describing what happened. +func (e *actionExecutor) execute(action *models.RuleAction, rule *models.Rule, obj MatchedObject) actionOutcome { + outcome := actionOutcome{ + ActionID: action.ID, + ActionType: action.ActionType, + ObjectDN: obj.DN, + Details: map[string]any{}, + } + + cfg, err := parseActionConfig(action.ConfigurationJSON) + if err != nil { + outcome.fail(fmt.Errorf("invalid configuration: %w", err)) + return outcome + } + + ctx := variables.NewContext(). + WithObject(obj.Attributes). + WithRule(rule.Name, rule.ID) + + switch types.ActionType(action.ActionType) { + case types.ActionAddToGroup: + e.doAddToGroup(&outcome, cfg, ctx, obj.DN) + case types.ActionAddGroupToGroup: + e.doAddToGroup(&outcome, cfg, ctx, obj.DN) + case types.ActionEnsureGroupExists: + e.doEnsureGroupExists(&outcome, cfg, ctx) + case types.ActionMoveToOu: + e.doMoveToOU(&outcome, cfg, ctx, obj.DN) + case types.ActionRemoveFromGroupIfNoMatch: + e.doRemoveFromGroup(&outcome, cfg, ctx, obj.DN) + default: + outcome.fail(fmt.Errorf("unsupported action type: %s", action.ActionType)) + } + + return outcome +} + +func (e *actionExecutor) doAddToGroup(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context, memberDN string) { + groupDN, err := e.resolveGroupDN(cfg, ctx) + if err != nil { + out.fail(err) + return + } + + if cfg.CreateIfMissing { + exists, err := e.client.Exists(groupDN) + if err != nil { + out.fail(fmt.Errorf("checking group existence: %w", err)) + return + } + if !exists { + if err := e.client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil { + out.fail(fmt.Errorf("creating group: %w", err)) + return + } + out.Details["groupCreated"] = true + } + } + + if err := e.client.AddGroupMember(groupDN, memberDN); err != nil { + out.fail(err) + return + } + + out.Details["groupDn"] = groupDN + out.Details["memberDn"] = memberDN + out.Success = true +} + +func (e *actionExecutor) doEnsureGroupExists(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context) { + groupDN, err := e.resolveGroupDN(cfg, ctx) + if err != nil { + out.fail(err) + return + } + + exists, err := e.client.Exists(groupDN) + if err != nil { + out.fail(fmt.Errorf("checking group existence: %w", err)) + return + } + if exists { + out.Details["groupExisted"] = true + out.Success = true + return + } + + if err := e.client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil { + out.fail(fmt.Errorf("creating group: %w", err)) + return + } + out.Details["groupCreated"] = true + out.Details["groupDn"] = groupDN + out.Success = true +} + +func (e *actionExecutor) doMoveToOU(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context, objectDN string) { + targetOU, err := e.expand(cfg.TargetOU, ctx) + if err != nil { + out.fail(fmt.Errorf("expanding target OU: %w", err)) + return + } + if targetOU == "" { + out.fail(fmt.Errorf("targetOu is required")) + return + } + + if cfg.CreateOUIfMissing { + exists, err := e.client.Exists(targetOU) + if err != nil { + out.fail(fmt.Errorf("checking OU existence: %w", err)) + return + } + if !exists { + if err := e.client.CreateOU(targetOU); err != nil { + out.fail(fmt.Errorf("creating OU: %w", err)) + return + } + out.Details["ouCreated"] = true + } + } + + // If object is already in the target OU, nothing to do. + if parentDN(objectDN) == targetOU { + out.Details["alreadyInTarget"] = true + out.Success = true + return + } + + if err := e.client.MoveObject(objectDN, targetOU); err != nil { + out.fail(err) + return + } + out.Details["fromDn"] = objectDN + out.Details["toOu"] = targetOU + out.Success = true +} + +func (e *actionExecutor) doRemoveFromGroup(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context, memberDN string) { + groupDN, err := e.resolveGroupDN(cfg, ctx) + if err != nil { + out.fail(err) + return + } + + if err := e.client.RemoveGroupMember(groupDN, memberDN); err != nil { + out.fail(err) + return + } + out.Details["groupDn"] = groupDN + out.Details["memberDn"] = memberDN + out.Success = true +} + +func (e *actionExecutor) resolveGroupDN(cfg models.ActionConfig, ctx *variables.Context) (string, error) { + if cfg.UseDynamicPath && cfg.PathTemplate != "" { + return e.expand(cfg.PathTemplate, ctx) + } + if cfg.TargetGroupDN == "" { + return "", fmt.Errorf("targetGroupDn is required") + } + return e.expand(cfg.TargetGroupDN, ctx) +} + +func (e *actionExecutor) expand(template string, ctx *variables.Context) (string, error) { + if template == "" { + return "", nil + } + return e.expander.Expand(template, ctx) +} + +func parseActionConfig(raw string) (models.ActionConfig, error) { + var cfg models.ActionConfig + if raw == "" { + return cfg, nil + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return cfg, err + } + return cfg, nil +} + +func parentDN(dn string) string { + idx := strings.Index(dn, ",") + if idx < 0 { + return "" + } + return strings.TrimSpace(dn[idx+1:]) +} diff --git a/backend/internal/rules/engine/engine.go b/backend/internal/rules/engine/engine.go index 5e15001..85d88f4 100644 --- a/backend/internal/rules/engine/engine.go +++ b/backend/internal/rules/engine/engine.go @@ -40,12 +40,30 @@ type ExecutionResult struct { // ActionResult contains the result of a single action type ActionResult struct { - ActionID string - ActionType string - ObjectDN string - Success bool - Error string - Duration time.Duration + ActionID string + ActionType string + ObjectDN string + Success bool + Error string + Duration time.Duration + Details map[string]any +} + +// actionOutcome is the internal outcome of a single action execution +type actionOutcome struct { + ActionID string + ActionType string + ObjectDN string + Success bool + Error string + Details map[string]any +} + +func (o *actionOutcome) fail(err error) { + o.Success = false + if err != nil { + o.Error = err.Error() + } } // PreviewResult contains a preview of what would be executed @@ -59,52 +77,45 @@ type PreviewResult struct { // MatchedObject represents an AD object that matched rule conditions type MatchedObject struct { - DN string - ObjectType string - Attributes map[string][]string + DN string + ObjectType string + Attributes map[string][]string } // PlannedAction represents an action that would be executed type PlannedAction struct { - ActionID string - ActionType string - TargetDN string - Description string - IsChange bool + ActionID string + ActionType string + TargetDN string + Description string + IsChange bool } // Preview generates a preview of rule execution without making changes -func (e *Engine) Preview(ctx context.Context, rule *models.Rule, client *ldap.Client) (*PreviewResult, error) { +func (e *Engine) Preview(ctx context.Context, rule *models.Rule, conn *models.ADConnection, client *ldap.Client) (*PreviewResult, error) { e.logger.Info("RuleEngine", "Generating preview for rule '%s'", rule.Name) result := &PreviewResult{ RuleID: rule.ID, } - // Build LDAP filter from conditions conditionGroups := buildConditionGroups(rule) filter := ldap.BuildRuleFilter(types.ObjectType(rule.ObjectType), conditionGroups, types.JoinOperator(rule.GroupJoinOperator)) result.GeneratedFilter = filter - // Determine base DN - baseDN := rule.BaseDNOverride - if baseDN == nil || *baseDN == "" { - // Would get from connection config + baseDN := resolveBaseDN(rule, conn) + if baseDN == "" { + return nil, fmt.Errorf("base DN could not be resolved from rule or connection") + } + if rule.BaseDNOverride == nil || *rule.BaseDNOverride == "" { result.Warnings = append(result.Warnings, "Using connection default base DN") } - // Search for matching objects - entries, err := client.Search( - getBaseDN(rule), - getSearchScope(rule), - filter, - []string{"dn", "cn", "objectClass", "sAMAccountName"}, - ) + entries, err := client.Search(baseDN, resolveScope(rule, conn), filter, collectAttributes(rule)) if err != nil { return nil, fmt.Errorf("search failed: %w", err) } - // Convert entries to matched objects for _, entry := range entries { matched := MatchedObject{ DN: entry.DN, @@ -125,6 +136,121 @@ func (e *Engine) Preview(ctx context.Context, rule *models.Rule, client *ldap.Cl return result, nil } +// Execute runs the rule against the directory and performs its actions. +// The caller is responsible for providing an already-bound LDAP client and +// for persisting the returned ExecutionResult if desired. +func (e *Engine) Execute(ctx context.Context, rule *models.Rule, conn *models.ADConnection, client *ldap.Client) *ExecutionResult { + started := time.Now().UTC() + result := &ExecutionResult{ + RuleID: rule.ID, + Status: types.RunStatusRunning, + StartedAt: started, + } + + e.logger.Info("RuleEngine", "Executing rule '%s' (ID: %s)", rule.Name, rule.ID) + + conditionGroups := buildConditionGroups(rule) + filter := ldap.BuildRuleFilter(types.ObjectType(rule.ObjectType), conditionGroups, types.JoinOperator(rule.GroupJoinOperator)) + + baseDN := resolveBaseDN(rule, conn) + if baseDN == "" { + return e.failResult(result, started, "base DN could not be resolved from rule or connection") + } + + entries, err := client.Search(baseDN, resolveScope(rule, conn), filter, collectAttributes(rule)) + if err != nil { + return e.failResult(result, started, fmt.Sprintf("search failed: %v", err)) + } + + result.ObjectsMatched = len(entries) + + if types.ExecutionMode(rule.ExecutionMode) == types.ExecutionModePreviewOnly { + e.finalize(result, started, types.RunStatusCompleted) + e.logger.Info("RuleEngine", "Preview-only run completed: %d matched", result.ObjectsMatched) + return result + } + + executor := newActionExecutor(client) + cancelled := false + +objectLoop: + for _, entry := range entries { + select { + case <-ctx.Done(): + cancelled = true + result.Errors = append(result.Errors, "execution cancelled") + break objectLoop + default: + } + + matched := MatchedObject{ + DN: entry.DN, + ObjectType: rule.ObjectType, + Attributes: make(map[string][]string), + } + for _, attr := range entry.Attributes { + matched.Attributes[attr.Name] = attr.Values + } + + for i := range rule.Actions { + action := &rule.Actions[i] + if !action.IsEnabled { + continue + } + actionStart := time.Now() + outcome := executor.execute(action, rule, matched) + dur := time.Since(actionStart) + + result.ActionResults = append(result.ActionResults, ActionResult{ + ActionID: outcome.ActionID, + ActionType: outcome.ActionType, + ObjectDN: outcome.ObjectDN, + Success: outcome.Success, + Error: outcome.Error, + Duration: dur, + Details: outcome.Details, + }) + + if outcome.Success { + result.ActionsExecuted++ + } else { + result.ActionsFailed++ + result.Errors = append(result.Errors, + fmt.Sprintf("action %s on %s failed: %s", outcome.ActionType, outcome.ObjectDN, outcome.Error)) + if rule.StopOnError { + break objectLoop + } + } + } + result.ObjectsProcessed++ + } + + status := types.RunStatusCompleted + if cancelled { + status = types.RunStatusCancelled + } else if result.ActionsFailed > 0 && rule.StopOnError { + status = types.RunStatusFailed + } + e.finalize(result, started, status) + + e.logger.Info("RuleEngine", "Execution finished: matched=%d processed=%d executed=%d failed=%d status=%s", + result.ObjectsMatched, result.ObjectsProcessed, result.ActionsExecuted, result.ActionsFailed, status) + + return result +} + +func (e *Engine) failResult(result *ExecutionResult, started time.Time, msg string) *ExecutionResult { + result.Errors = append(result.Errors, msg) + e.finalize(result, started, types.RunStatusFailed) + return result +} + +func (e *Engine) finalize(result *ExecutionResult, started time.Time, status types.RunStatus) { + result.CompletedAt = time.Now().UTC() + result.Duration = result.CompletedAt.Sub(started) + result.Status = status +} + func buildConditionGroups(rule *models.Rule) []ldap.ConditionGroup { var groups []ldap.ConditionGroup for _, g := range rule.ConditionGroups { @@ -177,18 +303,24 @@ func (e *Engine) planActions(rule *models.Rule, objects []MatchedObject) []Plann return planned } -func getBaseDN(rule *models.Rule) string { +func resolveBaseDN(rule *models.Rule, conn *models.ADConnection) string { if rule.BaseDNOverride != nil && *rule.BaseDNOverride != "" { return *rule.BaseDNOverride } - return "DC=example,DC=com" // Placeholder + if conn != nil { + return conn.RootDN + } + return "" } -func getSearchScope(rule *models.Rule) int { - scope := "Subtree" +func resolveScope(rule *models.Rule, conn *models.ADConnection) int { + scope := "" if rule.SearchScopeOverride != nil { scope = *rule.SearchScopeOverride } + if scope == "" && conn != nil { + scope = conn.DefaultSearchScope + } switch scope { case "Base": return ldapv3.ScopeBaseObject @@ -198,3 +330,29 @@ func getSearchScope(rule *models.Rule) int { return ldapv3.ScopeWholeSubtree } } + +func collectAttributes(rule *models.Rule) []string { + set := map[string]bool{ + "dn": true, + "cn": true, + "objectClass": true, + "sAMAccountName": true, + "distinguishedName": true, + "userPrincipalName": true, + "mail": true, + "memberOf": true, + "member": true, + } + for _, g := range rule.ConditionGroups { + for _, c := range g.Conditions { + if c.AttributeName != "" { + set[c.AttributeName] = true + } + } + } + attrs := make([]string, 0, len(set)) + for k := range set { + attrs = append(attrs, k) + } + return attrs +} diff --git a/backend/internal/rules/runner/runner.go b/backend/internal/rules/runner/runner.go new file mode 100644 index 0000000..20f244c --- /dev/null +++ b/backend/internal/rules/runner/runner.go @@ -0,0 +1,156 @@ +// Package runner coordinates end-to-end rule execution. +package runner + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Grace-Solutions/OrchestrAD/internal/logging" + "github.com/Grace-Solutions/OrchestrAD/internal/models" + "github.com/Grace-Solutions/OrchestrAD/internal/repository" + "github.com/Grace-Solutions/OrchestrAD/internal/rules/engine" + "github.com/Grace-Solutions/OrchestrAD/internal/services" + "github.com/Grace-Solutions/OrchestrAD/internal/types" +) + +// Runner loads rules and their dependencies, executes them via the engine, +// and records the resulting run and action histories. +type Runner struct { + ruleRepo *repository.RuleRepository + connRepo *repository.ConnectionRepository + runRepo *repository.RuleRunRepository + connService *services.ConnectionService + engine *engine.Engine + logger *logging.Logger +} + +// New creates a new Runner +func New(db *sql.DB, connService *services.ConnectionService, logger *logging.Logger) *Runner { + return &Runner{ + ruleRepo: repository.NewRuleRepository(db), + connRepo: repository.NewConnectionRepository(db), + runRepo: repository.NewRuleRunRepository(db), + connService: connService, + engine: engine.NewEngine(logger), + logger: logger, + } +} + +// RunRule executes the rule identified by ruleID. triggeredBy is a free-form +// label stored on the run record (e.g. "scheduler", "user:", "api"). +func (r *Runner) RunRule(ctx context.Context, ruleID string, triggeredBy string) error { + rule, err := r.ruleRepo.GetByID(ruleID) + if err != nil { + return fmt.Errorf("loading rule: %w", err) + } + if rule == nil { + return fmt.Errorf("rule not found: %s", ruleID) + } + if !rule.IsEnabled { + r.logger.Info("Runner", "Rule '%s' is disabled, skipping", rule.Name) + return nil + } + + conn, err := r.connRepo.GetByID(rule.ADConnectionID) + if err != nil { + return fmt.Errorf("loading AD connection: %w", err) + } + if conn == nil { + return fmt.Errorf("AD connection not found: %s", rule.ADConnectionID) + } + + run := &models.RuleRun{ + RuleID: rule.ID, + Status: string(types.RunStatusRunning), + StartedUTC: time.Now().UTC(), + ExecutionMode: rule.ExecutionMode, + } + if triggeredBy != "" { + tb := triggeredBy + run.TriggeredBy = &tb + } + if err := r.runRepo.Create(run); err != nil { + return fmt.Errorf("creating rule run: %w", err) + } + + client, err := r.connService.BuildClient(conn) + if err != nil { + r.failRun(run, fmt.Sprintf("LDAP connection failed: %v", err)) + return fmt.Errorf("building LDAP client: %w", err) + } + defer client.Close() + + result := r.engine.Execute(ctx, rule, conn, client) + + for _, ar := range result.ActionResults { + r.persistActionRecord(run.ID, ar) + } + + r.finalizeRun(run, result) + return nil +} + +func (r *Runner) persistActionRecord(runID string, ar engine.ActionResult) { + status := "Succeeded" + if !ar.Success { + status = "Failed" + } + rec := &models.RuleRunAction{ + RuleRunID: runID, + RuleActionID: ar.ActionID, + ObjectDN: ar.ObjectDN, + ActionType: ar.ActionType, + Status: status, + } + if len(ar.Details) > 0 { + if b, err := json.Marshal(ar.Details); err == nil { + s := string(b) + rec.DetailsJSON = &s + } + } + if ar.Error != "" { + em := ar.Error + rec.ErrorMessage = &em + } + dur := int(ar.Duration.Milliseconds()) + rec.DurationMS = &dur + + if err := r.runRepo.CreateAction(rec); err != nil { + r.logger.Warn("Runner", "Failed to persist action record for run %s: %v", runID, err) + } +} + +func (r *Runner) finalizeRun(run *models.RuleRun, result *engine.ExecutionResult) { + completed := result.CompletedAt + dur := int(result.Duration.Milliseconds()) + run.Status = string(result.Status) + run.CompletedUTC = &completed + run.DurationMS = &dur + run.ObjectsMatched = result.ObjectsMatched + run.ObjectsProcessed = result.ObjectsProcessed + run.ActionsExecuted = result.ActionsExecuted + run.ActionsFailed = result.ActionsFailed + if len(result.Errors) > 0 { + msg := strings.Join(result.Errors, "; ") + run.ErrorMessage = &msg + } + if err := r.runRepo.Update(run); err != nil { + r.logger.Warn("Runner", "Failed to update rule run %s: %v", run.ID, err) + } +} + +func (r *Runner) failRun(run *models.RuleRun, msg string) { + now := time.Now().UTC() + dur := int(now.Sub(run.StartedUTC).Milliseconds()) + run.Status = string(types.RunStatusFailed) + run.CompletedUTC = &now + run.DurationMS = &dur + run.ErrorMessage = &msg + if err := r.runRepo.Update(run); err != nil { + r.logger.Warn("Runner", "Failed to update failed rule run %s: %v", run.ID, err) + } +} diff --git a/backend/internal/scheduler/scheduler.go b/backend/internal/scheduler/scheduler.go index cb8d243..ace03eb 100644 --- a/backend/internal/scheduler/scheduler.go +++ b/backend/internal/scheduler/scheduler.go @@ -12,10 +12,17 @@ import ( "github.com/robfig/cron/v3" ) +// RuleRunner executes a rule identified by ID. The scheduler uses this +// abstraction to avoid importing the runner package directly. +type RuleRunner interface { + RunRule(ctx context.Context, ruleID string, triggeredBy string) error +} + // Scheduler manages scheduled rule execution type Scheduler struct { db *sql.DB logger *logging.Logger + runner RuleRunner cron *cron.Cron mu sync.RWMutex entries map[string]cron.EntryID @@ -23,8 +30,9 @@ type Scheduler struct { stopChan chan struct{} } -// New creates a new Scheduler -func New(db *sql.DB, logger *logging.Logger) *Scheduler { +// New creates a new Scheduler. A non-nil runner is required to actually +// execute scheduled rules; if nil, triggered schedules will log and no-op. +func New(db *sql.DB, runner RuleRunner, logger *logging.Logger) *Scheduler { // Use six-field cron parser (with seconds) parser := cron.NewParser( cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow, @@ -34,6 +42,7 @@ func New(db *sql.DB, logger *logging.Logger) *Scheduler { return &Scheduler{ db: db, logger: logger, + runner: runner, cron: c, entries: make(map[string]cron.EntryID), stopChan: make(chan struct{}), @@ -189,6 +198,14 @@ func (s *Scheduler) buildCronExpression(cronExpr sql.NullString, easyValue sql.N func (s *Scheduler) createRuleRunner(ruleID, ruleName string) func() { return func() { s.logger.Info("Scheduler", "Executing scheduled rule '%s' (ID: %s)", ruleName, ruleID) - // TODO: Call rule executor + if s.runner == nil { + s.logger.Warn("Scheduler", "No runner configured; skipping execution of '%s'", ruleName) + return + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.runner.RunRule(ctx, ruleID, "scheduler"); err != nil { + s.logger.Error("Scheduler", "Rule '%s' failed: %v", ruleName, err) + } } } diff --git a/backend/internal/services/connection_service.go b/backend/internal/services/connection_service.go index 1e2a26b..7ed7fc7 100644 --- a/backend/internal/services/connection_service.go +++ b/backend/internal/services/connection_service.go @@ -16,10 +16,10 @@ import ( // ConnectionService handles AD connection operations type ConnectionService struct { - connRepo *repository.ConnectionRepository - credRepo *repository.CredentialRepository - encryptor *crypto.Encryptor - logger *logging.Logger + connRepo *repository.ConnectionRepository + credRepo *repository.CredentialRepository + encryptor *crypto.Encryptor + logger *logging.Logger } // NewConnectionService creates a new ConnectionService @@ -55,7 +55,7 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu } // Build LDAP config - config, err := s.buildLDAPConfig(conn) + config, err := s.BuildLDAPConfig(conn) if err != nil { result.Message = fmt.Sprintf("Configuration error: %v", err) return result, nil @@ -67,14 +67,14 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu start := time.Now() err = client.Connect() connectLatency := time.Since(start) - + result.TestDetails = append(result.TestDetails, TestDetail{ Step: "TCP Connection", Success: err == nil, Message: errorMessage(err), Latency: connectLatency, }) - + if err != nil { result.Message = "Connection failed" return result, nil @@ -85,14 +85,14 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu start = time.Now() err = client.Bind() bindLatency := time.Since(start) - + result.TestDetails = append(result.TestDetails, TestDetail{ Step: "LDAP Bind", Success: err == nil, Message: errorMessage(err), Latency: bindLatency, }) - + if err != nil { result.Message = "Authentication failed" return result, nil @@ -102,14 +102,14 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu start = time.Now() err = testRootDN(client, conn.RootDN) rootLatency := time.Since(start) - + result.TestDetails = append(result.TestDetails, TestDetail{ Step: "Root DN Access", Success: err == nil, Message: errorMessage(err), Latency: rootLatency, }) - + if err != nil { result.Message = "Root DN not accessible" return result, nil @@ -120,7 +120,28 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu return result, nil } -func (s *ConnectionService) buildLDAPConfig(conn *models.ADConnection) (*ldap.Config, error) { +// BuildClient returns a connected and bound LDAP client for the given connection. +// Caller is responsible for calling Close() on the returned client. +func (s *ConnectionService) BuildClient(conn *models.ADConnection) (*ldap.Client, error) { + config, err := s.BuildLDAPConfig(conn) + if err != nil { + return nil, err + } + + client := ldap.NewClient(config) + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("connect failed: %w", err) + } + if err := client.Bind(); err != nil { + client.Close() + return nil, fmt.Errorf("bind failed: %w", err) + } + return client, nil +} + +// BuildLDAPConfig constructs an LDAP config from a connection model and +// resolves any referenced credential. +func (s *ConnectionService) BuildLDAPConfig(conn *models.ADConnection) (*ldap.Config, error) { hosts := strings.Split(conn.Hosts, ",") for i := range hosts { hosts[i] = strings.TrimSpace(hosts[i])