Files
spark-mcp/internal/audit/repo.go
T
tao.chenandClaude 77d8bae503 Linus 修复: 空 list JSON 序列化成 [] + seed.sh curl 续行
Linus 视角问题: ClusterRepo.List / AuditRepo.List 在空 list 时
返 nil, encoding/json 默认序列化成 null, 不是 []。
LLM/前端期望一致空数组语义。

- internal/storage/cluster_repo.go: List 返回前 out = []*cluster.Cluster{}
- internal/audit/repo.go: List 返回前 out = []*Entry{}

scripts/seed.sh curl 续行 bug: 在 set -euo pipefail 下 \\ 续行
被解析成多行, curl 收到混乱 argv 报 "URL rejected: Bad hostname"。
改为单行 + backslash 续行在 bash 中更稳。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 17:24:40 +08:00

127 lines
3.2 KiB
Go

package audit
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"spark-mcp-go/internal/storage"
)
// Action enumerates admin write operations that are persisted to the audit log.
type Action string
const (
ActionClusterCreate Action = "cluster.create"
ActionClusterUpdate Action = "cluster.update"
ActionClusterDelete Action = "cluster.delete"
)
// Entry is one admin write operation recorded for accountability.
//
// Actor is stored as "admin:<token_name>" (the first 8 characters of the
// admin token when multiple tokens are in use).
// Details is a JSON-encoded string produced by MarshalDetails; callers that
// do not need structured details can leave it empty.
type Entry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
Actor string `json:"actor"`
Action Action `json:"action"`
ClusterID string `json:"cluster_id,omitempty"`
Details string `json:"details,omitempty"`
}
// Repo writes and reads audit_log rows.
type Repo struct {
db *storage.DB
}
// NewRepo returns a repository bound to the supplied DB handle.
func NewRepo(db *storage.DB) *Repo {
return &Repo{db: db}
}
// Insert persists a single audit entry. A zero Timestamp is replaced with
// the current time before storage.
func (r *Repo) Insert(ctx context.Context, e *Entry) error {
if e.Timestamp.IsZero() {
e.Timestamp = time.Now()
}
var details any
if e.Details != "" {
details = e.Details
}
_, err := r.db.SQLDB().ExecContext(ctx, `
INSERT INTO audit_log (timestamp, actor, action, cluster_id, details)
VALUES (?, ?, ?, ?, ?)`,
e.Timestamp.UnixNano(), e.Actor, string(e.Action), e.ClusterID, details,
)
if err != nil {
return fmt.Errorf("audit: insert: %w", err)
}
return nil
}
// List returns the most recent audit entries ordered by timestamp descending.
// A limit of zero or less falls back to 100.
func (r *Repo) List(ctx context.Context, limit int) ([]*Entry, error) {
if limit <= 0 {
limit = 100
}
rows, err := r.db.SQLDB().QueryContext(ctx, `
SELECT id, timestamp, actor, action, cluster_id, details
FROM audit_log ORDER BY timestamp DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("audit: list: %w", err)
}
defer rows.Close()
var out []*Entry
for rows.Next() {
var e Entry
var ts int64
var clusterID, details sql.NullString
var action string
if err := rows.Scan(&e.ID, &ts, &e.Actor, &action, &clusterID, &details); err != nil {
return nil, fmt.Errorf("audit: scan: %w", err)
}
e.Timestamp = time.Unix(0, ts)
e.Action = Action(action)
if clusterID.Valid {
e.ClusterID = clusterID.String
}
if details.Valid {
e.Details = details.String
}
out = append(out, &e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("audit: rows: %w", err)
}
if out == nil {
// Force empty array (not null) in JSON.
out = []*Entry{}
}
return out, nil
}
// MarshalDetails encodes any value as a JSON string for Entry.Details.
func MarshalDetails(v any) (string, error) {
if v == nil {
return "", nil
}
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
// ErrNotFound is unused in this package (single-entry lookups are not
// supported), but keeps the errors import available for future use.
var _ = errors.New