Files
OrchestrAD/backend/internal/api/rules_metadata.go
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

124 lines
5.7 KiB
Go

// Package api - rule editor metadata (vocabulary served to the UI)
package api
// OperatorMeta describes a condition operator for the editor.
type OperatorMeta struct {
Value string `json:"value"`
Label string `json:"label"`
NeedsValue bool `json:"needsValue"` // false for Exists/NotExists
Custom bool `json:"custom,omitempty"` // true for raw LDAP expression
Hint string `json:"hint,omitempty"`
}
// LabeledValue is a generic {value,label,description} tuple for dropdowns.
type LabeledValue struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
// AttributeMeta is a suggested attribute for a given object type.
type AttributeMeta struct {
Name string `json:"name"`
Label string `json:"label"`
}
// RuleMetadata is the full vocabulary the rule editor renders from.
type RuleMetadata struct {
ObjectTypes []LabeledValue `json:"objectTypes"`
SearchScopes []LabeledValue `json:"searchScopes"`
JoinOperators []LabeledValue `json:"joinOperators"`
Operators []OperatorMeta `json:"operators"`
ActionTypes []LabeledValue `json:"actionTypes"`
SyncModes []LabeledValue `json:"syncModes"`
ScheduleUnits []LabeledValue `json:"scheduleUnits"`
CommonAttributes map[string][]AttributeMeta `json:"commonAttributes"`
}
func ruleMetadata() RuleMetadata {
return RuleMetadata{
ObjectTypes: []LabeledValue{
{Value: "User", Label: "Users"},
{Value: "Computer", Label: "Computers"},
{Value: "Group", Label: "Groups"},
},
SearchScopes: []LabeledValue{
{Value: "Subtree", Label: "This container and everything below"},
{Value: "OneLevel", Label: "Immediate children only"},
{Value: "Base", Label: "This object only"},
},
JoinOperators: []LabeledValue{
{Value: "AND", Label: "Match ALL of", Description: "Every condition must be true"},
{Value: "OR", Label: "Match ANY of", Description: "At least one condition must be true"},
},
Operators: []OperatorMeta{
{Value: "Equals", Label: "equals", NeedsValue: true},
{Value: "NotEquals", Label: "does not equal", NeedsValue: true},
{Value: "Contains", Label: "contains", NeedsValue: true},
{Value: "StartsWith", Label: "starts with", NeedsValue: true},
{Value: "EndsWith", Label: "ends with", NeedsValue: true},
{Value: "GreaterThan", Label: "is greater than or equal to", NeedsValue: true},
{Value: "LessThan", Label: "is less than or equal to", NeedsValue: true},
{Value: "Exists", Label: "is present", NeedsValue: false},
{Value: "NotExists", Label: "is not present", NeedsValue: false},
{Value: "MemberOf", Label: "is a member of (direct)", NeedsValue: true, Hint: "Group DN"},
{Value: "MemberOfRecursive", Label: "is a member of (incl. nested)", NeedsValue: true, Hint: "Group DN"},
{Value: "CustomLdap", Label: "raw LDAP filter", NeedsValue: true, Custom: true, Hint: "e.g. (department=Sales)"},
},
ActionTypes: []LabeledValue{
{Value: "SyncGroupMembership", Label: "Sync membership to group", Description: "Keep a target group's members in step with the matched set (add + remove)"},
{Value: "MoveToOu", Label: "Move to OU", Description: "Move each matched object into a target OU"},
{Value: "EnsureGroupExists", Label: "Ensure group exists", Description: "Create the target group if it is missing"},
{Value: "AddToGroup", Label: "Add to group (no removal)", Description: "Add matched objects to a group without ever removing anyone"},
},
SyncModes: []LabeledValue{
{Value: "FullSync", Label: "Full sync (add + remove)", Description: "Membership becomes exactly the matched set; members that no longer match are removed, including manual additions"},
{Value: "ManagedAdd", Label: "Managed (add + remove only what we added)", Description: "Add matches and remove only members this rule added; leaves manually-added members alone"},
{Value: "AddOnly", Label: "Add only", Description: "Only add matching members; never remove automatically"},
},
ScheduleUnits: []LabeledValue{
{Value: "Minutes", Label: "minutes"},
{Value: "Hours", Label: "hours"},
{Value: "Days", Label: "days"},
},
CommonAttributes: map[string][]AttributeMeta{
"User": {
{Name: "sAMAccountName", Label: "Logon name (sAMAccountName)"},
{Name: "userPrincipalName", Label: "User principal name (UPN)"},
{Name: "mail", Label: "Email"},
{Name: "displayName", Label: "Display name"},
{Name: "givenName", Label: "First name"},
{Name: "sn", Label: "Last name"},
{Name: "department", Label: "Department"},
{Name: "title", Label: "Job title"},
{Name: "company", Label: "Company"},
{Name: "physicalDeliveryOfficeName", Label: "Office"},
{Name: "l", Label: "City"},
{Name: "st", Label: "State/Province"},
{Name: "co", Label: "Country"},
{Name: "manager", Label: "Manager (DN)"},
{Name: "employeeType", Label: "Employee type"},
{Name: "description", Label: "Description"},
{Name: "memberOf", Label: "Member of (group DN)"},
{Name: "userAccountControl", Label: "Account control flags"},
},
"Computer": {
{Name: "cn", Label: "Name"},
{Name: "dNSHostName", Label: "DNS host name"},
{Name: "operatingSystem", Label: "Operating system"},
{Name: "operatingSystemVersion", Label: "OS version"},
{Name: "description", Label: "Description"},
{Name: "memberOf", Label: "Member of (group DN)"},
},
"Group": {
{Name: "cn", Label: "Name"},
{Name: "sAMAccountName", Label: "Group name (sAMAccountName)"},
{Name: "description", Label: "Description"},
{Name: "groupType", Label: "Group type flags"},
{Name: "mail", Label: "Email"},
{Name: "memberOf", Label: "Member of (group DN)"},
},
},
}
}