You've already forked agentic-coding-workflow
feat(audit): recorder with pg implementation and ip parsing
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user