5920b690d1
Turns rules into Adaxes/Active-Roles style dynamic groups. The core new
capability is a set-level SyncGroupMembership action that reconciles a
target group's membership against the matched object set in one pass
instead of the old add-only, per-object behaviour.
Engine / reconciliation:
- New ActionSyncGroupMembership runs once per target group after the
match: resolve (and optionally create) the group, read its current
members, diff against the matched set, and apply the adds/removes.
- Three per-rule sync modes (types.SyncMode): FullSync (membership ==
matched set; removes stale members incl. manual adds), ManagedAdd (adds
matches, removes only members this rule added), AddOnly (never removes).
- Managed ownership tracked in a new managed_group_members table
(migration 005) + repository, wired into the runner's engine so
ManagedAdd removes only what it added.
- Adds/removes are recorded as AddToGroup / RemoveFromGroupIfNoLongerMatched
run-actions so the activity feed categorises them as syncs/removals.
- Preview now computes an accurate, non-mutating diff for sync actions
(+add / -remove / already-in-sync counts and per-member entries).
- memberOf and memberOf-recursive (LDAP_MATCHING_RULE_IN_CHAIN) operators;
Regex no longer silently degrades to equals.
- Canonical group targets: CanonicalToLeafDN / NormalizeGroupTarget so a
target group can be given as domain.com/OU/Group as well as a DN.
Editor-facing APIs (backend-first; UI comes next):
- Rule create/update now accept conditionGroups + actions and persist them
via RuleRepository.ReplaceLogic (soft-delete + insert, preserving the
rule_run_actions FK). Omitting them leaves existing logic untouched.
- POST /api/v1/rules/preview evaluates an unsaved draft (live match panel).
- GET /api/v1/rules/metadata serves the operator vocabulary (object types,
operators, action types, sync modes, common attributes) so UI dropdowns
stay in lock-step with the backend.
- GET /api/v1/ad-connections/{id}/directory searches groups/OUs for the
target pickers.
Tests: reconciliation across all three modes + create-if-missing and the
missing-group error path (fake directory client); canonical leaf-DN
conversion; ReplaceLogic round-trip. Full suite green.
Note: RuleRepository.GetByID nests a query (getConditionGroups holds a
cursor while calling getConditions); safe under the production pool (25)
but a follow-up should flatten it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
471 lines
14 KiB
Go
471 lines
14 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
|
|
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
|
|
}
|