package model import ( "time" "github.com/heicode/manager/common" ) // AgentAuditEvent is the persistent audit-trail row for the Agent // control-plane. Each row records one observable transition in the // Agent lifecycle — deployment accepted, instance phase changed, SK // snapshot refreshed, etc. — so admins can answer the "who / when / // for which task / against which resource / with what result" set of // questions even after the Manager container restarts. // // Before this table existed the control-plane stashed events in an // in-process `map[string][]agentEvent` (controller/agent_control_plane // .go:219). Every restart wiped audit history — unacceptable for a // product where the audit page is part of the security story. // // Schema notes // - All string columns are bounded varchar to keep MySQL happy. // - `OccurredAt` is unix milliseconds so cross-DB ORDER BY / range // queries are trivial without a TIMESTAMP-with-timezone dance. // - `DetailsJSON` is a free-form payload bucket for fields we don't // want to promote to first-class columns yet (e.g. failure_reason, // phase, callback_url). Add a real column when a field is queried // enough to need an index. // - Indexes are picked for the dashboard queries: filter by user_id, // filter by deployment, time-range scan. // // Cross-DB compatibility (CLAUDE.md Rule 2): GORM AutoMigrate maps the // tags to the correct types on SQLite / MySQL / PostgreSQL. No raw // SQL. No DB-specific column types. type AgentAuditEvent struct { Id int `gorm:"primaryKey" json:"id"` EventID string `gorm:"type:varchar(64);uniqueIndex" json:"event_id"` Event string `gorm:"type:varchar(64);index" json:"event"` Actor string `gorm:"type:varchar(64)" json:"actor"` Resource string `gorm:"type:varchar(128);index" json:"resource"` UserID string `gorm:"type:varchar(64);index" json:"user_id"` ChannelID string `gorm:"type:varchar(64)" json:"channel_id"` BindingScope string `gorm:"type:varchar(64);index" json:"binding_scope"` DeploymentID string `gorm:"type:varchar(64);index" json:"deployment_id"` CorrelationID string `gorm:"type:varchar(64)" json:"correlation_id"` RequestID string `gorm:"type:varchar(64)" json:"request_id"` Result string `gorm:"type:varchar(16)" json:"result"` SchemaVersion int `gorm:"default:1" json:"schema_version"` OccurredAt int64 `gorm:"bigint;index" json:"occurred_at"` DetailsJSON string `gorm:"type:text" json:"details_json,omitempty"` } // TableName pins the migration target so we can rename the Go struct // later without breaking the deployed schema. func (AgentAuditEvent) TableName() string { return "agent_audit_events" } // InsertAgentAuditEvent best-effort persists one audit row. Callers // invoke this in a hot path (right after mutating a deployment), so: // - errors are logged but never returned — the audit write must NOT // fail the user-facing API // - DB is nil-guarded so unit tests / partial-init binaries don't // panic on a missing connection func InsertAgentAuditEvent(evt *AgentAuditEvent) { if DB == nil || evt == nil { return } if evt.OccurredAt == 0 { evt.OccurredAt = time.Now().UnixMilli() } if evt.SchemaVersion == 0 { evt.SchemaVersion = 1 } if evt.Result == "" { evt.Result = "ok" } if err := DB.Create(evt).Error; err != nil { common.SysLog("InsertAgentAuditEvent: " + err.Error()) } } // ListAgentAuditEventsFilter narrows the audit query to a slice of // dashboard relevant rows. Zero-value fields are ignored — callers // pass only the filters they care about. type ListAgentAuditEventsFilter struct { UserID string BindingScope string DeploymentID string EventLike string // prefix filter on Event column, e.g. "deployment." FromMs int64 // inclusive ToMs int64 // exclusive; zero == no upper bound Limit int // defaults to 200 Offset int } // ListAgentAuditEvents pages over audit rows ordered newest-first. // Used by the /api/agent/audit-logs endpoint and the future task- // scoped audit drawer. func ListAgentAuditEvents(f ListAgentAuditEventsFilter) ([]AgentAuditEvent, int64, error) { if DB == nil { return nil, 0, nil } q := DB.Model(&AgentAuditEvent{}) if f.UserID != "" { q = q.Where("user_id = ?", f.UserID) } if f.BindingScope != "" { q = q.Where("binding_scope = ?", f.BindingScope) } if f.DeploymentID != "" { q = q.Where("deployment_id = ?", f.DeploymentID) } if f.EventLike != "" { q = q.Where("event LIKE ?", f.EventLike+"%") } if f.FromMs > 0 { q = q.Where("occurred_at >= ?", f.FromMs) } if f.ToMs > 0 { q = q.Where("occurred_at < ?", f.ToMs) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } limit := f.Limit if limit <= 0 || limit > 1000 { limit = 200 } var items []AgentAuditEvent err := q.Order("occurred_at desc, id desc").Limit(limit).Offset(f.Offset).Find(&items).Error return items, total, err } // ListAgentAuditEventsByDeployment is the hot path for the deployment // detail drawer — returns all events for one deployment in chronological // order so the timeline reads top-to-bottom. func ListAgentAuditEventsByDeployment(deploymentID string) ([]AgentAuditEvent, error) { if DB == nil || deploymentID == "" { return nil, nil } var items []AgentAuditEvent err := DB.Where("deployment_id = ?", deploymentID). Order("occurred_at asc, id asc"). Find(&items).Error return items, err }