You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user