fix(posture): keep violation cycles consistent and audit new failures

Skip violation details on GetAllNodes, replace cycles transactionally, and
emit POSTURE_CHECK_FAILED for new CheckIDs (with one-time backfill).
This commit is contained in:
Abhishek Kondur
2026-09-08 12:21:33 +04:00
parent 8234ec09cb
commit 641f991f9c
12 changed files with 226 additions and 109 deletions
+1
View File
@@ -271,6 +271,7 @@ func joinHostToNetworks(ctx context.Context, key models.EnrollmentKey, host *sch
true,
)
if len(violations) > 0 {
logic.EmitNewPostureViolationEvents(ctx, nil, violations, models.PostureCheckDeviceInfo{HostID: host.ID.String()}, schema.NetworkID(network.Name))
logger.Log(0, fmt.Sprintf("skipping joining network %s due to violations", network.Name))
continue
}
+2
View File
@@ -538,6 +538,8 @@ func handleHostRegister(w http.ResponseWriter, r *http.Request) {
violations, _ := logic.CheckPostureViolationsForHost(r.Context(), &newHost, keyTags, schema.NetworkID(netI), true)
if len(violations) == 0 {
joinNetworks = append(joinNetworks, netI)
} else {
logic.EmitNewPostureViolationEvents(r.Context(), nil, violations, models.PostureCheckDeviceInfo{HostID: newHost.ID.String()}, schema.NetworkID(netI))
}
}
if len(joinNetworks) != len(enrollmentKey.Networks) && len(joinNetworks) == 0 {
+6 -2
View File
@@ -646,8 +646,10 @@ func createExtClient(w http.ResponseWriter, r *http.Request) {
if extclient.DeviceID != "" {
// check for violations connecting from desktop app
staticNode := models.ConvertToStaticNode(extclient)
violations, _ := logic.CheckPostureViolations(r.Context(), logic.GetPostureCheckDeviceInfoByNode(r.Context(), &staticNode), schema.NetworkID(extclient.Network))
deviceInfo := logic.GetPostureCheckDeviceInfoByNode(r.Context(), &staticNode)
violations, _ := logic.CheckPostureViolations(r.Context(), deviceInfo, schema.NetworkID(extclient.Network))
if len(violations) > 0 {
logic.EmitNewPostureViolationEvents(r.Context(), nil, violations, deviceInfo, schema.NetworkID(extclient.Network))
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("posture check violations"), logic.Forbidden))
return
}
@@ -927,8 +929,10 @@ func updateExtClient(w http.ResponseWriter, r *http.Request) {
if newclient.DeviceID != "" && newclient.Enabled {
// check for violations connecting from desktop app
staticNode := models.ConvertToStaticNode(newclient)
violations, _ := logic.CheckPostureViolations(r.Context(), logic.GetPostureCheckDeviceInfoByNode(r.Context(), &staticNode), schema.NetworkID(newclient.Network))
deviceInfo := logic.GetPostureCheckDeviceInfoByNode(r.Context(), &staticNode)
violations, _ := logic.CheckPostureViolations(r.Context(), deviceInfo, schema.NetworkID(newclient.Network))
if len(violations) > 0 {
logic.EmitNewPostureViolationEvents(r.Context(), nil, violations, deviceInfo, schema.NetworkID(newclient.Network))
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("posture check violations"), logic.Forbidden))
return
}
+7 -1
View File
@@ -679,7 +679,10 @@ func hostUpdateFallback(w http.ResponseWriter, r *http.Request) {
)
for _, _node := range _nodes {
node := logic.ConvertSchemaNodeToModelsNode(&_node)
node.PostureChecksViolations, node.PostureCheckViolationSeverityLevel = logic.CheckPostureViolations(ctx, logic.GetPostureCheckDeviceInfoByNode(ctx, node), schema.NetworkID(node.Network))
deviceInfo := logic.GetPostureCheckDeviceInfoByNode(ctx, node)
oldViolations := node.PostureChecksViolations
node.PostureChecksViolations, node.PostureCheckViolationSeverityLevel = logic.CheckPostureViolations(ctx, deviceInfo, schema.NetworkID(node.Network))
logic.EmitNewPostureViolationEvents(ctx, oldViolations, node.PostureChecksViolations, deviceInfo, schema.NetworkID(node.Network))
_node.PostureCheckSeverity = node.PostureCheckViolationSeverityLevel
_node.PostureCheckLastEvaluationCycleID = uuid.NewString()
_node.PostureCheckLastEvaluatedAt = time.Now().UTC()
@@ -1011,6 +1014,7 @@ func addHostToNetwork(w http.ResponseWriter, r *http.Request) {
violations, _ := logic.CheckPostureViolationsForHost(r.Context(), host, nil, schema.NetworkID(networkID), true)
if len(violations) > 0 {
logic.EmitNewPostureViolationEvents(r.Context(), nil, violations, models.PostureCheckDeviceInfo{HostID: host.ID.String()}, schema.NetworkID(networkID))
logic.ReturnErrorResponseWithJson(w, r, violations, logic.FormatError(errors.New("posture check violations"), logic.BadReq))
return
}
@@ -1913,6 +1917,7 @@ func approvePendingHost(w http.ResponseWriter, r *http.Request) {
violations, _ := logic.CheckPostureViolationsForHost(r.Context(), host, keyTags, schema.NetworkID(network.Name), true)
if len(violations) > 0 {
logic.EmitNewPostureViolationEvents(r.Context(), nil, violations, models.PostureCheckDeviceInfo{HostID: host.ID.String()}, schema.NetworkID(network.Name))
err = fmt.Errorf("failed to approve pending host (%s): posture check violations", id)
logger.Log(0, err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
@@ -1981,6 +1986,7 @@ func addDefaultHostToNetworks(ctx context.Context, host *schema.Host) {
violations, _ := logic.CheckPostureViolationsForHost(ctx, host, make(map[models.TagID]struct{}), schema.NetworkID(network.Name), true)
if len(violations) > 0 {
logic.EmitNewPostureViolationEvents(ctx, nil, violations, models.PostureCheckDeviceInfo{HostID: host.ID.String()}, schema.NetworkID(network.Name))
logger.Log(2, "skipping network", network.Name, "for default host", host.Name, ": posture check violations")
continue
}
+5
View File
@@ -44,6 +44,11 @@ var CheckPostureViolations = func(ctx context.Context, d models.PostureCheckDevi
return []models.Violation{}, schema.SeverityUnknown
}
// EmitNewPostureViolationEvents records audit events for newly observed posture
// failures. No-op in community; wired by pro.
var EmitNewPostureViolationEvents = func(ctx context.Context, oldVi, newVi []models.Violation, d models.PostureCheckDeviceInfo, network schema.NetworkID) {
}
var CheckPostureViolationsForHost = func(ctx context.Context, host *schema.Host, tags map[models.TagID]struct{}, network schema.NetworkID, skipAutoUpdate bool) ([]models.Violation, schema.Severity) {
if host == nil {
return []models.Violation{}, schema.SeverityUnknown
+36 -8
View File
@@ -328,8 +328,23 @@ func DeleteNodeByID(ctx context.Context, node *models.Node) error {
return nil
}
// GetAllNodes - returns all nodes in the DB
// GetAllNodes - returns all nodes in the DB.
// List/API responses use posture severity/cycle fields already on the node row
// (written by the background posture hook). Full violation details are omitted
// here — use GetAllNodesWithViolations or single-node convert / the dedicated
// violations API when the detail array is required.
func GetAllNodes(ctx context.Context) ([]models.Node, error) {
return getAllNodes(ctx, false)
}
// GetAllNodesWithViolations is like GetAllNodes but attaches current-cycle
// posture_check_violations in one batch query. Intended for the posture hook
// (event diffs), not for high-frequency list APIs.
func GetAllNodesWithViolations(ctx context.Context) ([]models.Node, error) {
return getAllNodes(ctx, true)
}
func getAllNodes(ctx context.Context, withViolations bool) ([]models.Node, error) {
var nodes []models.Node
_nodes, err := (&schema.Node{}).ListAll(ctx, dbtypes.WithAllPreloads())
if err != nil {
@@ -341,7 +356,9 @@ func GetAllNodes(ctx context.Context) ([]models.Node, error) {
ensureNodeMutex(node)
nodes = append(nodes, *node)
}
attachPostureViolations(ctx, _nodes, nodes)
if withViolations {
attachPostureViolations(ctx, _nodes, nodes)
}
return nodes, nil
}
@@ -349,6 +366,7 @@ func GetAllNodes(ctx context.Context) ([]models.Node, error) {
// attachPostureViolations loads violations for all nodes in one query and
// assigns each node's current-cycle violations onto the models.Node slice.
// schemaNodes and modelsNodes must be the same length and aligned by index.
// Used by GetAllNodesWithViolations (posture hook), not the high-frequency list API.
func attachPostureViolations(ctx context.Context, schemaNodes []schema.Node, modelsNodes []models.Node) {
if len(schemaNodes) == 0 || len(schemaNodes) != len(modelsNodes) {
return
@@ -367,7 +385,6 @@ func attachPostureViolations(ctx context.Context, schemaNodes []schema.Node, mod
slog.Warn("failed to batch-load posture violations", "error", err, "nodes", len(ids))
return
}
// nodeID -> cycleID -> violations
byNodeCycle := make(map[string]map[string][]models.Violation, len(ids))
for _, v := range all {
cycles := byNodeCycle[v.NodeID]
@@ -385,11 +402,22 @@ func attachPostureViolations(ctx context.Context, schemaNodes []schema.Node, mod
}
for i := range modelsNodes {
cycleID := schemaNodes[i].PostureCheckLastEvaluationCycleID
if cycleID == "" {
cycles := byNodeCycle[schemaNodes[i].ID]
if cycles == nil {
continue
}
if cycles, ok := byNodeCycle[schemaNodes[i].ID]; ok {
modelsNodes[i].PostureChecksViolations = cycles[cycleID]
if cycleID != "" {
if v, ok := cycles[cycleID]; ok {
modelsNodes[i].PostureChecksViolations = v
continue
}
}
// Fallback when cycle metadata and rows disagree after a partial upsert.
if schemaNodes[i].PostureCheckSeverity != schema.SeverityUnknown {
for _, v := range cycles {
modelsNodes[i].PostureChecksViolations = v
break
}
}
}
}
@@ -490,7 +518,6 @@ func GetNodesByIDs(ids []string) (map[string]models.Node, error) {
ensureNodeMutex(n)
modelsNodes[i] = *n
}
attachPostureViolations(ctx, _nodes, modelsNodes)
result := make(map[string]models.Node, len(modelsNodes))
for i := range modelsNodes {
@@ -659,7 +686,8 @@ type nodeConvertOpts struct {
type NodeConvertOption func(*nodeConvertOpts)
// SkipViolations skips the per-node posture_check_violations query.
// Use with attachPostureViolations for batched list loads.
// Use on list paths: severity/cycle fields on the node row are enough;
// violation details come from the dedicated violations API or single-node get.
func SkipViolations() NodeConvertOption {
return func(o *nodeConvertOpts) { o.skipViolations = true }
}
+4 -1
View File
@@ -155,8 +155,11 @@ func (n *NodeOrchestrator) CreateNode(ctx context.Context, host *schema.Host, ne
go func(ctx context.Context) {
modelsNode := logic.ConvertSchemaNodeToModelsNode(node)
deviceInfo := logic.GetPostureCheckDeviceInfoByNode(ctx, modelsNode)
oldViolations := modelsNode.PostureChecksViolations
modelsNode.PostureChecksViolations, modelsNode.PostureCheckViolationSeverityLevel = logic.CheckPostureViolations(ctx, logic.GetPostureCheckDeviceInfoByNode(ctx, modelsNode), schema.NetworkID(node.Network.Name))
modelsNode.PostureChecksViolations, modelsNode.PostureCheckViolationSeverityLevel = logic.CheckPostureViolations(ctx, deviceInfo, schema.NetworkID(node.Network.Name))
logic.EmitNewPostureViolationEvents(ctx, oldViolations, modelsNode.PostureChecksViolations, deviceInfo, schema.NetworkID(node.Network.Name))
node.PostureCheckSeverity = modelsNode.PostureCheckViolationSeverityLevel
node.PostureCheckLastEvaluationCycleID = uuid.NewString()
node.PostureCheckLastEvaluatedAt = time.Now().UTC()
+1
View File
@@ -224,6 +224,7 @@ func InitPro() {
logic.ValidateEgressReq = proLogic.ValidateEgressReq
logic.CheckPostureViolations = proLogic.CheckPostureViolations
logic.CheckPostureViolationsForHost = proLogic.CheckPostureViolationsForHost
logic.EmitNewPostureViolationEvents = proLogic.EmitNewPostureViolationEvents
logic.GetPostureCheckDeviceInfoByNode = proLogic.GetPostureCheckDeviceInfoByNode
logic.SyncHostMDMState = mdmpkg.SyncHostMDMState
logic.SyncHostEDRState = edrpkg.SyncHostEDRState
+9 -1
View File
@@ -107,7 +107,15 @@ func EventWatcher() {
Diff: diff,
TimeStamp: time.Now().UTC(),
}
a.Create(db.WithContext(context.TODO()))
writeCtx := db.WithContext(context.TODO())
if e.TenantID != "" {
writeCtx = scope.WithContext(writeCtx, scope.TenantScope, e.TenantID)
}
if err := a.Create(writeCtx); err != nil {
slog.Error("failed to persist audit event",
"action", e.Action, "network", e.NetworkID, "tenant", e.TenantID, "error", err)
continue
}
_siemMtx.Lock()
if !_pushToSiem {
+74 -84
View File
@@ -92,10 +92,21 @@ func RunPostureChecksForTenant(ctx context.Context) error {
if err != nil {
return err
}
nodes, err := logic.GetAllNodes(ctx)
nodes, err := logic.GetAllNodesWithViolations(ctx)
if err != nil {
return err
}
// One-time backfill: if this tenant has never logged a posture failure,
// treat previous violations as empty so current failures appear in activity.
// After the first POSTURE_CHECK_FAILED is written, resume new-only dedupe.
backfillPostureFailures := false
if has, err := (&schema.Event{}).HasAction(ctx, schema.PostureCheckFailed); err != nil {
slog.Warn("failed to check for existing posture failure events", "error", err)
} else {
backfillPostureFailures = !has
}
for _, netI := range nets {
networkNodes := logic.GetNetworkNodesMemory(nodes, netI.Name)
if len(networkNodes) == 0 {
@@ -126,22 +137,35 @@ func RunPostureChecksForTenant(ctx context.Context) error {
if noChecks && len(extclient.PostureChecksViolations) == 0 {
continue
}
emitNewMDMViolationEvents(ctx, extclient.PostureChecksViolations, postureChecksViolations, deviceInfo, schema.NetworkID(netI.Name))
emitNewEDRViolationEvents(ctx, extclient.PostureChecksViolations, postureChecksViolations, deviceInfo, schema.NetworkID(netI.Name))
oldVi := extclient.PostureChecksViolations
if backfillPostureFailures {
oldVi = nil
}
EmitNewPostureViolationEvents(ctx, oldVi, postureChecksViolations, deviceInfo, schema.NetworkID(netI.Name))
extclient.PostureChecksViolations = postureChecksViolations
extclient.PostureCheckVolationSeverityLevel = postureCheckVolationSeverityLevel
extclient.LastEvaluatedAt = time.Now().UTC()
logic.SaveExtClient(ctx, &extclient)
}
} else {
if noChecks && len(nodeI.PostureChecksViolations) == 0 {
// When no checks are configured and the node is already clean, skip
// a full cycle rewrite — but still purge any orphaned historical
// violation rows left by older upserts that only appended.
if noChecks &&
nodeI.PostureCheckViolationSeverityLevel == schema.SeverityUnknown &&
len(nodeI.PostureChecksViolations) == 0 {
_ = (&schema.Node{ID: nodeI.ID.String(), TenantID: nodeI.TenantID}).DeleteViolations(ctx)
continue
}
emitNewMDMViolationEvents(ctx, nodeI.PostureChecksViolations, postureChecksViolations, deviceInfo, schema.NetworkID(netI.Name))
emitNewEDRViolationEvents(ctx, nodeI.PostureChecksViolations, postureChecksViolations, deviceInfo, schema.NetworkID(netI.Name))
oldVi := nodeI.PostureChecksViolations
if backfillPostureFailures {
oldVi = nil
}
EmitNewPostureViolationEvents(ctx, oldVi, postureChecksViolations, deviceInfo, schema.NetworkID(netI.Name))
_node := &schema.Node{
ID: nodeI.ID.String(),
TenantID: nodeI.TenantID,
PostureCheckSeverity: postureCheckVolationSeverityLevel,
PostureCheckLastEvaluationCycleID: uuid.NewString(),
PostureCheckLastEvaluatedAt: time.Now().UTC(),
@@ -161,7 +185,10 @@ func RunPostureChecksForTenant(ctx context.Context) error {
EvaluatedAt: _node.PostureCheckLastEvaluatedAt,
})
}
_ = _node.UpsertViolations(ctx, _violations)
if err := _node.UpsertViolations(ctx, _violations); err != nil {
slog.Error("failed to upsert posture check violations",
"node_id", _node.ID, "error", err)
}
}
}
}
@@ -942,94 +969,54 @@ func asInt(v interface{}) int {
return 0
}
// emitNewMDMViolationEvents emits a posture_check_failed audit event for every
// MDM compliance violation that is newly present (not in oldVi) in newVi.
// Old violations don't re-fire; cleared violations are also ignored here.
func emitNewMDMViolationEvents(ctx context.Context, oldVi, newVi []models.Violation, d models.PostureCheckDeviceInfo, network schema.NetworkID) {
// EmitNewPostureViolationEvents emits a posture_check_failed audit event for
// every violation newly present in newVi (any attribute). Dedupes by CheckID
// against oldVi so ongoing failures do not re-fire; cleared violations are
// ignored. MDM/EDR events keep their provider-specific enrichment fields.
func EmitNewPostureViolationEvents(ctx context.Context, oldVi, newVi []models.Violation, d models.PostureCheckDeviceInfo, network schema.NetworkID) {
if len(newVi) == 0 {
return
}
prev := make(map[string]struct{}, len(oldVi))
for _, v := range oldVi {
prev[v.CheckID+"|"+v.Message] = struct{}{}
}
providerID, _ := mdmpkg.ActiveProviderID(ctx)
for _, v := range newVi {
if v.Attribute != string(schema.MDMCompliance) {
if v.CheckID == "" {
continue
}
if _, ok := prev[v.CheckID+"|"+v.Message]; ok {
continue
}
diff := models.Diff{
Old: nil,
New: map[string]interface{}{
"event": "posture_check_failed",
"type": string(schema.MDMCompliance),
"host_id": d.HostID,
"check_id": v.CheckID,
"check": v.Name,
"reason": v.Message,
"severity": v.Severity,
"provider": providerID,
"enrolled": mdmStateEnrolled(d.MDMState),
"compliant": mdmStateCompliant(d.MDMState),
},
}
logic.LogEvent(ctx, &models.Event{
Action: schema.PostureCheckFailed,
Source: models.Subject{
ID: d.HostID,
Name: d.HostID,
Type: schema.DeviceSub,
},
TriggeredBy: "system",
Target: models.Subject{
ID: v.CheckID,
Name: v.Name,
Type: schema.PostureCheckSub,
},
NetworkID: network,
Origin: schema.Api,
Diff: diff,
})
prev[v.CheckID] = struct{}{}
}
}
// emitNewEDRViolationEvents emits a posture_check_failed audit event for every
// EDR compliance violation that is newly present (not in oldVi) in newVi.
// Old violations don't re-fire; cleared violations are also ignored here.
func emitNewEDRViolationEvents(ctx context.Context, oldVi, newVi []models.Violation, d models.PostureCheckDeviceInfo, network schema.NetworkID) {
if len(newVi) == 0 {
return
}
prev := make(map[string]struct{}, len(oldVi))
for _, v := range oldVi {
prev[v.CheckID+"|"+v.Message] = struct{}{}
}
providerID, _ := edrpkg.ActiveProviderID(ctx)
var mdmProviderID, edrProviderID string
for _, v := range newVi {
if v.Attribute != string(schema.EDRCompliance) {
continue
if v.CheckID != "" {
if _, ok := prev[v.CheckID]; ok {
continue
}
}
if _, ok := prev[v.CheckID+"|"+v.Message]; ok {
continue
payload := map[string]interface{}{
"event": "posture_check_failed",
"type": v.Attribute,
"host_id": d.HostID,
"check_id": v.CheckID,
"check": v.Name,
"reason": v.Message,
"severity": v.Severity,
}
diff := models.Diff{
Old: nil,
New: map[string]interface{}{
"event": "posture_check_failed",
"type": string(schema.EDRCompliance),
"host_id": d.HostID,
"check_id": v.CheckID,
"check": v.Name,
"reason": v.Message,
"severity": v.Severity,
"provider": providerID,
"agent_installed": edrStateAgentInstalled(d.EDRState),
"agent_healthy": edrStateAgentHealthy(d.EDRState),
"risk_level": edrStateRiskLevel(d.EDRState),
},
switch v.Attribute {
case string(schema.MDMCompliance):
if mdmProviderID == "" {
mdmProviderID, _ = mdmpkg.ActiveProviderID(ctx)
}
payload["provider"] = mdmProviderID
payload["enrolled"] = mdmStateEnrolled(d.MDMState)
payload["compliant"] = mdmStateCompliant(d.MDMState)
case string(schema.EDRCompliance):
if edrProviderID == "" {
edrProviderID, _ = edrpkg.ActiveProviderID(ctx)
}
payload["provider"] = edrProviderID
payload["agent_installed"] = edrStateAgentInstalled(d.EDRState)
payload["agent_healthy"] = edrStateAgentHealthy(d.EDRState)
payload["risk_level"] = edrStateRiskLevel(d.EDRState)
}
logic.LogEvent(ctx, &models.Event{
Action: schema.PostureCheckFailed,
@@ -1046,7 +1033,10 @@ func emitNewEDRViolationEvents(ctx context.Context, oldVi, newVi []models.Violat
},
NetworkID: network,
Origin: schema.Api,
Diff: diff,
Diff: models.Diff{
Old: nil,
New: payload,
},
})
}
}
+12
View File
@@ -191,6 +191,18 @@ func (a *Event) List(ctx context.Context, from, to time.Time) (ats []Event, err
return
}
// HasAction returns true if at least one event with the given action exists
// for the current tenant (used for one-time posture-failure audit backfill).
func (a *Event) HasAction(ctx context.Context, action Action) (bool, error) {
query := db.FromContext(ctx).Model(&Event{}).Where("action = ?", action)
if tenantID := scope.ID(ctx); tenantID != "" {
query = dbtypes.WithFilter(fmt.Sprintf("%s.tenant_id", eventsTable), tenantID)(query)
}
var count int64
err := query.Limit(1).Count(&count).Error
return count > 0, err
}
func (a *Event) DeleteAllForTenant(ctx context.Context) error {
if tenantID := scope.ID(ctx); tenantID != "" {
return db.FromContext(ctx).Where(fmt.Sprintf("%s.tenant_id = ?", eventsTable), tenantID).Delete(&Event{}).Error
+69 -12
View File
@@ -185,41 +185,98 @@ func (n *Node) Count(ctx context.Context, options ...dbtypes.Option) (int, error
return int(count), err
}
// UpsertViolations replaces stored violations for this node with the latest
// evaluation cycle, then updates severity / cycle metadata on the node row.
// Inserts the new cycle and updates the node first, then deletes older cycles,
// so readers never observe a window with severity set and zero violation rows.
func (n *Node) UpsertViolations(ctx context.Context, violations []PostureCheckViolation) error {
txCtx := db.BeginTx(ctx)
tx := db.FromContext(txCtx)
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
cycleID := n.PostureCheckLastEvaluationCycleID
if len(violations) > 0 {
for i := range violations {
if violations[i].TenantID == "" {
violations[i].TenantID = n.TenantID
}
if violations[i].NodeID == "" {
violations[i].NodeID = n.ID
}
if violations[i].EvaluationCycleID == "" {
violations[i].EvaluationCycleID = cycleID
}
}
err := db.FromContext(ctx).Model(&PostureCheckViolation{}).Create(&violations).Error
if err != nil {
if err := tx.Model(&PostureCheckViolation{}).Create(&violations).Error; err != nil {
return err
}
}
return db.FromContext(ctx).Model(&Node{}).
if err := tx.Model(&Node{}).
Where("id = ?", n.ID).
Update("posture_check_severity", n.PostureCheckSeverity).
Update("posture_check_last_evaluation_cycle_id", n.PostureCheckLastEvaluationCycleID).
Update("posture_check_last_evaluated_at", n.PostureCheckLastEvaluatedAt).
Error
Updates(map[string]interface{}{
"posture_check_severity": n.PostureCheckSeverity,
"posture_check_last_evaluation_cycle_id": cycleID,
"posture_check_last_evaluated_at": n.PostureCheckLastEvaluatedAt,
}).Error; err != nil {
return err
}
del := tx.Model(&PostureCheckViolation{}).Where("node_id = ?", n.ID)
if tenantID := scope.ID(ctx); tenantID != "" {
del = dbtypes.WithFilter(fmt.Sprintf("%s.tenant_id", postureCheckViolationsTable), tenantID)(del)
}
if cycleID != "" {
// Keep the cycle we just wrote; drop historical rows.
del = del.Where("evaluation_cycle_id <> ?", cycleID)
}
if err := del.Delete(&PostureCheckViolation{}).Error; err != nil {
return err
}
if err := tx.Commit().Error; err != nil {
return err
}
committed = true
return nil
}
func (n *Node) ListViolations(ctx context.Context) ([]PostureCheckViolation, error) {
var violations []PostureCheckViolation
query := db.FromContext(ctx).Model(&PostureCheckViolation{}).
Where("node_id = ? AND evaluation_cycle_id = ?", n.ID, n.PostureCheckLastEvaluationCycleID)
query := db.FromContext(ctx).Model(&PostureCheckViolation{}).Where("node_id = ?", n.ID)
if tenantID := scope.ID(ctx); tenantID != "" {
query = dbtypes.WithFilter(fmt.Sprintf("%s.tenant_id", postureCheckViolationsTable), tenantID)(query)
}
err := query.Find(&violations).Error
if n.PostureCheckLastEvaluationCycleID != "" {
err := query.Where("evaluation_cycle_id = ?", n.PostureCheckLastEvaluationCycleID).Find(&violations).Error
if err != nil {
return nil, err
}
if len(violations) > 0 || n.PostureCheckSeverity == SeverityUnknown {
return violations, nil
}
}
// Fallback when severity says violated but the stored cycle has no rows
// (partial upsert from older delete-first writers).
fallback := db.FromContext(ctx).Model(&PostureCheckViolation{}).Where("node_id = ?", n.ID)
if tenantID := scope.ID(ctx); tenantID != "" {
fallback = dbtypes.WithFilter(fmt.Sprintf("%s.tenant_id", postureCheckViolationsTable), tenantID)(fallback)
}
err := fallback.Find(&violations).Error
return violations, err
}
// ListViolationsByNodeIDs fetches posture violations for many nodes in one query.
// Callers should filter by each node's PostureCheckLastEvaluationCycleID in memory.
// Prefer after UpsertViolations (which replaces cycles) so only current rows remain.
// Callers should still filter by each node's PostureCheckLastEvaluationCycleID.
func ListViolationsByNodeIDs(ctx context.Context, nodeIDs []string) ([]PostureCheckViolation, error) {
if len(nodeIDs) == 0 {
return nil, nil