You've already forked agentic-coding-workflow
159 lines
4.4 KiB
Go
159 lines
4.4 KiB
Go
// handler.go 暴露 admin-only 的审计日志查看接口(spec §11 步骤4)。
|
|
//
|
|
// GET /api/v1/admin/audit-logs?action=&action_prefix=&target_type=&target_id=
|
|
// &user_id=&from=&to=&limit=&offset=
|
|
//
|
|
// 鉴权:复刻 chat.adminGuard —— Auth 中间件解出 uid,再经 AdminLookup.IsAdmin
|
|
// 门禁;非 admin 返回 403。from/to 接受 RFC3339 时间字符串。
|
|
package audit
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
|
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
|
)
|
|
|
|
// AdminLookup resolves whether a user is an admin (narrow interface; mirrors
|
|
// chat/acp handler pattern). user.Service satisfies it.
|
|
type AdminLookup interface {
|
|
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// Handler exposes the admin audit-log read endpoint.
|
|
type Handler struct {
|
|
reader Reader
|
|
resolver middleware.SessionResolver
|
|
admin AdminLookup
|
|
}
|
|
|
|
// NewHandler constructs the audit read handler.
|
|
func NewHandler(reader Reader, resolver middleware.SessionResolver, admin AdminLookup) *Handler {
|
|
return &Handler{reader: reader, resolver: resolver, admin: admin}
|
|
}
|
|
|
|
// Mount registers the admin audit routes under /api/v1/admin.
|
|
func (h *Handler) Mount(r chi.Router) {
|
|
r.Route("/api/v1/admin/audit-logs", func(r chi.Router) {
|
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
|
r.Use(h.adminGuard)
|
|
r.Get("/", h.list)
|
|
})
|
|
}
|
|
|
|
func (h *Handler) adminGuard(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
uid, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
h.writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized"))
|
|
return
|
|
}
|
|
isAdmin, err := h.admin.IsAdmin(r.Context(), uid)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
if !isAdmin {
|
|
h.writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type auditLogDTO struct {
|
|
ID int64 `json:"id"`
|
|
UserID *string `json:"user_id,omitempty"`
|
|
Action string `json:"action"`
|
|
TargetType string `json:"target_type,omitempty"`
|
|
TargetID string `json:"target_id,omitempty"`
|
|
IP string `json:"ip,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
f := AuditFilter{
|
|
Action: q.Get("action"),
|
|
ActionPrefix: q.Get("action_prefix"),
|
|
TargetType: q.Get("target_type"),
|
|
TargetID: q.Get("target_id"),
|
|
}
|
|
if v := q.Get("user_id"); v != "" {
|
|
uid, err := uuid.Parse(v)
|
|
if err != nil {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 格式错误"))
|
|
return
|
|
}
|
|
f.UserID = &uid
|
|
}
|
|
if v := q.Get("from"); v != "" {
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "from 须为 RFC3339"))
|
|
return
|
|
}
|
|
f.From = &t
|
|
}
|
|
if v := q.Get("to"); v != "" {
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "to 须为 RFC3339"))
|
|
return
|
|
}
|
|
f.To = &t
|
|
}
|
|
if v := q.Get("limit"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "limit 须为非负整数"))
|
|
return
|
|
}
|
|
f.Limit = n
|
|
}
|
|
if v := q.Get("offset"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "offset 须为非负整数"))
|
|
return
|
|
}
|
|
f.Offset = n
|
|
}
|
|
|
|
entries, total, err := h.reader.List(r.Context(), f)
|
|
if err != nil {
|
|
h.writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]auditLogDTO, 0, len(entries))
|
|
for _, e := range entries {
|
|
dto := auditLogDTO{
|
|
ID: e.ID,
|
|
Action: e.Action,
|
|
TargetType: e.TargetType,
|
|
TargetID: e.TargetID,
|
|
IP: e.IP,
|
|
Metadata: e.Metadata,
|
|
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
if e.UserID != nil {
|
|
s := e.UserID.String()
|
|
dto.UserID = &s
|
|
}
|
|
out = append(out, dto)
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": out, "total": total})
|
|
}
|
|
|
|
func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
|
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
|
}
|