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

263 lines
6.9 KiB
Go

// Package services - Rule service
package services
import (
"database/sql"
"fmt"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
)
// RuleService handles rule operations
type RuleService struct {
repo *repository.RuleRepository
logger *logging.Logger
}
// NewRuleService creates a new RuleService
func NewRuleService(db *sql.DB, logger *logging.Logger) *RuleService {
return &RuleService{
repo: repository.NewRuleRepository(db),
logger: logger,
}
}
// CreateRuleInput represents input for creating a rule
type CreateRuleInput struct {
Name string
Description *string
ADConnectionID string
ObjectType string
BaseDNOverride *string
SearchScopeOverride *string
ScheduleID *string
ExecutionMode string
GroupJoinOperator string
MaxParallelism *int
StopOnError bool
}
// Create creates a new rule
func (s *RuleService) Create(input CreateRuleInput) (*models.Rule, error) {
rule := &models.Rule{
Name: input.Name,
Description: input.Description,
IsEnabled: true,
ADConnectionID: input.ADConnectionID,
ObjectType: input.ObjectType,
BaseDNOverride: input.BaseDNOverride,
SearchScopeOverride: input.SearchScopeOverride,
ScheduleID: input.ScheduleID,
ExecutionMode: input.ExecutionMode,
GroupJoinOperator: input.GroupJoinOperator,
MaxParallelism: input.MaxParallelism,
StopOnError: input.StopOnError,
}
if err := s.repo.Create(rule); err != nil {
return nil, err
}
s.logger.Info("RuleService", "Created rule '%s' (ID: %s)", rule.Name, rule.ID)
return rule, nil
}
// GetByID retrieves a rule by ID with all nested data
func (s *RuleService) GetByID(id string) (*models.Rule, error) {
return s.repo.GetByID(id)
}
// ReplaceLogic replaces a rule's condition groups and actions wholesale. Used
// by the rule editor to persist the filter and target-group actions.
func (s *RuleService) ReplaceLogic(ruleID string, groups []models.RuleConditionGroup, actions []models.RuleAction) error {
if err := s.repo.ReplaceLogic(ruleID, groups, actions); err != nil {
return err
}
s.logger.Info("RuleService", "Replaced logic for rule %s (%d groups, %d actions)", ruleID, len(groups), len(actions))
return nil
}
// RuleListItem represents a rule in list views
type RuleListItem struct {
ID string
Name string
Description *string
IsEnabled bool
ObjectType string
ScheduleID *string
LastRunUTC *string
LastRunResult *string
ConditionCount int
ActionCount int
}
// List retrieves rules for list view with per-rule condition/action counts.
func (s *RuleService) List(page, pageSize int) ([]RuleListItem, int, error) {
offset := (page - 1) * pageSize
rules, total, err := s.repo.List(offset, pageSize)
if err != nil {
return nil, 0, err
}
items := make([]RuleListItem, 0, len(rules))
for i := range rules {
r := &rules[i]
cc, err := s.repo.CountConditionsForRule(r.ID)
if err != nil {
return nil, 0, err
}
ac, err := s.repo.CountActionsForRule(r.ID)
if err != nil {
return nil, 0, err
}
var lastRun *string
if r.LastRunUTC != nil {
v := r.LastRunUTC.UTC().Format("2006-01-02T15:04:05Z")
lastRun = &v
}
items = append(items, RuleListItem{
ID: r.ID,
Name: r.Name,
Description: r.Description,
IsEnabled: r.IsEnabled,
ObjectType: r.ObjectType,
ScheduleID: r.ScheduleID,
LastRunUTC: lastRun,
LastRunResult: r.LastRunResult,
ConditionCount: cc,
ActionCount: ac,
})
}
return items, total, nil
}
// UpdateRuleInput represents updatable top-level rule fields.
type UpdateRuleInput struct {
ID string
Name *string
Description *string
IsEnabled *bool
ADConnectionID *string
ObjectType *string
BaseDNOverride *string
SearchScopeOverride *string
ScheduleID *string
ExecutionMode *string
GroupJoinOperator *string
MaxParallelism *int
StopOnError *bool
}
// Update modifies top-level rule fields.
func (s *RuleService) Update(input UpdateRuleInput) (*models.Rule, error) {
rule, err := s.repo.GetByID(input.ID)
if err != nil {
return nil, err
}
if rule == nil {
return nil, fmt.Errorf("rule not found")
}
if input.Name != nil {
rule.Name = *input.Name
}
if input.Description != nil {
rule.Description = input.Description
}
if input.IsEnabled != nil {
rule.IsEnabled = *input.IsEnabled
}
if input.ADConnectionID != nil {
rule.ADConnectionID = *input.ADConnectionID
}
if input.ObjectType != nil {
rule.ObjectType = *input.ObjectType
}
if input.BaseDNOverride != nil {
rule.BaseDNOverride = input.BaseDNOverride
}
if input.SearchScopeOverride != nil {
rule.SearchScopeOverride = input.SearchScopeOverride
}
if input.ScheduleID != nil {
rule.ScheduleID = input.ScheduleID
}
if input.ExecutionMode != nil {
rule.ExecutionMode = *input.ExecutionMode
}
if input.GroupJoinOperator != nil {
rule.GroupJoinOperator = *input.GroupJoinOperator
}
if input.MaxParallelism != nil {
rule.MaxParallelism = input.MaxParallelism
}
if input.StopOnError != nil {
rule.StopOnError = *input.StopOnError
}
if err := s.repo.Update(rule); err != nil {
return nil, err
}
s.logger.Info("RuleService", "Updated rule '%s' (ID: %s)", rule.Name, rule.ID)
return rule, nil
}
// Enable enables a rule
func (s *RuleService) Enable(id string) error {
if err := s.repo.UpdateEnabled(id, true); err != nil {
return err
}
s.logger.Info("RuleService", "Enabled rule (ID: %s)", id)
return nil
}
// Disable disables a rule
func (s *RuleService) Disable(id string) error {
if err := s.repo.UpdateEnabled(id, false); err != nil {
return err
}
s.logger.Info("RuleService", "Disabled rule (ID: %s)", id)
return nil
}
// Delete soft-deletes a rule.
func (s *RuleService) Delete(id string) error {
if err := s.repo.SoftDelete(id); err != nil {
return err
}
s.logger.Info("RuleService", "Deleted rule (ID: %s)", id)
return nil
}
// ValidateRule validates a rule configuration
func (s *RuleService) ValidateRule(rule *models.Rule) []string {
var errors []string
if rule.Name == "" {
errors = append(errors, "Name is required")
}
if rule.ADConnectionID == "" {
errors = append(errors, "AD Connection is required")
}
if rule.ObjectType == "" {
errors = append(errors, "Object Type is required")
}
if len(rule.ConditionGroups) == 0 {
errors = append(errors, "At least one condition group is required")
}
if len(rule.Actions) == 0 {
errors = append(errors, "At least one action is required")
}
// Validate condition groups have conditions
for i, group := range rule.ConditionGroups {
if len(group.Conditions) == 0 {
errors = append(errors, fmt.Sprintf("Condition group %d has no conditions", i+1))
}
}
return errors
}