Files
Alphaeus Mote 5920b690d1 feat(rules): dynamic-group reconciliation + editor-facing APIs
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>
2026-09-02 14:57:30 -04:00

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
}