7be92da2e6
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>
221 lines
5.7 KiB
Go
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:])
|
|
}
|