Files
OrchestrAD/backend/internal/rules/engine/engine.go
T
GraceSolutions bca862ca01 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
2026-04-23 12:12:59 -04:00

359 lines
9.6 KiB
Go

// Package engine provides rule evaluation and execution
package engine
import (
"context"
"fmt"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
ldapv3 "github.com/go-ldap/ldap/v3"
)
// Engine executes rules against Active Directory
type Engine struct {
logger *logging.Logger
}
// NewEngine creates a new rule engine
func NewEngine(logger *logging.Logger) *Engine {
return &Engine{logger: logger}
}
// ExecutionResult contains the results of rule execution
type ExecutionResult struct {
RuleID string
Status types.RunStatus
StartedAt time.Time
CompletedAt time.Time
Duration time.Duration
ObjectsMatched int
ObjectsProcessed int
ActionsExecuted int
ActionsFailed int
Errors []string
ActionResults []ActionResult
}
// ActionResult contains the result of a single action
type ActionResult struct {
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
type PreviewResult struct {
RuleID string
MatchedObjects []MatchedObject
PlannedActions []PlannedAction
Warnings []string
GeneratedFilter string
}
// MatchedObject represents an AD object that matched rule conditions
type MatchedObject struct {
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
}
// Preview generates a preview of rule execution without making changes
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,
}
conditionGroups := buildConditionGroups(rule)
filter := ldap.BuildRuleFilter(types.ObjectType(rule.ObjectType), conditionGroups, types.JoinOperator(rule.GroupJoinOperator))
result.GeneratedFilter = filter
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")
}
entries, err := client.Search(baseDN, resolveScope(rule, conn), filter, collectAttributes(rule))
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, entry := range entries {
matched := MatchedObject{
DN: entry.DN,
ObjectType: rule.ObjectType,
Attributes: make(map[string][]string),
}
for _, attr := range entry.Attributes {
matched.Attributes[attr.Name] = attr.Values
}
result.MatchedObjects = append(result.MatchedObjects, matched)
}
result.PlannedActions = e.planActions(rule, result.MatchedObjects)
e.logger.Info("RuleEngine", "Preview complete: %d objects matched, %d actions planned",
len(result.MatchedObjects), len(result.PlannedActions))
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 {
if !g.IsEnabled {
continue
}
group := ldap.ConditionGroup{
JoinOperator: types.JoinOperator(g.JoinOperator),
Negate: g.Negate,
}
for _, c := range g.Conditions {
if !c.IsEnabled {
continue
}
cond := ldap.Condition{
Attribute: c.AttributeName,
Operator: types.ConditionOperator(c.Operator),
Negate: c.Negate,
CaseSensitive: c.CaseSensitive,
}
if c.ComparisonValue != nil {
cond.Value = *c.ComparisonValue
}
if c.CustomLdapExpression != nil {
cond.CustomLdap = *c.CustomLdapExpression
}
group.Conditions = append(group.Conditions, cond)
}
groups = append(groups, group)
}
return groups
}
func (e *Engine) planActions(rule *models.Rule, objects []MatchedObject) []PlannedAction {
var planned []PlannedAction
for _, action := range rule.Actions {
if !action.IsEnabled {
continue
}
for _, obj := range objects {
planned = append(planned, PlannedAction{
ActionID: action.ID,
ActionType: action.ActionType,
TargetDN: obj.DN,
Description: fmt.Sprintf("%s on %s", action.ActionType, obj.DN),
IsChange: true,
})
}
}
return planned
}
func resolveBaseDN(rule *models.Rule, conn *models.ADConnection) string {
if rule.BaseDNOverride != nil && *rule.BaseDNOverride != "" {
return *rule.BaseDNOverride
}
if conn != nil {
return conn.RootDN
}
return ""
}
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
case "OneLevel":
return ldapv3.ScopeSingleLevel
default:
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
}