bca862ca01
- 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
359 lines
9.6 KiB
Go
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
|
|
}
|