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>
263 lines
6.9 KiB
Go
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
|
|
}
|