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>
234 lines
7.3 KiB
Go
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
|
|
}
|