Files
Alphaeus Mote 7be92da2e6 feat(rules): canonical->DN targets and idempotent OU-path creation
Add CanonicalToOUDN/NormalizeOUTarget so an OU target can be given as a canonical
path (domain.com/OU/OU) or a DN, and Client.EnsureOUPath which idempotently
creates every OU down the path (parents before children). MoveToOu and group
creation now normalize the target and ensure the full OU path instead of only
the leaf. Unit-tested (conversion, escaped split) and verified against the test
AD: a canonical nested target creates each OU and moves the object; re-running is
a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 12:47:26 -04:00

221 lines
5.7 KiB
Go

// 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.EnsureOUPath(parentDN(groupDN)); err != nil {
out.fail(fmt.Errorf("ensuring group OU path: %w", err))
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
}
}
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.EnsureOUPath(parentDN(groupDN)); err != nil {
out.fail(fmt.Errorf("ensuring group OU path: %w", err))
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
}
// Accept either a DN or a canonical path (domain.com/OU/OU).
targetOU = ldap.NormalizeOUTarget(targetOU)
if targetOU == "" {
out.fail(fmt.Errorf("targetOu is required"))
return
}
if cfg.CreateOUIfMissing {
// Idempotently create every OU down the path, not just the leaf.
if err := e.client.EnsureOUPath(targetOU); err != nil {
out.fail(fmt.Errorf("ensuring OU path: %w", err))
return
}
}
// 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:])
}