feat(audit): persist Agnet control-plane audit events to DB

Sprint 1 of the post-product-doc gap closure. Replaces the previous
in-process `agnetEvents map[string][]agnetEvent` (lost on every
container restart) with a real GORM table `agnet_audit_events`.

What changed:
  - model/agnet_audit.go (new): AgnetAuditEvent model + InsertAgnetAuditEvent
    / ListAgnetAuditEvents / ListAgnetAuditEventsByDeployment helpers.
    Indexes picked for the dashboard queries: user_id, deployment_id,
    binding_scope, occurred_at desc.
  - model/main.go: AutoMigrate &AgnetAuditEvent{} alongside the existing
    schema (SQLite/MySQL/PostgreSQL compatible per CLAUDE.md Rule 2).
  - controller/agnet_control_plane.go: drop agnetEvents map; the 3
    producer sites (deployment accepted / stop / sk_snapshot_refreshed)
    now call recordAgnetAuditEvent which writes to DB best-effort.
    The 3 reader sites (events list / logs / audit-logs) now query
    the table; AgnetListAuditLogs also supports limit/offset pagination.
  - controller/agnet_control_plane_test.go: reset helper no longer
    touches the deleted map.
  - model/agnet_audit_test.go (new): 4 tests covering persistence,
    nil-guard production safety, filter+paginate, chronological reads.

Sidebar UX:
  - "Preparation checklist" → "Resource binding"
    Per product-package doc README §统一表述 — user-facing term is
    "资源绑定" not "准备清单". URL /sk-sources kept to preserve
    bookmarks; can rename in a later pass with redirect.

Verification:
  - go test ./controller/... ./middleware/... ./model/... all green
  - go vet clean
  - frontend tsc --noEmit clean
  - audit writes are best-effort: errors log via SysLog but never
    fail the user API call; DB nil-guards in place

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 16:42:34 +08:00
co-authored by Claude Opus 4.7
parent 5081289b65
commit 8f70115c6a
8 changed files with 430 additions and 41 deletions
+127 -37
View File
@@ -3,12 +3,14 @@ package controller
import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
const (
@@ -216,10 +218,49 @@ type agnetSKSnapshot struct {
var (
agnetMu sync.RWMutex
agnetDeployments = make(map[string]agnetDeploymentRecord)
agnetEvents = make(map[string][]agnetEvent)
agnetSnapshots = make(map[string][]agnetSKSnapshot)
)
// recordAgnetAuditEvent persists `evt` to model.AgnetAuditEvent
// (audit table) so dashboards and admin queries survive a container
// restart. The previous implementation appended to an in-process
// map[string][]agnetEvent which was lost on every redeploy.
//
// `requestID` and `result` are usually unknown at the producer site
// (we're called from inside the deployment lifecycle, not from a
// request handler with a c.GetString), so they're free-form here.
// Pass "" / "ok" when you don't have specifics.
func recordAgnetAuditEvent(evt agnetEvent, actor, resource, requestID, result string) {
if result == "" {
result = "ok"
}
model.InsertAgnetAuditEvent(&model.AgnetAuditEvent{
EventID: evt.EventID,
Event: evt.Event,
Actor: actor,
Resource: resource,
UserID: evt.UserID,
ChannelID: evt.ChannelID,
BindingScope: evt.BindingScope,
DeploymentID: evt.DeploymentID,
CorrelationID: evt.CorrelationID,
RequestID: requestID,
Result: result,
SchemaVersion: evt.SchemaVersion,
OccurredAt: parseAgnetOccurredAtMs(evt.OccurredAt),
})
}
// parseAgnetOccurredAtMs converts the RFC3339 string emitted by
// agnetNow() into unix-ms for the DB column. Falls back to "now" so
// a malformed timestamp doesn't drop the row.
func parseAgnetOccurredAtMs(s string) int64 {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UnixMilli()
}
return time.Now().UnixMilli()
}
func agnetNow() string {
return time.Now().UTC().Format(time.RFC3339)
}
@@ -696,8 +737,8 @@ func AgnetCreateDeployment(c *gin.Context) {
agnetMu.Lock()
agnetDeployments[deploymentID] = record
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], event)
agnetMu.Unlock()
recordAgnetAuditEvent(event, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
@@ -774,7 +815,7 @@ func AgnetStopDeployment(c *gin.Context) {
}
record.UpdatedAt = agnetNow()
agnetDeployments[deploymentID] = record
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
stopEvent := agnetEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "instance.phase_changed",
SchemaVersion: 1,
@@ -784,8 +825,9 @@ func AgnetStopDeployment(c *gin.Context) {
DeploymentID: deploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: agnetNow(),
})
}
agnetMu.Unlock()
recordAgnetAuditEvent(stopEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
@@ -801,9 +843,17 @@ func AgnetListDeploymentEvents(c *gin.Context) {
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
agnetMu.RLock()
events := agnetEvents[deploymentID]
agnetMu.RUnlock()
// Audit rows are now in PostgreSQL/SQLite/MySQL — survive restarts.
rows, err := model.ListAgnetAuditEventsByDeployment(deploymentID)
if err != nil {
common.SysLog("AgnetListDeploymentEvents: " + err.Error())
agnetError(c, "POLICY_REJECTED", "internal error")
return
}
events := make([]agnetEvent, 0, len(rows))
for _, r := range rows {
events = append(events, agnetAuditRowToEvent(r))
}
common.ApiSuccess(c, gin.H{
"items": events,
"total": len(events),
@@ -819,12 +869,19 @@ func AgnetListDeploymentLogs(c *gin.Context) {
agnetMu.RLock()
record, ok := agnetDeployments[deploymentID]
events := agnetEvents[deploymentID]
agnetMu.RUnlock()
if !ok {
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
rows, err := model.ListAgnetAuditEventsByDeployment(deploymentID)
if err != nil {
common.SysLog("AgnetListDeploymentLogs: " + err.Error())
}
events := make([]agnetEvent, 0, len(rows))
for _, r := range rows {
events = append(events, agnetAuditRowToEvent(r))
}
items := make([]gin.H, 0, len(events)+1)
items = append(items, gin.H{
@@ -981,7 +1038,7 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
}
}
agnetSnapshots[deploymentID] = snapshots
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
snapEvent := agnetEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "sk_snapshot_refreshed",
SchemaVersion: 1,
@@ -991,8 +1048,9 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
DeploymentID: deploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: now,
})
}
agnetMu.Unlock()
recordAgnetAuditEvent(snapEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
@@ -1018,36 +1076,68 @@ func AgnetListSKSnapshots(c *gin.Context) {
}
func AgnetListAuditLogs(c *gin.Context) {
userID := strings.TrimSpace(c.Query("user_id"))
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
items := make([]gin.H, 0)
agnetMu.RLock()
for deploymentID, events := range agnetEvents {
for _, event := range events {
if userID != "" && event.UserID != userID {
continue
}
if bindingScope != "" && event.BindingScope != bindingScope {
continue
}
items = append(items, gin.H{
"actor": "agnet_control_plane",
"action": event.Event,
"resource": deploymentID,
"user_id": event.UserID,
"channel_id": event.ChannelID,
"binding_scope": event.BindingScope,
"request_id": agnetRequestID(c),
"correlation_id": event.CorrelationID,
"result": "ok",
"occurred_at": event.OccurredAt,
})
// Persistent audit query — survives container restarts. Filters
// are optional; the dashboard call usually narrows by user_id +
// time window. Pagination is opt-in via ?limit / ?offset.
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
agnetMu.RUnlock()
offset := 0
if v := strings.TrimSpace(c.Query("offset")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
rows, total, err := model.ListAgnetAuditEvents(model.ListAgnetAuditEventsFilter{
UserID: strings.TrimSpace(c.Query("user_id")),
BindingScope: strings.TrimSpace(c.Query("binding_scope")),
DeploymentID: strings.TrimSpace(c.Query("deployment_id")),
Limit: limit,
Offset: offset,
})
if err != nil {
common.SysLog("AgnetListAuditLogs: " + err.Error())
agnetError(c, "POLICY_REJECTED", "internal error")
return
}
items := make([]gin.H, 0, len(rows))
for _, r := range rows {
items = append(items, gin.H{
"actor": r.Actor,
"action": r.Event,
"resource": r.Resource,
"user_id": r.UserID,
"channel_id": r.ChannelID,
"binding_scope": r.BindingScope,
"request_id": r.RequestID,
"correlation_id": r.CorrelationID,
"result": r.Result,
"occurred_at": time.UnixMilli(r.OccurredAt).UTC().Format(time.RFC3339),
})
}
common.ApiSuccess(c, gin.H{
"items": items,
"total": len(items),
"total": total,
})
}
// agnetAuditRowToEvent reshapes a persisted audit row back into the
// in-process event struct so the deployment-detail / log endpoints
// can keep returning the same JSON shape they used to emit from the
// in-memory map. Avoids breaking any existing dashboard consumer.
func agnetAuditRowToEvent(r model.AgnetAuditEvent) agnetEvent {
return agnetEvent{
EventID: r.EventID,
Event: r.Event,
SchemaVersion: r.SchemaVersion,
UserID: r.UserID,
ChannelID: r.ChannelID,
BindingScope: r.BindingScope,
DeploymentID: r.DeploymentID,
CorrelationID: r.CorrelationID,
OccurredAt: time.UnixMilli(r.OccurredAt).UTC().Format(time.RFC3339),
}
}
@@ -28,8 +28,10 @@ func resetAgnetControlPlaneState(t *testing.T) {
agnetMu.Lock()
defer agnetMu.Unlock()
agnetDeployments = make(map[string]agnetDeploymentRecord)
agnetEvents = make(map[string][]agnetEvent)
agnetSnapshots = make(map[string][]agnetSKSnapshot)
// Audit rows live in DB now; tests use a fresh SQLite per case
// (setupTokenControllerTestDB style) so there is no global map
// to flush here.
}
func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
+148
View File
@@ -0,0 +1,148 @@
package model
import (
"time"
"github.com/heicode/manager/common"
)
// AgnetAuditEvent is the persistent audit-trail row for the Agnet
// control-plane. Each row records one observable transition in the
// Agnet 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][]agnetEvent` (controller/agnet_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 AgnetAuditEvent 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 (AgnetAuditEvent) TableName() string {
return "agnet_audit_events"
}
// InsertAgnetAuditEvent 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 InsertAgnetAuditEvent(evt *AgnetAuditEvent) {
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("InsertAgnetAuditEvent: " + err.Error())
}
}
// ListAgnetAuditEventsFilter 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 ListAgnetAuditEventsFilter 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
}
// ListAgnetAuditEvents pages over audit rows ordered newest-first.
// Used by the /api/agnet/audit-logs endpoint and the future task-
// scoped audit drawer.
func ListAgnetAuditEvents(f ListAgnetAuditEventsFilter) ([]AgnetAuditEvent, int64, error) {
if DB == nil {
return nil, 0, nil
}
q := DB.Model(&AgnetAuditEvent{})
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 []AgnetAuditEvent
err := q.Order("occurred_at desc, id desc").Limit(limit).Offset(f.Offset).Find(&items).Error
return items, total, err
}
// ListAgnetAuditEventsByDeployment 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 ListAgnetAuditEventsByDeployment(deploymentID string) ([]AgnetAuditEvent, error) {
if DB == nil || deploymentID == "" {
return nil, nil
}
var items []AgnetAuditEvent
err := DB.Where("deployment_id = ?", deploymentID).
Order("occurred_at asc, id asc").
Find(&items).Error
return items, err
}
+143
View File
@@ -0,0 +1,143 @@
package model
import (
"fmt"
"testing"
"time"
)
// The model package shares a single in-memory SQLite across all tests
// via TestMain in task_cas_test.go. We MUST NOT swap DB out or null
// it in Cleanup — doing so wedges every other test in the package.
//
// Strategy: AutoMigrate the audit table once (idempotent on SQLite),
// then DELETE rows at the top of each test to isolate cases.
func setupAuditTest(t *testing.T) {
t.Helper()
if err := DB.AutoMigrate(&AgnetAuditEvent{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := DB.Exec("DELETE FROM agnet_audit_events").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
}
func TestInsertAgnetAuditEvent_PersistsRow(t *testing.T) {
// Regression for H1 — the previous implementation appended to an
// in-process map that was wiped on every container restart. This
// test pins the new behaviour: rows survive in DB.
setupAuditTest(t)
evt := &AgnetAuditEvent{
EventID: "evt_test_001",
Event: "deployment.accepted",
Actor: "agnet_control_plane",
Resource: "dep_abc",
UserID: "user-42",
ChannelID: "channel-1",
BindingScope: "project-main",
DeploymentID: "dep_abc",
CorrelationID: "corr-xyz",
RequestID: "req-001",
}
InsertAgnetAuditEvent(evt)
var got AgnetAuditEvent
if err := DB.Where("event_id = ?", "evt_test_001").First(&got).Error; err != nil {
t.Fatalf("not persisted: %v", err)
}
if got.Result != "ok" {
t.Errorf("default Result should be 'ok', got %q", got.Result)
}
if got.SchemaVersion != 1 {
t.Errorf("default SchemaVersion should be 1, got %d", got.SchemaVersion)
}
if got.OccurredAt == 0 {
t.Errorf("OccurredAt should be auto-stamped, got 0")
}
}
func TestInsertAgnetAuditEvent_NilGuards(t *testing.T) {
// Production safety: audit writes run inside hot paths (right
// after a deployment mutation). A nil DB or nil event MUST NOT
// panic — better to drop the audit row than to fail the user
// API call.
defer func() {
if r := recover(); r != nil {
t.Fatalf("InsertAgnetAuditEvent panicked: %v", r)
}
}()
InsertAgnetAuditEvent(nil) // nil evt
prev := DB
DB = nil
InsertAgnetAuditEvent(&AgnetAuditEvent{EventID: "x"}) // nil DB
DB = prev
}
func TestListAgnetAuditEvents_FilterAndPaginate(t *testing.T) {
setupAuditTest(t)
now := time.Now().UnixMilli()
for i := 0; i < 5; i++ {
uid := "user-A"
if i%2 == 0 {
uid = "user-B"
}
InsertAgnetAuditEvent(&AgnetAuditEvent{
EventID: fmt.Sprintf("evt_%d", i),
Event: "deployment.accepted",
UserID: uid,
DeploymentID: "dep_X",
OccurredAt: now + int64(i)*1000,
})
}
// All rows visible without filter.
rows, total, err := ListAgnetAuditEvents(ListAgnetAuditEventsFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 5 || len(rows) != 5 {
t.Errorf("expected 5 rows, got total=%d len=%d", total, len(rows))
}
// user_id filter narrows to 3 (i=0,2,4 → user-B).
_, total, err = ListAgnetAuditEvents(ListAgnetAuditEventsFilter{UserID: "user-B"})
if err != nil {
t.Fatalf("list with userid: %v", err)
}
if total != 3 {
t.Errorf("user-B should have 3 rows, got %d", total)
}
// Newest-first ordering. evt_4 inserted last → top.
rows, _, _ = ListAgnetAuditEvents(ListAgnetAuditEventsFilter{})
if rows[0].EventID != "evt_4" {
t.Errorf("expected newest-first, got %q on top", rows[0].EventID)
}
}
func TestListAgnetAuditEventsByDeployment_Chronological(t *testing.T) {
// Detail-drawer reads need oldest-first for a top-to-bottom
// timeline. Confirms ascending order independent of insertion
// order.
setupAuditTest(t)
InsertAgnetAuditEvent(&AgnetAuditEvent{
EventID: "evt_late", Event: "x", DeploymentID: "dep_T",
OccurredAt: 9000,
})
InsertAgnetAuditEvent(&AgnetAuditEvent{
EventID: "evt_early", Event: "x", DeploymentID: "dep_T",
OccurredAt: 1000,
})
rows, err := ListAgnetAuditEventsByDeployment("dep_T")
if err != nil {
t.Fatalf("query: %v", err)
}
if len(rows) != 2 || rows[0].EventID != "evt_early" {
t.Fatalf("expected [evt_early, evt_late] order, got %+v", rows)
}
}
+4
View File
@@ -286,6 +286,10 @@ func migrateDB() error {
// V2 device-binding: X25519 keypair the Manager uses for ECDH
// body decryption. See model/server_key.go.
&ServerKey{},
// Agnet control-plane audit trail. Replaces the previous
// in-process `agnetEvents map` that was wiped on every container
// restart. See model/agnet_audit.go for the rationale.
&AgnetAuditEvent{},
)
if err != nil {
return err
+1 -1
View File
@@ -49,7 +49,7 @@ export function useSidebarData(): SidebarData {
icon: LayoutDashboard,
},
{
title: t('Preparation checklist'),
title: t('Resource binding'),
url: '/sk-sources',
icon: GitBranch,
},
+2 -1
View File
@@ -4071,6 +4071,7 @@
"Give this device a friendly name so you can identify it later.": "Give this device a friendly name so you can identify it later.",
"Name cannot be empty": "Name cannot be empty",
"e.g. Chen's MacBook": "e.g. Chen's MacBook",
"Unnamed device": "Unnamed device"
"Unnamed device": "Unnamed device",
"Resource binding": "Resource binding"
}
}
+2 -1
View File
@@ -4071,6 +4071,7 @@
"Give this device a friendly name so you can identify it later.": "给这台设备起个易识别的名字,方便日后辨认。",
"Name cannot be empty": "名称不能为空",
"e.g. Chen's MacBook": "例如:小明的 MacBook",
"Unnamed device": "未命名设备"
"Unnamed device": "未命名设备",
"Resource binding": "资源绑定"
}
}