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

234 lines
7.3 KiB
Go

// Package engine - dynamic-group membership reconciliation.
//
// SyncGroupMembership is a set-level action: unlike per-object actions it runs
// once per target group with the entire matched set, diffs it against the
// group's current membership, and issues the adds/removes needed to make the
// group reflect the rule. This is the behaviour that turns a rule into an
// Adaxes/Active-Roles style dynamic group.
package engine
import (
"fmt"
"strings"
"github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
)
// membershipClient is the slice of the LDAP client that reconciliation needs.
// Declaring it as an interface keeps the reconciler unit-testable with a fake.
type membershipClient interface {
Exists(dn string) (bool, error)
GetGroupMembers(groupDN string) ([]string, error)
AddGroupMember(groupDN, memberDN string) error
RemoveGroupMember(groupDN, memberDN string) error
CreateGroup(groupDN, groupType, groupScope string) error
EnsureOUPath(ouDN string) error
}
// ManagedMemberStore records which memberships this rule added, so ManagedAdd
// mode can remove only what it added. Implementations are backed by the DB; a
// nil store disables managed-removal (it degrades to add-only removal).
type ManagedMemberStore interface {
List(ruleID, groupDN string) ([]string, error)
Add(ruleID, groupDN, memberDN string) error
Remove(ruleID, groupDN, memberDN string) error
}
// membershipPlan is the computed diff for one target group.
type membershipPlan struct {
GroupDN string
GroupCreated bool
ToAdd []string // original-cased member DNs to add
ToRemove []string // original-cased member DNs to remove
Unchanged int
Err error
}
// normDN returns the case/space-normalised key used to compare DNs. AD returns
// member DNs and search DNs in a consistent form, but comparing case-folded is
// safe and avoids spurious add/remove churn.
func normDN(dn string) string {
return strings.ToLower(strings.TrimSpace(dn))
}
// planMembership resolves the target group and computes the add/remove diff
// against the matched DN set without mutating anything. It is shared by preview
// (counts only) and execution (which then applies the plan).
func planMembership(action *models.RuleAction, rule *models.Rule, matchedDNs []string,
client membershipClient, store ManagedMemberStore, allowCreate bool) membershipPlan {
cfg, err := parseActionConfig(action.ConfigurationJSON)
if err != nil {
return membershipPlan{Err: fmt.Errorf("invalid action configuration: %w", err)}
}
groupDN := ldap.NormalizeGroupTarget(cfg.TargetGroupDN)
if groupDN == "" {
return membershipPlan{Err: fmt.Errorf("targetGroupDn is required for SyncGroupMembership")}
}
plan := membershipPlan{GroupDN: groupDN}
exists, err := client.Exists(groupDN)
if err != nil {
plan.Err = fmt.Errorf("checking group existence: %w", err)
return plan
}
if !exists {
if !cfg.CreateIfMissing {
plan.Err = fmt.Errorf("target group does not exist: %s", groupDN)
return plan
}
// Group would be created; its current membership is empty.
plan.GroupCreated = true
if allowCreate {
if err := client.EnsureOUPath(parentDN(groupDN)); err != nil {
plan.Err = fmt.Errorf("ensuring group OU path: %w", err)
return plan
}
if err := client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil {
plan.Err = fmt.Errorf("creating group: %w", err)
return plan
}
}
}
// Current members (empty when the group was, or would be, freshly created).
var current []string
if !plan.GroupCreated {
current, err = client.GetGroupMembers(groupDN)
if err != nil {
plan.Err = fmt.Errorf("reading group members: %w", err)
return plan
}
}
matchedByKey := make(map[string]string, len(matchedDNs))
for _, dn := range matchedDNs {
matchedByKey[normDN(dn)] = dn
}
currentByKey := make(map[string]string, len(current))
for _, dn := range current {
currentByKey[normDN(dn)] = dn
}
// Additions: matched objects not currently in the group.
for key, dn := range matchedByKey {
if _, ok := currentByKey[key]; ok {
plan.Unchanged++
} else {
plan.ToAdd = append(plan.ToAdd, dn)
}
}
// Removals depend on the sync mode.
mode := types.SyncMode(cfg.SyncMode)
if mode == "" {
mode = types.SyncModeFull
}
switch mode {
case types.SyncModeFull:
for key, dn := range currentByKey {
if _, ok := matchedByKey[key]; !ok {
plan.ToRemove = append(plan.ToRemove, dn)
}
}
case types.SyncModeManaged:
if store != nil {
managed, err := store.List(rule.ID, groupDN)
if err != nil {
plan.Err = fmt.Errorf("reading managed members: %w", err)
return plan
}
managedKeys := make(map[string]bool, len(managed))
for _, dn := range managed {
managedKeys[normDN(dn)] = true
}
for key, dn := range currentByKey {
if managedKeys[key] {
if _, ok := matchedByKey[key]; !ok {
plan.ToRemove = append(plan.ToRemove, dn)
}
}
}
}
case types.SyncModeAddOnly:
// never remove
}
return plan
}
// reconcileMembership applies a SyncGroupMembership action and returns one
// ActionResult per membership change (plus a group-created result when
// relevant). Adds are reported as AddToGroup and removes as
// RemoveFromGroupIfNoLongerMatched so the activity feed categorises them as
// syncs and removals.
func (e *Engine) reconcileMembership(action *models.RuleAction, rule *models.Rule,
matchedDNs []string, client membershipClient, store ManagedMemberStore) []ActionResult {
plan := planMembership(action, rule, matchedDNs, client, store, true)
if plan.Err != nil {
return []ActionResult{{
ActionID: action.ID,
ActionType: string(types.ActionSyncGroupMembership),
ObjectDN: plan.GroupDN,
Success: false,
Error: plan.Err.Error(),
Details: map[string]any{"targetGroupDn": plan.GroupDN},
}}
}
var results []ActionResult
if plan.GroupCreated {
results = append(results, ActionResult{
ActionID: action.ID,
ActionType: string(types.ActionEnsureGroupExists),
ObjectDN: plan.GroupDN,
Success: true,
Details: map[string]any{"groupDn": plan.GroupDN, "groupCreated": true},
})
}
for _, memberDN := range plan.ToAdd {
res := ActionResult{
ActionID: action.ID,
ActionType: string(types.ActionAddToGroup),
ObjectDN: memberDN,
Details: map[string]any{"groupDn": plan.GroupDN, "memberDn": memberDN, "reason": "matched"},
}
if err := client.AddGroupMember(plan.GroupDN, memberDN); err != nil {
res.Success = false
res.Error = err.Error()
} else {
res.Success = true
if store != nil {
_ = store.Add(rule.ID, plan.GroupDN, memberDN)
}
}
results = append(results, res)
}
for _, memberDN := range plan.ToRemove {
res := ActionResult{
ActionID: action.ID,
ActionType: string(types.ActionRemoveFromGroupIfNoMatch),
ObjectDN: memberDN,
Details: map[string]any{"groupDn": plan.GroupDN, "memberDn": memberDN, "reason": "noLongerMatched"},
}
if err := client.RemoveGroupMember(plan.GroupDN, memberDN); err != nil {
res.Success = false
res.Error = err.Error()
} else {
res.Success = true
if store != nil {
_ = store.Remove(rule.ID, plan.GroupDN, memberDN)
}
}
results = append(results, res)
}
return results
}