feat(rules): implement end-to-end rule execution pipeline

- Add engine.Execute() that searches AD, iterates matches, runs actions,
  honors StopOnError and PreviewOnly, and supports context cancellation
- Resolve base DN and search scope from rule override then connection defaults
- Add actionExecutor covering AddToGroup, AddGroupToGroup, EnsureGroupExists,
  MoveToOu, and RemoveFromGroupIfNoLongerMatched with dynamic DN expansion
- Add RuleRunRepository persisting rule_runs and rule_run_actions
- Add Runner coordinator that loads rule+connection, builds an LDAP client,
  invokes the engine, and records the run + per-action outcomes
- Expose ConnectionService.BuildClient / BuildLDAPConfig for reuse
- Scheduler now accepts a RuleRunner and invokes it when a scheduled rule fires
This commit is contained in:
GraceSolutions
2026-04-23 12:12:59 -04:00
parent 8986fc9325
commit bca862ca01
6 changed files with 810 additions and 47 deletions
+194
View File
@@ -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()
}
+217
View File
@@ -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:])
}
+190 -32
View File
@@ -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
}
+156
View File
@@ -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:<id>", "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)
}
}
+20 -3
View File
@@ -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)
}
}
}
+33 -12
View File
@@ -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])