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>
626 lines
23 KiB
Go
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
|
|
}
|