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

626 lines
23 KiB
Go

// Package api - Rules handlers
package api
import (
"net/http"
"strings"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/rules/engine"
"github.com/Grace-Solutions/OrchestrAD/internal/rules/runner"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
"github.com/go-chi/chi/v5"
)
// RulesHandler handles rule execution and CRUD API endpoints
type RulesHandler struct {
runner *runner.Runner
service *services.RuleService
auditService *audit.Service
logger *logging.Logger
}
// NewRulesHandler creates a new RulesHandler
func NewRulesHandler(r *runner.Runner, svc *services.RuleService, auditService *audit.Service, logger *logging.Logger) *RulesHandler {
return &RulesHandler{runner: r, service: svc, auditService: auditService, logger: logger}
}
// PreviewResponse is the JSON shape returned by the preview endpoint
type PreviewResponse struct {
RuleID string `json:"ruleId"`
GeneratedFilter string `json:"generatedFilter"`
Warnings []string `json:"warnings,omitempty"`
MatchedObjects []previewMatchedObject `json:"matchedObjects"`
PlannedActions []previewPlannedAction `json:"plannedActions"`
}
type previewMatchedObject struct {
DN string `json:"dn"`
CanonicalName string `json:"canonicalName,omitempty"`
ObjectType string `json:"objectType"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
type previewPlannedAction struct {
ActionID string `json:"actionId"`
ActionType string `json:"actionType"`
TargetDN string `json:"targetDn"`
Description string `json:"description"`
IsChange bool `json:"isChange"`
}
// RunResponse is the JSON shape returned by the run endpoint
type RunResponse struct {
RuleID string `json:"ruleId"`
Status string `json:"status"`
TriggeredBy string `json:"triggeredBy"`
}
// Preview handles POST /api/v1/rules/{id}/preview
func (h *RulesHandler) Preview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "rule id is required")
return
}
result, err := h.runner.PreviewRule(r.Context(), id)
if err != nil {
h.logger.Error("RulesHandler", "Preview failed for rule %s: %v", id, err)
if isNotFound(err) {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, err.Error())
return
}
WriteErrorWithDetails(w, http.StatusInternalServerError, ErrCodeInternalError, "preview failed", err.Error())
return
}
WriteJSON(w, http.StatusOK, toPreviewResponse(result))
}
// PreviewSpec handles POST /api/v1/rules/preview — a preview of an unsaved
// rule draft (the editor's live "matching objects" panel).
func (h *RulesHandler) PreviewSpec(w http.ResponseWriter, r *http.Request) {
var req RuleRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
if req.ADConnectionID == "" || req.ObjectType == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "adConnectionId and objectType are required")
return
}
rule := &models.Rule{
ID: "preview",
Name: req.Name,
ADConnectionID: req.ADConnectionID,
ObjectType: req.ObjectType,
BaseDNOverride: req.BaseDNOverride,
SearchScopeOverride: req.SearchScopeOverride,
GroupJoinOperator: req.GroupJoinOperator,
ExecutionMode: string(types.ExecutionModePreviewOnly),
}
if req.ConditionGroups != nil {
rule.ConditionGroups = toModelGroups(*req.ConditionGroups)
}
if req.Actions != nil {
rule.Actions = toModelActions(*req.Actions)
}
result, err := h.runner.PreviewRuleSpec(r.Context(), rule)
if err != nil {
h.logger.Error("RulesHandler", "PreviewSpec failed: %v", err)
if isNotFound(err) {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, err.Error())
return
}
WriteErrorWithDetails(w, http.StatusInternalServerError, ErrCodeInternalError, "preview failed", err.Error())
return
}
WriteJSON(w, http.StatusOK, toPreviewResponse(result))
}
// Metadata handles GET /api/v1/rules/metadata — the operator-facing vocabulary
// the editor needs (object types, operators, action types, sync modes, and
// common attributes) so its dropdowns stay in lock-step with the backend.
func (h *RulesHandler) Metadata(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, ruleMetadata())
}
// Run handles POST /api/v1/rules/{id}/run
func (h *RulesHandler) Run(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "rule id is required")
return
}
triggeredBy := resolveTriggeredBy(r)
if err := h.runner.RunRule(r.Context(), id, triggeredBy); err != nil {
h.logger.Error("RulesHandler", "Run failed for rule %s: %v", id, err)
emitAudit(h.auditService, r, audit.EventRuleRun, "Rule", id, "Run", false,
map[string]any{"triggeredBy": triggeredBy}, err.Error())
if isNotFound(err) {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, err.Error())
return
}
WriteErrorWithDetails(w, http.StatusInternalServerError, ErrCodeInternalError, "rule execution failed", err.Error())
return
}
emitAudit(h.auditService, r, audit.EventRuleRun, "Rule", id, "Run", true,
map[string]any{"triggeredBy": triggeredBy}, "")
WriteJSON(w, http.StatusAccepted, RunResponse{
RuleID: id,
Status: "completed",
TriggeredBy: triggeredBy,
})
}
func toPreviewResponse(p *engine.PreviewResult) PreviewResponse {
resp := PreviewResponse{
RuleID: p.RuleID,
GeneratedFilter: p.GeneratedFilter,
Warnings: p.Warnings,
MatchedObjects: make([]previewMatchedObject, 0, len(p.MatchedObjects)),
PlannedActions: make([]previewPlannedAction, 0, len(p.PlannedActions)),
}
for _, m := range p.MatchedObjects {
resp.MatchedObjects = append(resp.MatchedObjects, previewMatchedObject{
DN: m.DN,
CanonicalName: m.CanonicalName,
ObjectType: m.ObjectType,
Attributes: m.Attributes,
})
}
for _, a := range p.PlannedActions {
resp.PlannedActions = append(resp.PlannedActions, previewPlannedAction{
ActionID: a.ActionID,
ActionType: a.ActionType,
TargetDN: a.TargetDN,
Description: a.Description,
IsChange: a.IsChange,
})
}
return resp
}
func isNotFound(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "not found")
}
func resolveTriggeredBy(r *http.Request) string {
if v := r.Header.Get("X-Triggered-By"); v != "" {
return v
}
return "api"
}
// RuleRequest represents a create/update rule payload. When conditionGroups or
// actions are present (non-nil), the rule's logic is replaced wholesale;
// omitting them leaves existing logic untouched (so legacy scalar-only callers
// keep working).
type RuleRequest struct {
Name string `json:"name"`
Description *string `json:"description,omitempty"`
IsEnabled *bool `json:"isEnabled,omitempty"`
ADConnectionID string `json:"adConnectionId"`
ObjectType string `json:"objectType"`
BaseDNOverride *string `json:"baseDnOverride,omitempty"`
SearchScopeOverride *string `json:"searchScopeOverride,omitempty"`
ScheduleID *string `json:"scheduleId,omitempty"`
ExecutionMode string `json:"executionMode"`
GroupJoinOperator string `json:"groupJoinOperator"`
MaxParallelism *int `json:"maxParallelism,omitempty"`
StopOnError *bool `json:"stopOnError,omitempty"`
ConditionGroups *[]ruleGroupRequest `json:"conditionGroups,omitempty"`
Actions *[]ruleActionRequest `json:"actions,omitempty"`
}
type ruleGroupRequest struct {
Name *string `json:"name,omitempty"`
IsEnabled *bool `json:"isEnabled,omitempty"`
JoinOperator string `json:"joinOperator"`
Negate bool `json:"negate"`
Conditions []ruleConditionRequest `json:"conditions"`
}
type ruleConditionRequest struct {
IsEnabled *bool `json:"isEnabled,omitempty"`
AttributeName string `json:"attributeName"`
Operator string `json:"operator"`
ValueType string `json:"valueType,omitempty"`
ComparisonValue *string `json:"comparisonValue,omitempty"`
CustomLdapExpression *string `json:"customLdapExpression,omitempty"`
Negate bool `json:"negate"`
CaseSensitive bool `json:"caseSensitive"`
}
type ruleActionRequest struct {
ActionType string `json:"actionType"`
IsEnabled *bool `json:"isEnabled,omitempty"`
ConfigurationJSON string `json:"configurationJson"`
RollbackMode *string `json:"rollbackMode,omitempty"`
}
func boolOrTrue(p *bool) bool {
if p == nil {
return true
}
return *p
}
func toModelGroups(reqs []ruleGroupRequest) []models.RuleConditionGroup {
groups := make([]models.RuleConditionGroup, 0, len(reqs))
for _, gr := range reqs {
g := models.RuleConditionGroup{
Name: gr.Name,
IsEnabled: boolOrTrue(gr.IsEnabled),
JoinOperator: gr.JoinOperator,
Negate: gr.Negate,
}
for _, cr := range gr.Conditions {
g.Conditions = append(g.Conditions, models.RuleCondition{
IsEnabled: boolOrTrue(cr.IsEnabled),
AttributeName: cr.AttributeName,
Operator: cr.Operator,
ValueType: cr.ValueType,
ComparisonValue: cr.ComparisonValue,
CustomLdapExpression: cr.CustomLdapExpression,
Negate: cr.Negate,
CaseSensitive: cr.CaseSensitive,
})
}
groups = append(groups, g)
}
return groups
}
func toModelActions(reqs []ruleActionRequest) []models.RuleAction {
actions := make([]models.RuleAction, 0, len(reqs))
for _, ar := range reqs {
actions = append(actions, models.RuleAction{
ActionType: ar.ActionType,
IsEnabled: boolOrTrue(ar.IsEnabled),
ConfigurationJSON: ar.ConfigurationJSON,
RollbackMode: ar.RollbackMode,
})
}
return actions
}
// applyLogic persists a rule's condition groups / actions when the request
// carried them, then returns the freshly re-loaded rule.
func (h *RulesHandler) applyLogic(req *RuleRequest, ruleID string) (*models.Rule, error) {
if req.ConditionGroups == nil && req.Actions == nil {
return h.service.GetByID(ruleID)
}
var groups []models.RuleConditionGroup
var actions []models.RuleAction
if req.ConditionGroups != nil {
groups = toModelGroups(*req.ConditionGroups)
}
if req.Actions != nil {
actions = toModelActions(*req.Actions)
}
if err := h.service.ReplaceLogic(ruleID, groups, actions); err != nil {
return nil, err
}
return h.service.GetByID(ruleID)
}
// RuleSummaryResponse is the list/summary projection of a rule.
type RuleSummaryResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
IsEnabled bool `json:"isEnabled"`
ObjectType string `json:"objectType"`
ScheduleID *string `json:"scheduleId,omitempty"`
LastRunUTC *string `json:"lastRunUtc,omitempty"`
LastRunResult *string `json:"lastRunResult,omitempty"`
ConditionCount int `json:"conditionCount"`
ActionCount int `json:"actionCount"`
}
// RuleDetailResponse is the full projection returned by Get.
type RuleDetailResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
IsEnabled bool `json:"isEnabled"`
ADConnectionID string `json:"adConnectionId"`
ObjectType string `json:"objectType"`
BaseDNOverride *string `json:"baseDnOverride,omitempty"`
SearchScopeOverride *string `json:"searchScopeOverride,omitempty"`
ScheduleID *string `json:"scheduleId,omitempty"`
ExecutionMode string `json:"executionMode"`
GroupJoinOperator string `json:"groupJoinOperator"`
MaxParallelism *int `json:"maxParallelism,omitempty"`
StopOnError bool `json:"stopOnError"`
LastRunUTC *string `json:"lastRunUtc,omitempty"`
LastRunResult *string `json:"lastRunResult,omitempty"`
ConditionGroups []ruleGroupResponse `json:"conditionGroups"`
Actions []ruleActionResponse `json:"actions"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type ruleGroupResponse struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
IsEnabled bool `json:"isEnabled"`
JoinOperator string `json:"joinOperator"`
SortOrder int `json:"sortOrder"`
Negate bool `json:"negate"`
Conditions []ruleConditionResponse `json:"conditions"`
}
type ruleConditionResponse struct {
ID string `json:"id"`
IsEnabled bool `json:"isEnabled"`
AttributeName string `json:"attributeName"`
Operator string `json:"operator"`
ValueType string `json:"valueType"`
ComparisonValue *string `json:"comparisonValue,omitempty"`
CustomLdapExpression *string `json:"customLdapExpression,omitempty"`
Negate bool `json:"negate"`
SortOrder int `json:"sortOrder"`
CaseSensitive bool `json:"caseSensitive"`
}
type ruleActionResponse struct {
ID string `json:"id"`
ActionType string `json:"actionType"`
IsEnabled bool `json:"isEnabled"`
SortOrder int `json:"sortOrder"`
ConfigurationJSON string `json:"configurationJson"`
RollbackMode *string `json:"rollbackMode,omitempty"`
}
// List handles GET /api/v1/rules
func (h *RulesHandler) List(w http.ResponseWriter, r *http.Request) {
p := ParsePagination(r)
items, total, err := h.service.List(p.Page, p.PageSize)
if err != nil {
h.logger.Error("RulesHandler", "List failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list rules")
return
}
resp := make([]RuleSummaryResponse, 0, len(items))
for _, it := range items {
resp = append(resp, RuleSummaryResponse{
ID: it.ID,
Name: it.Name,
Description: it.Description,
IsEnabled: it.IsEnabled,
ObjectType: it.ObjectType,
ScheduleID: it.ScheduleID,
LastRunUTC: it.LastRunUTC,
LastRunResult: it.LastRunResult,
ConditionCount: it.ConditionCount,
ActionCount: it.ActionCount,
})
}
WriteList(w, resp, p.Page, p.PageSize, total)
}
// Get handles GET /api/v1/rules/{id}
func (h *RulesHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
rule, err := h.service.GetByID(id)
if err != nil {
h.logger.Error("RulesHandler", "Get failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to get rule")
return
}
if rule == nil {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "Rule not found")
return
}
WriteJSON(w, http.StatusOK, ruleToDetailResponse(rule))
}
// Create handles POST /api/v1/rules
func (h *RulesHandler) Create(w http.ResponseWriter, r *http.Request) {
var req RuleRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
if req.Name == "" || req.ADConnectionID == "" || req.ObjectType == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "name, adConnectionId and objectType are required")
return
}
stopOnError := false
if req.StopOnError != nil {
stopOnError = *req.StopOnError
}
rule, err := h.service.Create(services.CreateRuleInput{
Name: req.Name,
Description: req.Description,
ADConnectionID: req.ADConnectionID,
ObjectType: req.ObjectType,
BaseDNOverride: req.BaseDNOverride,
SearchScopeOverride: req.SearchScopeOverride,
ScheduleID: req.ScheduleID,
ExecutionMode: req.ExecutionMode,
GroupJoinOperator: req.GroupJoinOperator,
MaxParallelism: req.MaxParallelism,
StopOnError: stopOnError,
})
if err != nil {
h.logger.Error("RulesHandler", "Create failed: %v", err)
emitAudit(h.auditService, r, audit.EventCreate, "Rule", "", "Create", false,
map[string]any{"name": req.Name}, err.Error())
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to create rule")
return
}
if withLogic, err := h.applyLogic(&req, rule.ID); err != nil {
h.logger.Error("RulesHandler", "Create: persisting rule logic failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to save rule filter/actions")
return
} else if withLogic != nil {
rule = withLogic
}
emitAudit(h.auditService, r, audit.EventCreate, "Rule", rule.ID, "Create", true,
map[string]any{"name": rule.Name, "objectType": rule.ObjectType}, "")
WriteJSON(w, http.StatusCreated, ruleToDetailResponse(rule))
}
// Update handles PUT /api/v1/rules/{id}
func (h *RulesHandler) Update(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req RuleRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
input := services.UpdateRuleInput{ID: id}
if req.Name != "" {
input.Name = &req.Name
}
input.Description = req.Description
input.IsEnabled = req.IsEnabled
if req.ADConnectionID != "" {
input.ADConnectionID = &req.ADConnectionID
}
if req.ObjectType != "" {
input.ObjectType = &req.ObjectType
}
input.BaseDNOverride = req.BaseDNOverride
input.SearchScopeOverride = req.SearchScopeOverride
input.ScheduleID = req.ScheduleID
if req.ExecutionMode != "" {
input.ExecutionMode = &req.ExecutionMode
}
if req.GroupJoinOperator != "" {
input.GroupJoinOperator = &req.GroupJoinOperator
}
input.MaxParallelism = req.MaxParallelism
input.StopOnError = req.StopOnError
rule, err := h.service.Update(input)
if err != nil {
h.logger.Error("RulesHandler", "Update failed: %v", err)
emitAudit(h.auditService, r, audit.EventUpdate, "Rule", id, "Update", false, nil, err.Error())
if isNotFound(err) {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, err.Error())
return
}
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to update rule")
return
}
if withLogic, err := h.applyLogic(&req, rule.ID); err != nil {
h.logger.Error("RulesHandler", "Update: persisting rule logic failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to save rule filter/actions")
return
} else if withLogic != nil {
rule = withLogic
}
emitAudit(h.auditService, r, audit.EventUpdate, "Rule", rule.ID, "Update", true,
map[string]any{"name": rule.Name}, "")
WriteJSON(w, http.StatusOK, ruleToDetailResponse(rule))
}
// Delete handles DELETE /api/v1/rules/{id}
func (h *RulesHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.service.Delete(id); err != nil {
h.logger.Error("RulesHandler", "Delete failed: %v", err)
emitAudit(h.auditService, r, audit.EventDelete, "Rule", id, "Delete", false, nil, err.Error())
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to delete rule")
return
}
emitAudit(h.auditService, r, audit.EventDelete, "Rule", id, "Delete", true, nil, "")
WriteJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// Enable handles POST /api/v1/rules/{id}/enable
func (h *RulesHandler) Enable(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.service.Enable(id); err != nil {
h.logger.Error("RulesHandler", "Enable failed: %v", err)
emitAudit(h.auditService, r, audit.EventUpdate, "Rule", id, "Enable", false, nil, err.Error())
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to enable rule")
return
}
emitAudit(h.auditService, r, audit.EventUpdate, "Rule", id, "Enable", true,
map[string]any{"isEnabled": true}, "")
WriteJSON(w, http.StatusOK, map[string]bool{"enabled": true})
}
// Disable handles POST /api/v1/rules/{id}/disable
func (h *RulesHandler) Disable(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.service.Disable(id); err != nil {
h.logger.Error("RulesHandler", "Disable failed: %v", err)
emitAudit(h.auditService, r, audit.EventUpdate, "Rule", id, "Disable", false, nil, err.Error())
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to disable rule")
return
}
emitAudit(h.auditService, r, audit.EventUpdate, "Rule", id, "Disable", true,
map[string]any{"isEnabled": false}, "")
WriteJSON(w, http.StatusOK, map[string]bool{"enabled": false})
}
func ruleToDetailResponse(r *models.Rule) RuleDetailResponse {
resp := RuleDetailResponse{
ID: r.ID,
Name: r.Name,
Description: r.Description,
IsEnabled: r.IsEnabled,
ADConnectionID: r.ADConnectionID,
ObjectType: r.ObjectType,
BaseDNOverride: r.BaseDNOverride,
SearchScopeOverride: r.SearchScopeOverride,
ScheduleID: r.ScheduleID,
ExecutionMode: r.ExecutionMode,
GroupJoinOperator: r.GroupJoinOperator,
MaxParallelism: r.MaxParallelism,
StopOnError: r.StopOnError,
LastRunResult: r.LastRunResult,
ConditionGroups: make([]ruleGroupResponse, 0, len(r.ConditionGroups)),
Actions: make([]ruleActionResponse, 0, len(r.Actions)),
CreatedAt: r.CreatedUTC.Format(time.RFC3339),
UpdatedAt: r.UpdatedUTC.Format(time.RFC3339),
}
if r.LastRunUTC != nil {
v := r.LastRunUTC.Format(time.RFC3339)
resp.LastRunUTC = &v
}
for _, g := range r.ConditionGroups {
gr := ruleGroupResponse{
ID: g.ID, Name: g.Name, IsEnabled: g.IsEnabled,
JoinOperator: g.JoinOperator, SortOrder: g.SortOrder, Negate: g.Negate,
Conditions: make([]ruleConditionResponse, 0, len(g.Conditions)),
}
for _, c := range g.Conditions {
gr.Conditions = append(gr.Conditions, ruleConditionResponse{
ID: c.ID, IsEnabled: c.IsEnabled, AttributeName: c.AttributeName,
Operator: c.Operator, ValueType: c.ValueType,
ComparisonValue: c.ComparisonValue, CustomLdapExpression: c.CustomLdapExpression,
Negate: c.Negate, SortOrder: c.SortOrder, CaseSensitive: c.CaseSensitive,
})
}
resp.ConditionGroups = append(resp.ConditionGroups, gr)
}
for _, a := range r.Actions {
resp.Actions = append(resp.Actions, ruleActionResponse{
ID: a.ID, ActionType: a.ActionType, IsEnabled: a.IsEnabled,
SortOrder: a.SortOrder, ConfigurationJSON: a.ConfigurationJSON,
RollbackMode: a.RollbackMode,
})
}
return resp
}