// 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 managed ManagedMemberStore } // NewEngine creates a new rule engine func NewEngine(logger *logging.Logger) *Engine { return &Engine{logger: logger} } // SetManagedStore attaches the managed-membership store used by the ManagedAdd // sync mode to remember which memberships this engine added. Optional; when // unset, ManagedAdd never removes and FullSync is unaffected. func (e *Engine) SetManagedStore(store ManagedMemberStore) { e.managed = store } // isSetAction reports whether an action operates on the whole matched set at // once (rather than per matched object). func isSetAction(actionType string) bool { return types.ActionType(actionType) == types.ActionSyncGroupMembership } // 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 // CanonicalName is the operator-friendly name (domain.com/OU/CN), from the // directory's canonicalName attribute when present, else built from the DN. CanonicalName 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 } matched.CanonicalName = ldap.CanonicalName(entry.DN, entry.GetAttributeValue("canonicalName")) result.MatchedObjects = append(result.MatchedObjects, matched) } result.PlannedActions = e.planActions(rule, result.MatchedObjects, client) 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 matchedDNs := make([]string, 0, len(entries)) 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 } matchedDNs = append(matchedDNs, entry.DN) for i := range rule.Actions { action := &rule.Actions[i] if !action.IsEnabled || isSetAction(action.ActionType) { continue // set-level actions run once after the loop } 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++ } // Set-level actions (SyncGroupMembership) reconcile the whole matched set // against a target group's membership in one pass. if !cancelled { for i := range rule.Actions { action := &rule.Actions[i] if !action.IsEnabled || !isSetAction(action.ActionType) { continue } actionStart := time.Now() outcomes := e.reconcileMembership(action, rule, matchedDNs, client, e.managed) dur := time.Since(actionStart) for _, oc := range outcomes { oc.Duration = dur result.ActionResults = append(result.ActionResults, oc) if oc.Success { result.ActionsExecuted++ } else { result.ActionsFailed++ result.Errors = append(result.Errors, fmt.Sprintf("sync %s on %s failed: %s", oc.ActionType, oc.ObjectDN, oc.Error)) } } if result.ActionsFailed > 0 && rule.StopOnError { break } } } 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, client membershipClient) []PlannedAction { var planned []PlannedAction matchedDNs := make([]string, 0, len(objects)) for _, o := range objects { matchedDNs = append(matchedDNs, o.DN) } for i := range rule.Actions { action := &rule.Actions[i] if !action.IsEnabled { continue } if isSetAction(action.ActionType) { planned = append(planned, e.planSyncAction(action, rule, matchedDNs, client)...) 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 } // planSyncAction computes an accurate, non-mutating preview for a // SyncGroupMembership action: a summary line plus one planned entry per member // that would be added or removed. func (e *Engine) planSyncAction(action *models.RuleAction, rule *models.Rule, matchedDNs []string, client membershipClient) []PlannedAction { plan := planMembership(action, rule, matchedDNs, client, e.managed, false) if plan.Err != nil { return []PlannedAction{{ ActionID: action.ID, ActionType: string(types.ActionSyncGroupMembership), TargetDN: plan.GroupDN, Description: "cannot sync: " + plan.Err.Error(), IsChange: false, }} } var planned []PlannedAction summary := fmt.Sprintf("sync %s: +%d add / -%d remove (%d already in sync)", plan.GroupDN, len(plan.ToAdd), len(plan.ToRemove), plan.Unchanged) if plan.GroupCreated { summary = "create group + " + summary } planned = append(planned, PlannedAction{ ActionID: action.ID, ActionType: string(types.ActionSyncGroupMembership), TargetDN: plan.GroupDN, Description: summary, IsChange: plan.GroupCreated || len(plan.ToAdd) > 0 || len(plan.ToRemove) > 0, }) for _, dn := range plan.ToAdd { planned = append(planned, PlannedAction{ ActionID: action.ID, ActionType: string(types.ActionAddToGroup), TargetDN: dn, Description: "add to " + plan.GroupDN, IsChange: true, }) } for _, dn := range plan.ToRemove { planned = append(planned, PlannedAction{ ActionID: action.ID, ActionType: string(types.ActionRemoveFromGroupIfNoMatch), TargetDN: dn, Description: "remove from " + plan.GroupDN + " (no longer matches)", 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, "canonicalName": 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 }