feat(rules): implement end-to-end rule execution pipeline
- Add engine.Execute() that searches AD, iterates matches, runs actions, honors StopOnError and PreviewOnly, and supports context cancellation - Resolve base DN and search scope from rule override then connection defaults - Add actionExecutor covering AddToGroup, AddGroupToGroup, EnsureGroupExists, MoveToOu, and RemoveFromGroupIfNoLongerMatched with dynamic DN expansion - Add RuleRunRepository persisting rule_runs and rule_run_actions - Add Runner coordinator that loads rule+connection, builds an LDAP client, invokes the engine, and records the run + per-action outcomes - Expose ConnectionService.BuildClient / BuildLDAPConfig for reuse - Scheduler now accepts a RuleRunner and invokes it when a scheduled rule fires
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Package repository - Rule runs repository
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RuleRunRepository handles rule run persistence
|
||||
type RuleRunRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewRuleRunRepository creates a new RuleRunRepository
|
||||
func NewRuleRunRepository(db *sql.DB) *RuleRunRepository {
|
||||
return &RuleRunRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new rule run record
|
||||
func (r *RuleRunRepository) Create(run *models.RuleRun) error {
|
||||
if run.ID == "" {
|
||||
run.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if run.CreatedUTC.IsZero() {
|
||||
run.CreatedUTC = now
|
||||
}
|
||||
if run.StartedUTC.IsZero() {
|
||||
run.StartedUTC = now
|
||||
}
|
||||
|
||||
var completed *string
|
||||
if run.CompletedUTC != nil {
|
||||
s := formatTime(*run.CompletedUTC)
|
||||
completed = &s
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO rule_runs (
|
||||
id, rule_id, status, started_utc, completed_utc, duration_ms,
|
||||
objects_matched, objects_processed, actions_executed, actions_failed,
|
||||
error_message, execution_mode, triggered_by, created_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
run.ID, run.RuleID, run.Status, formatTime(run.StartedUTC), completed, run.DurationMS,
|
||||
run.ObjectsMatched, run.ObjectsProcessed, run.ActionsExecuted, run.ActionsFailed,
|
||||
run.ErrorMessage, run.ExecutionMode, run.TriggeredBy, formatTime(run.CreatedUTC),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update updates an existing rule run
|
||||
func (r *RuleRunRepository) Update(run *models.RuleRun) error {
|
||||
var completed *string
|
||||
if run.CompletedUTC != nil {
|
||||
s := formatTime(*run.CompletedUTC)
|
||||
completed = &s
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(`
|
||||
UPDATE rule_runs SET
|
||||
status = ?, completed_utc = ?, duration_ms = ?,
|
||||
objects_matched = ?, objects_processed = ?,
|
||||
actions_executed = ?, actions_failed = ?,
|
||||
error_message = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
run.Status, completed, run.DurationMS,
|
||||
run.ObjectsMatched, run.ObjectsProcessed,
|
||||
run.ActionsExecuted, run.ActionsFailed,
|
||||
run.ErrorMessage, run.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByID retrieves a rule run by ID
|
||||
func (r *RuleRunRepository) GetByID(id string) (*models.RuleRun, error) {
|
||||
run := &models.RuleRun{}
|
||||
var completed sql.NullString
|
||||
|
||||
err := r.db.QueryRow(`
|
||||
SELECT id, rule_id, status, started_utc, completed_utc, duration_ms,
|
||||
objects_matched, objects_processed, actions_executed, actions_failed,
|
||||
error_message, execution_mode, triggered_by, created_utc
|
||||
FROM rule_runs WHERE id = ?
|
||||
`, id).Scan(
|
||||
&run.ID, &run.RuleID, &run.Status, &run.StartedUTC, &completed, &run.DurationMS,
|
||||
&run.ObjectsMatched, &run.ObjectsProcessed, &run.ActionsExecuted, &run.ActionsFailed,
|
||||
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &run.CreatedUTC,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
run.CompletedUTC = parseNullTime(completed)
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// ListByRule retrieves runs for a specific rule with pagination
|
||||
func (r *RuleRunRepository) ListByRule(ruleID string, offset, limit int) ([]models.RuleRun, int, error) {
|
||||
var total int
|
||||
err := r.db.QueryRow(`SELECT COUNT(*) FROM rule_runs WHERE rule_id = ?`, ruleID).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, rule_id, status, started_utc, completed_utc, duration_ms,
|
||||
objects_matched, objects_processed, actions_executed, actions_failed,
|
||||
error_message, execution_mode, triggered_by, created_utc
|
||||
FROM rule_runs
|
||||
WHERE rule_id = ?
|
||||
ORDER BY started_utc DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, ruleID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var runs []models.RuleRun
|
||||
for rows.Next() {
|
||||
var run models.RuleRun
|
||||
var completed sql.NullString
|
||||
if err := rows.Scan(
|
||||
&run.ID, &run.RuleID, &run.Status, &run.StartedUTC, &completed, &run.DurationMS,
|
||||
&run.ObjectsMatched, &run.ObjectsProcessed, &run.ActionsExecuted, &run.ActionsFailed,
|
||||
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &run.CreatedUTC,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
run.CompletedUTC = parseNullTime(completed)
|
||||
runs = append(runs, run)
|
||||
}
|
||||
|
||||
return runs, total, rows.Err()
|
||||
}
|
||||
|
||||
// CreateAction inserts a rule run action detail record
|
||||
func (r *RuleRunRepository) CreateAction(action *models.RuleRunAction) error {
|
||||
if action.ID == "" {
|
||||
action.ID = uuid.New().String()
|
||||
}
|
||||
if action.CreatedUTC.IsZero() {
|
||||
action.CreatedUTC = time.Now().UTC()
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO rule_run_actions (
|
||||
id, rule_run_id, rule_action_id, object_dn, action_type,
|
||||
status, details_json, error_message, duration_ms, created_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
action.ID, action.RuleRunID, action.RuleActionID, action.ObjectDN, action.ActionType,
|
||||
action.Status, action.DetailsJSON, action.ErrorMessage, action.DurationMS,
|
||||
formatTime(action.CreatedUTC),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListActionsByRun retrieves action details for a rule run
|
||||
func (r *RuleRunRepository) ListActionsByRun(runID string) ([]models.RuleRunAction, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, rule_run_id, rule_action_id, object_dn, action_type,
|
||||
status, details_json, error_message, duration_ms, created_utc
|
||||
FROM rule_run_actions
|
||||
WHERE rule_run_id = ?
|
||||
ORDER BY created_utc ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var actions []models.RuleRunAction
|
||||
for rows.Next() {
|
||||
var a models.RuleRunAction
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.RuleRunID, &a.RuleActionID, &a.ObjectDN, &a.ActionType,
|
||||
&a.Status, &a.DetailsJSON, &a.ErrorMessage, &a.DurationMS, &a.CreatedUTC,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
|
||||
return actions, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Package engine - Rule action execution
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/rules/variables"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/types"
|
||||
)
|
||||
|
||||
// actionExecutor encapsulates action execution against an LDAP client
|
||||
type actionExecutor struct {
|
||||
client *ldap.Client
|
||||
expander *variables.Expander
|
||||
}
|
||||
|
||||
func newActionExecutor(client *ldap.Client) *actionExecutor {
|
||||
return &actionExecutor{
|
||||
client: client,
|
||||
expander: variables.NewExpander(),
|
||||
}
|
||||
}
|
||||
|
||||
// execute performs a single action against a matched object and returns
|
||||
// a structured outcome describing what happened.
|
||||
func (e *actionExecutor) execute(action *models.RuleAction, rule *models.Rule, obj MatchedObject) actionOutcome {
|
||||
outcome := actionOutcome{
|
||||
ActionID: action.ID,
|
||||
ActionType: action.ActionType,
|
||||
ObjectDN: obj.DN,
|
||||
Details: map[string]any{},
|
||||
}
|
||||
|
||||
cfg, err := parseActionConfig(action.ConfigurationJSON)
|
||||
if err != nil {
|
||||
outcome.fail(fmt.Errorf("invalid configuration: %w", err))
|
||||
return outcome
|
||||
}
|
||||
|
||||
ctx := variables.NewContext().
|
||||
WithObject(obj.Attributes).
|
||||
WithRule(rule.Name, rule.ID)
|
||||
|
||||
switch types.ActionType(action.ActionType) {
|
||||
case types.ActionAddToGroup:
|
||||
e.doAddToGroup(&outcome, cfg, ctx, obj.DN)
|
||||
case types.ActionAddGroupToGroup:
|
||||
e.doAddToGroup(&outcome, cfg, ctx, obj.DN)
|
||||
case types.ActionEnsureGroupExists:
|
||||
e.doEnsureGroupExists(&outcome, cfg, ctx)
|
||||
case types.ActionMoveToOu:
|
||||
e.doMoveToOU(&outcome, cfg, ctx, obj.DN)
|
||||
case types.ActionRemoveFromGroupIfNoMatch:
|
||||
e.doRemoveFromGroup(&outcome, cfg, ctx, obj.DN)
|
||||
default:
|
||||
outcome.fail(fmt.Errorf("unsupported action type: %s", action.ActionType))
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
func (e *actionExecutor) doAddToGroup(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context, memberDN string) {
|
||||
groupDN, err := e.resolveGroupDN(cfg, ctx)
|
||||
if err != nil {
|
||||
out.fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.CreateIfMissing {
|
||||
exists, err := e.client.Exists(groupDN)
|
||||
if err != nil {
|
||||
out.fail(fmt.Errorf("checking group existence: %w", err))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
if err := e.client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil {
|
||||
out.fail(fmt.Errorf("creating group: %w", err))
|
||||
return
|
||||
}
|
||||
out.Details["groupCreated"] = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.client.AddGroupMember(groupDN, memberDN); err != nil {
|
||||
out.fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
out.Details["groupDn"] = groupDN
|
||||
out.Details["memberDn"] = memberDN
|
||||
out.Success = true
|
||||
}
|
||||
|
||||
func (e *actionExecutor) doEnsureGroupExists(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context) {
|
||||
groupDN, err := e.resolveGroupDN(cfg, ctx)
|
||||
if err != nil {
|
||||
out.fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := e.client.Exists(groupDN)
|
||||
if err != nil {
|
||||
out.fail(fmt.Errorf("checking group existence: %w", err))
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
out.Details["groupExisted"] = true
|
||||
out.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil {
|
||||
out.fail(fmt.Errorf("creating group: %w", err))
|
||||
return
|
||||
}
|
||||
out.Details["groupCreated"] = true
|
||||
out.Details["groupDn"] = groupDN
|
||||
out.Success = true
|
||||
}
|
||||
|
||||
func (e *actionExecutor) doMoveToOU(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context, objectDN string) {
|
||||
targetOU, err := e.expand(cfg.TargetOU, ctx)
|
||||
if err != nil {
|
||||
out.fail(fmt.Errorf("expanding target OU: %w", err))
|
||||
return
|
||||
}
|
||||
if targetOU == "" {
|
||||
out.fail(fmt.Errorf("targetOu is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.CreateOUIfMissing {
|
||||
exists, err := e.client.Exists(targetOU)
|
||||
if err != nil {
|
||||
out.fail(fmt.Errorf("checking OU existence: %w", err))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
if err := e.client.CreateOU(targetOU); err != nil {
|
||||
out.fail(fmt.Errorf("creating OU: %w", err))
|
||||
return
|
||||
}
|
||||
out.Details["ouCreated"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// If object is already in the target OU, nothing to do.
|
||||
if parentDN(objectDN) == targetOU {
|
||||
out.Details["alreadyInTarget"] = true
|
||||
out.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.client.MoveObject(objectDN, targetOU); err != nil {
|
||||
out.fail(err)
|
||||
return
|
||||
}
|
||||
out.Details["fromDn"] = objectDN
|
||||
out.Details["toOu"] = targetOU
|
||||
out.Success = true
|
||||
}
|
||||
|
||||
func (e *actionExecutor) doRemoveFromGroup(out *actionOutcome, cfg models.ActionConfig, ctx *variables.Context, memberDN string) {
|
||||
groupDN, err := e.resolveGroupDN(cfg, ctx)
|
||||
if err != nil {
|
||||
out.fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.client.RemoveGroupMember(groupDN, memberDN); err != nil {
|
||||
out.fail(err)
|
||||
return
|
||||
}
|
||||
out.Details["groupDn"] = groupDN
|
||||
out.Details["memberDn"] = memberDN
|
||||
out.Success = true
|
||||
}
|
||||
|
||||
func (e *actionExecutor) resolveGroupDN(cfg models.ActionConfig, ctx *variables.Context) (string, error) {
|
||||
if cfg.UseDynamicPath && cfg.PathTemplate != "" {
|
||||
return e.expand(cfg.PathTemplate, ctx)
|
||||
}
|
||||
if cfg.TargetGroupDN == "" {
|
||||
return "", fmt.Errorf("targetGroupDn is required")
|
||||
}
|
||||
return e.expand(cfg.TargetGroupDN, ctx)
|
||||
}
|
||||
|
||||
func (e *actionExecutor) expand(template string, ctx *variables.Context) (string, error) {
|
||||
if template == "" {
|
||||
return "", nil
|
||||
}
|
||||
return e.expander.Expand(template, ctx)
|
||||
}
|
||||
|
||||
func parseActionConfig(raw string) (models.ActionConfig, error) {
|
||||
var cfg models.ActionConfig
|
||||
if raw == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parentDN(dn string) string {
|
||||
idx := strings.Index(dn, ",")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(dn[idx+1:])
|
||||
}
|
||||
@@ -40,12 +40,30 @@ type ExecutionResult struct {
|
||||
|
||||
// ActionResult contains the result of a single action
|
||||
type ActionResult struct {
|
||||
ActionID string
|
||||
ActionType string
|
||||
ObjectDN string
|
||||
Success bool
|
||||
Error string
|
||||
Duration time.Duration
|
||||
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
|
||||
@@ -59,52 +77,45 @@ type PreviewResult struct {
|
||||
|
||||
// MatchedObject represents an AD object that matched rule conditions
|
||||
type MatchedObject struct {
|
||||
DN string
|
||||
ObjectType string
|
||||
Attributes map[string][]string
|
||||
DN 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
|
||||
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, client *ldap.Client) (*PreviewResult, error) {
|
||||
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,
|
||||
}
|
||||
|
||||
// Build LDAP filter from conditions
|
||||
conditionGroups := buildConditionGroups(rule)
|
||||
filter := ldap.BuildRuleFilter(types.ObjectType(rule.ObjectType), conditionGroups, types.JoinOperator(rule.GroupJoinOperator))
|
||||
result.GeneratedFilter = filter
|
||||
|
||||
// Determine base DN
|
||||
baseDN := rule.BaseDNOverride
|
||||
if baseDN == nil || *baseDN == "" {
|
||||
// Would get from connection config
|
||||
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")
|
||||
}
|
||||
|
||||
// Search for matching objects
|
||||
entries, err := client.Search(
|
||||
getBaseDN(rule),
|
||||
getSearchScope(rule),
|
||||
filter,
|
||||
[]string{"dn", "cn", "objectClass", "sAMAccountName"},
|
||||
)
|
||||
entries, err := client.Search(baseDN, resolveScope(rule, conn), filter, collectAttributes(rule))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert entries to matched objects
|
||||
for _, entry := range entries {
|
||||
matched := MatchedObject{
|
||||
DN: entry.DN,
|
||||
@@ -125,6 +136,121 @@ func (e *Engine) Preview(ctx context.Context, rule *models.Rule, client *ldap.Cl
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
for i := range rule.Actions {
|
||||
action := &rule.Actions[i]
|
||||
if !action.IsEnabled {
|
||||
continue
|
||||
}
|
||||
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++
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -177,18 +303,24 @@ func (e *Engine) planActions(rule *models.Rule, objects []MatchedObject) []Plann
|
||||
return planned
|
||||
}
|
||||
|
||||
func getBaseDN(rule *models.Rule) string {
|
||||
func resolveBaseDN(rule *models.Rule, conn *models.ADConnection) string {
|
||||
if rule.BaseDNOverride != nil && *rule.BaseDNOverride != "" {
|
||||
return *rule.BaseDNOverride
|
||||
}
|
||||
return "DC=example,DC=com" // Placeholder
|
||||
if conn != nil {
|
||||
return conn.RootDN
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getSearchScope(rule *models.Rule) int {
|
||||
scope := "Subtree"
|
||||
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
|
||||
@@ -198,3 +330,29 @@ func getSearchScope(rule *models.Rule) int {
|
||||
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,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package runner coordinates end-to-end rule execution.
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/rules/engine"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/services"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/types"
|
||||
)
|
||||
|
||||
// Runner loads rules and their dependencies, executes them via the engine,
|
||||
// and records the resulting run and action histories.
|
||||
type Runner struct {
|
||||
ruleRepo *repository.RuleRepository
|
||||
connRepo *repository.ConnectionRepository
|
||||
runRepo *repository.RuleRunRepository
|
||||
connService *services.ConnectionService
|
||||
engine *engine.Engine
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// New creates a new Runner
|
||||
func New(db *sql.DB, connService *services.ConnectionService, logger *logging.Logger) *Runner {
|
||||
return &Runner{
|
||||
ruleRepo: repository.NewRuleRepository(db),
|
||||
connRepo: repository.NewConnectionRepository(db),
|
||||
runRepo: repository.NewRuleRunRepository(db),
|
||||
connService: connService,
|
||||
engine: engine.NewEngine(logger),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RunRule executes the rule identified by ruleID. triggeredBy is a free-form
|
||||
// label stored on the run record (e.g. "scheduler", "user:<id>", "api").
|
||||
func (r *Runner) RunRule(ctx context.Context, ruleID string, triggeredBy string) error {
|
||||
rule, err := r.ruleRepo.GetByID(ruleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading rule: %w", err)
|
||||
}
|
||||
if rule == nil {
|
||||
return fmt.Errorf("rule not found: %s", ruleID)
|
||||
}
|
||||
if !rule.IsEnabled {
|
||||
r.logger.Info("Runner", "Rule '%s' is disabled, skipping", rule.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := r.connRepo.GetByID(rule.ADConnectionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading AD connection: %w", err)
|
||||
}
|
||||
if conn == nil {
|
||||
return fmt.Errorf("AD connection not found: %s", rule.ADConnectionID)
|
||||
}
|
||||
|
||||
run := &models.RuleRun{
|
||||
RuleID: rule.ID,
|
||||
Status: string(types.RunStatusRunning),
|
||||
StartedUTC: time.Now().UTC(),
|
||||
ExecutionMode: rule.ExecutionMode,
|
||||
}
|
||||
if triggeredBy != "" {
|
||||
tb := triggeredBy
|
||||
run.TriggeredBy = &tb
|
||||
}
|
||||
if err := r.runRepo.Create(run); err != nil {
|
||||
return fmt.Errorf("creating rule run: %w", err)
|
||||
}
|
||||
|
||||
client, err := r.connService.BuildClient(conn)
|
||||
if err != nil {
|
||||
r.failRun(run, fmt.Sprintf("LDAP connection failed: %v", err))
|
||||
return fmt.Errorf("building LDAP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
result := r.engine.Execute(ctx, rule, conn, client)
|
||||
|
||||
for _, ar := range result.ActionResults {
|
||||
r.persistActionRecord(run.ID, ar)
|
||||
}
|
||||
|
||||
r.finalizeRun(run, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) persistActionRecord(runID string, ar engine.ActionResult) {
|
||||
status := "Succeeded"
|
||||
if !ar.Success {
|
||||
status = "Failed"
|
||||
}
|
||||
rec := &models.RuleRunAction{
|
||||
RuleRunID: runID,
|
||||
RuleActionID: ar.ActionID,
|
||||
ObjectDN: ar.ObjectDN,
|
||||
ActionType: ar.ActionType,
|
||||
Status: status,
|
||||
}
|
||||
if len(ar.Details) > 0 {
|
||||
if b, err := json.Marshal(ar.Details); err == nil {
|
||||
s := string(b)
|
||||
rec.DetailsJSON = &s
|
||||
}
|
||||
}
|
||||
if ar.Error != "" {
|
||||
em := ar.Error
|
||||
rec.ErrorMessage = &em
|
||||
}
|
||||
dur := int(ar.Duration.Milliseconds())
|
||||
rec.DurationMS = &dur
|
||||
|
||||
if err := r.runRepo.CreateAction(rec); err != nil {
|
||||
r.logger.Warn("Runner", "Failed to persist action record for run %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) finalizeRun(run *models.RuleRun, result *engine.ExecutionResult) {
|
||||
completed := result.CompletedAt
|
||||
dur := int(result.Duration.Milliseconds())
|
||||
run.Status = string(result.Status)
|
||||
run.CompletedUTC = &completed
|
||||
run.DurationMS = &dur
|
||||
run.ObjectsMatched = result.ObjectsMatched
|
||||
run.ObjectsProcessed = result.ObjectsProcessed
|
||||
run.ActionsExecuted = result.ActionsExecuted
|
||||
run.ActionsFailed = result.ActionsFailed
|
||||
if len(result.Errors) > 0 {
|
||||
msg := strings.Join(result.Errors, "; ")
|
||||
run.ErrorMessage = &msg
|
||||
}
|
||||
if err := r.runRepo.Update(run); err != nil {
|
||||
r.logger.Warn("Runner", "Failed to update rule run %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) failRun(run *models.RuleRun, msg string) {
|
||||
now := time.Now().UTC()
|
||||
dur := int(now.Sub(run.StartedUTC).Milliseconds())
|
||||
run.Status = string(types.RunStatusFailed)
|
||||
run.CompletedUTC = &now
|
||||
run.DurationMS = &dur
|
||||
run.ErrorMessage = &msg
|
||||
if err := r.runRepo.Update(run); err != nil {
|
||||
r.logger.Warn("Runner", "Failed to update failed rule run %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,17 @@ import (
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// RuleRunner executes a rule identified by ID. The scheduler uses this
|
||||
// abstraction to avoid importing the runner package directly.
|
||||
type RuleRunner interface {
|
||||
RunRule(ctx context.Context, ruleID string, triggeredBy string) error
|
||||
}
|
||||
|
||||
// Scheduler manages scheduled rule execution
|
||||
type Scheduler struct {
|
||||
db *sql.DB
|
||||
logger *logging.Logger
|
||||
runner RuleRunner
|
||||
cron *cron.Cron
|
||||
mu sync.RWMutex
|
||||
entries map[string]cron.EntryID
|
||||
@@ -23,8 +30,9 @@ type Scheduler struct {
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
// New creates a new Scheduler
|
||||
func New(db *sql.DB, logger *logging.Logger) *Scheduler {
|
||||
// New creates a new Scheduler. A non-nil runner is required to actually
|
||||
// execute scheduled rules; if nil, triggered schedules will log and no-op.
|
||||
func New(db *sql.DB, runner RuleRunner, logger *logging.Logger) *Scheduler {
|
||||
// Use six-field cron parser (with seconds)
|
||||
parser := cron.NewParser(
|
||||
cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow,
|
||||
@@ -34,6 +42,7 @@ func New(db *sql.DB, logger *logging.Logger) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: db,
|
||||
logger: logger,
|
||||
runner: runner,
|
||||
cron: c,
|
||||
entries: make(map[string]cron.EntryID),
|
||||
stopChan: make(chan struct{}),
|
||||
@@ -189,6 +198,14 @@ func (s *Scheduler) buildCronExpression(cronExpr sql.NullString, easyValue sql.N
|
||||
func (s *Scheduler) createRuleRunner(ruleID, ruleName string) func() {
|
||||
return func() {
|
||||
s.logger.Info("Scheduler", "Executing scheduled rule '%s' (ID: %s)", ruleName, ruleID)
|
||||
// TODO: Call rule executor
|
||||
if s.runner == nil {
|
||||
s.logger.Warn("Scheduler", "No runner configured; skipping execution of '%s'", ruleName)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := s.runner.RunRule(ctx, ruleID, "scheduler"); err != nil {
|
||||
s.logger.Error("Scheduler", "Rule '%s' failed: %v", ruleName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
|
||||
// ConnectionService handles AD connection operations
|
||||
type ConnectionService struct {
|
||||
connRepo *repository.ConnectionRepository
|
||||
credRepo *repository.CredentialRepository
|
||||
encryptor *crypto.Encryptor
|
||||
logger *logging.Logger
|
||||
connRepo *repository.ConnectionRepository
|
||||
credRepo *repository.CredentialRepository
|
||||
encryptor *crypto.Encryptor
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewConnectionService creates a new ConnectionService
|
||||
@@ -55,7 +55,7 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu
|
||||
}
|
||||
|
||||
// Build LDAP config
|
||||
config, err := s.buildLDAPConfig(conn)
|
||||
config, err := s.BuildLDAPConfig(conn)
|
||||
if err != nil {
|
||||
result.Message = fmt.Sprintf("Configuration error: %v", err)
|
||||
return result, nil
|
||||
@@ -67,14 +67,14 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu
|
||||
start := time.Now()
|
||||
err = client.Connect()
|
||||
connectLatency := time.Since(start)
|
||||
|
||||
|
||||
result.TestDetails = append(result.TestDetails, TestDetail{
|
||||
Step: "TCP Connection",
|
||||
Success: err == nil,
|
||||
Message: errorMessage(err),
|
||||
Latency: connectLatency,
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
result.Message = "Connection failed"
|
||||
return result, nil
|
||||
@@ -85,14 +85,14 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu
|
||||
start = time.Now()
|
||||
err = client.Bind()
|
||||
bindLatency := time.Since(start)
|
||||
|
||||
|
||||
result.TestDetails = append(result.TestDetails, TestDetail{
|
||||
Step: "LDAP Bind",
|
||||
Success: err == nil,
|
||||
Message: errorMessage(err),
|
||||
Latency: bindLatency,
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
result.Message = "Authentication failed"
|
||||
return result, nil
|
||||
@@ -102,14 +102,14 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu
|
||||
start = time.Now()
|
||||
err = testRootDN(client, conn.RootDN)
|
||||
rootLatency := time.Since(start)
|
||||
|
||||
|
||||
result.TestDetails = append(result.TestDetails, TestDetail{
|
||||
Step: "Root DN Access",
|
||||
Success: err == nil,
|
||||
Message: errorMessage(err),
|
||||
Latency: rootLatency,
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
result.Message = "Root DN not accessible"
|
||||
return result, nil
|
||||
@@ -120,7 +120,28 @@ func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResu
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ConnectionService) buildLDAPConfig(conn *models.ADConnection) (*ldap.Config, error) {
|
||||
// BuildClient returns a connected and bound LDAP client for the given connection.
|
||||
// Caller is responsible for calling Close() on the returned client.
|
||||
func (s *ConnectionService) BuildClient(conn *models.ADConnection) (*ldap.Client, error) {
|
||||
config, err := s.BuildLDAPConfig(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := ldap.NewClient(config)
|
||||
if err := client.Connect(); err != nil {
|
||||
return nil, fmt.Errorf("connect failed: %w", err)
|
||||
}
|
||||
if err := client.Bind(); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("bind failed: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// BuildLDAPConfig constructs an LDAP config from a connection model and
|
||||
// resolves any referenced credential.
|
||||
func (s *ConnectionService) BuildLDAPConfig(conn *models.ADConnection) (*ldap.Config, error) {
|
||||
hosts := strings.Split(conn.Hosts, ",")
|
||||
for i := range hosts {
|
||||
hosts[i] = strings.TrimSpace(hosts[i])
|
||||
|
||||
Reference in New Issue
Block a user