diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..9502f28 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,101 @@ +// Package audit 提供登录、登出、关键管理动作等敏感事件的审计记录器。 +// Recorder 接口与具体存储解耦,user 模块只依赖接口;NewPostgresRecorder +// 是默认实现,把每条 Entry 转成 audit_logs 行写入。 +// +// IP 解析:调用方通常直接传 r.RemoteAddr(host:port 形式),Record 内部 +// 用 net.SplitHostPort + netip.ParseAddr 拆解。解析失败时不阻断主流程, +// 只把 ip 列写为 NULL,避免审计写失败影响业务。 +package audit + +import ( + "context" + "encoding/json" + "net" + "net/netip" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + auditsqlc "github.com/yan1h/agent-coding-workflow/internal/audit/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// Recorder 是审计记录的对外契约。Record 失败时返回 errs.AppError, +// 调用方决定是否吞错(user 模块通常吞错并仅记日志)。 +type Recorder interface { + Record(ctx context.Context, e Entry) error +} + +// Entry 描述一条待写入的审计事件。UserID 为 nil 表示无登录上下文(如系统 +// 维护任务);Action 为 dot.notation 字符串("user.login" / "user.logout" +// 等),TargetType/TargetID 描述事件指向的对象。 +type Entry struct { + UserID *uuid.UUID + Action string + TargetType string + TargetID string + IP string + Metadata map[string]any +} + +type pgRecorder struct { + q *auditsqlc.Queries +} + +// NewPostgresRecorder 用现有 pgxpool 构造 Recorder 实现。 +func NewPostgresRecorder(pool *pgxpool.Pool) Recorder { + return &pgRecorder{q: auditsqlc.New(pool)} +} + +// Record 把 Entry 落地为 audit_logs 一行。 +func (r *pgRecorder) Record(ctx context.Context, e Entry) error { + var meta []byte + if e.Metadata != nil { + b, err := json.Marshal(e.Metadata) + if err != nil { + return errs.Wrap(err, errs.CodeInternal, "marshal audit metadata") + } + meta = b + } + + var ip *netip.Addr + if e.IP != "" { + host, _, err := net.SplitHostPort(e.IP) + if err != nil { + host = e.IP + } + if a, err := netip.ParseAddr(host); err == nil { + ip = &a + } + } + + if err := r.q.InsertAuditLog(ctx, auditsqlc.InsertAuditLogParams{ + UserID: pgUUIDFromPtr(e.UserID), + Action: e.Action, + TargetType: ptr(e.TargetType), + TargetID: ptr(e.TargetID), + Ip: ip, + Metadata: meta, + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "insert audit") + } + return nil +} + +// pgUUIDFromPtr 把可空的领域 *uuid.UUID 转为 sqlc 期望的 pgtype.UUID。 +// nil 映射为 Valid=false(DB NULL),非 nil 映射为 Valid=true + Bytes。 +func pgUUIDFromPtr(u *uuid.UUID) pgtype.UUID { + if u == nil { + return pgtype.UUID{Valid: false} + } + return pgtype.UUID{Bytes: *u, Valid: true} +} + +// ptr 把空字符串映射为 nil,使数据库列写入 NULL;其它字符串原样取地址。 +func ptr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/internal/audit/queries/audit.sql b/internal/audit/queries/audit.sql index 9bd5c10..285a0d1 100644 --- a/internal/audit/queries/audit.sql +++ b/internal/audit/queries/audit.sql @@ -1,2 +1,3 @@ --- name: Ping :one -SELECT 1::int AS ok; +-- name: InsertAuditLog :exec +INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata) +VALUES ($1, $2, $3, $4, $5, $6); diff --git a/internal/audit/sqlc/audit.sql.go b/internal/audit/sqlc/audit.sql.go index 15ad5d5..a75fe48 100644 --- a/internal/audit/sqlc/audit.sql.go +++ b/internal/audit/sqlc/audit.sql.go @@ -7,15 +7,33 @@ package auditsqlc import ( "context" + "net/netip" + + "github.com/jackc/pgx/v5/pgtype" ) -const ping = `-- name: Ping :one -SELECT 1::int AS ok +const insertAuditLog = `-- name: InsertAuditLog :exec +INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata) +VALUES ($1, $2, $3, $4, $5, $6) ` -func (q *Queries) Ping(ctx context.Context) (int32, error) { - row := q.db.QueryRow(ctx, ping) - var ok int32 - err := row.Scan(&ok) - return ok, err +type InsertAuditLogParams struct { + UserID pgtype.UUID `json:"user_id"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Ip *netip.Addr `json:"ip"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error { + _, err := q.db.Exec(ctx, insertAuditLog, + arg.UserID, + arg.Action, + arg.TargetType, + arg.TargetID, + arg.Ip, + arg.Metadata, + ) + return err }