From 9e43d07428ae23700ecbf4cecdf979c24bc84ec6 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Thu, 2 Jul 2026 09:31:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E6=8A=A4=E6=A0=8F=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E4=BA=8B=E4=BB=B6=E8=90=BD=E5=BA=93=20+=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BA=8B=E4=BB=B6=E6=B5=81=EF=BC=88T4.B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.GuardrailEvent 表(sundynix_guardrail_event) + AppendGuardrailEvent/ListGuardrailEvents - middleware.Guardrail(db):命中 blocked/suspect 时 best-effort 落库 (actor/kind/reason/signals/method/path/ip,独立超时 ctx) - GET /api/v1/admin/guardrail-events:安全事件流(倒序,翻页) - store.clampPage 抽出分页归一(audit/guardrail 共用) - live:注入 "ignore all previous instructions" → 422 硬拦 + 事件留痕(kind=blocked) - DEPTH_ROADMAP T4.B 护栏事件打勾 Co-Authored-By: Claude Opus 4.8 --- DEPTH_ROADMAP.md | 2 +- sundynix-gateway/internal/handler/admin.go | 28 +++++++++++++++++ .../internal/middleware/guardrail.go | 29 ++++++++++++++++- sundynix-gateway/internal/router/router.go | 5 +-- sundynix-gateway/internal/store/audit.go | 31 +++++++++++++++++-- sundynix-gateway/internal/store/models.go | 15 +++++++++ sundynix-gateway/internal/store/pgsql.go | 2 +- 7 files changed, 104 insertions(+), 8 deletions(-) diff --git a/DEPTH_ROADMAP.md b/DEPTH_ROADMAP.md index f34d76e..5830b24 100644 --- a/DEPTH_ROADMAP.md +++ b/DEPTH_ROADMAP.md @@ -168,7 +168,7 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级* ### [~] 🔴 T4.B 审计 / 可溯源 + 管理端聚合(推荐先做,内聚且喂管理端) - [x] `GET /api/v1/admin/overview`(RequireAdmin) 系统级聚合 ✅ —— 全平台用户/KB/任务/评测/模型态/prompt 覆盖/健康;概览页已切过去(5调用→2),live 验证 users=7/kb 22/50 全平台口径。 - [x] `audit_log` 表 + 中间件 ✅ —— `store.AuditLog` + `middleware.Audit(db)` 挂管理组 + prompt 激活/撤销 + HITL 审批;只审计变更类(POST/PUT/DELETE/PATCH),best-effort 落库不拖垮主流程;`GET /admin/audit` 列表(倒序翻页)。live:PUT pricing / POST deactivate 留痕,GET 不记。 -- [ ] 护栏拦截事件落库 `guardrail_event`(middleware/guardrail.go:29 现只打日志,无法复盘"谁触发多少次")| M +- [x] 护栏拦截事件落库 `guardrail_event` ✅ —— Guardrail 中间件命中(blocked/suspect)best-effort 落库(actor/kind/reason/signals/path/ip) + `GET /admin/guardrail-events`;live:注入 payload→422 硬拦+事件留痕。 - [ ] HITL 审批决定明细落库(现审批已进 audit_log 但只有 who/when/status,无 approve/reject 决定与理由;可 enrich audit Detail 或单独表)| S - [ ] 前端:管理端加「审计流 / 安全事件」页或概览接 /admin/audit(把留痕可视化)| S diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index 86a574e..222104d 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -45,6 +45,34 @@ func (h *Handler) AuditList(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"logs": out}) } +// GuardrailEvents: GET /api/v1/admin/guardrail-events?limit=&offset= —— 护栏命中安全事件流(倒序)。 +func (h *Handler) GuardrailEvents(c *gin.Context) { + limit, offset := 50, 0 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + limit = n + } + } + if v := c.Query("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + offset = n + } + } + rows, err := h.db.ListGuardrailEvents(c.Request.Context(), limit, offset) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + out := make([]gin.H, 0, len(rows)) + for _, e := range rows { + out = append(out, gin.H{ + "id": e.ID, "actor": e.Actor, "kind": e.Kind, "reason": e.Reason, + "signals": e.Signals, "method": e.Method, "path": e.Path, "ip": e.IP, "at": e.CreatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"events": out}) +} + // AdminOverview: GET /api/v1/admin/overview —— 管理端系统级聚合(控制塔口径)。 // 区别于 stats/overview(桌面端个人工作台):这里一律系统级——全平台用户/任务/评测/ // 模型配置态/提示词控制面态/服务健康。Task/Eval 表无 owner 即全量;用户/KB/Doc 走全局计数。 diff --git a/sundynix-gateway/internal/middleware/guardrail.go b/sundynix-gateway/internal/middleware/guardrail.go index a1f33c5..14ddf73 100644 --- a/sundynix-gateway/internal/middleware/guardrail.go +++ b/sundynix-gateway/internal/middleware/guardrail.go @@ -3,6 +3,8 @@ package middleware import ( "bytes" + "context" + "encoding/json" "io" "log" "net/http" @@ -19,7 +21,8 @@ import ( // 只检查带 JSON 体的写请求(POST/PUT);文件上传(multipart)与 GET/SSE 不经此。 // 输出护栏不在此做 —— Token 流为 SSE 实时流,网关缓冲会破坏流式,输出过滤应在 // dispatcher 的 token 发射层(见 PROGRESS 路线图)。 -func Guardrail() gin.HandlerFunc { +// 命中(blocked/suspect)除打日志外,best-effort 落库 guardrail_event 供安全溯源。 +func Guardrail(db *store.Postgres) gin.HandlerFunc { return func(c *gin.Context) { if m := c.Request.Method; (m == http.MethodPost || m == http.MethodPut) && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { @@ -28,12 +31,14 @@ func Guardrail() gin.HandlerFunc { res := guardrail.Inspect(body) if res.Blocked { log.Printf("[guardrail] 拦截 %s %s:%s", c.Request.Method, c.Request.URL.Path, res.Reason) + recordGuardrail(c, db, "blocked", res.Reason, res.Signals) c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "输入护栏拦截:" + res.Reason}) return } if res.Suspect { // 灰区:放行但打标,交 Dispatcher 的 LLM 分类器(Tier2)裁决 log.Printf("[guardrail] 灰区放行 %s %s:软信号 %v", c.Request.Method, c.Request.URL.Path, res.Signals) c.Set("guardrail_suspect", true) + recordGuardrail(c, db, "suspect", res.Reason, res.Signals) } c.Request.Body = io.NopCloser(bytes.NewReader(body)) // 还原请求体供后续 handler 读取 } @@ -41,6 +46,28 @@ func Guardrail() gin.HandlerFunc { } } +// recordGuardrail best-effort 落库一条护栏事件(独立超时 ctx,失败静默)。 +func recordGuardrail(c *gin.Context, db *store.Postgres, kind, reason string, signals []string) { + if db == nil { + return + } + uid, _ := c.Get(CtxUserID) + actor, _ := uid.(string) + sig := "" + if len(signals) > 0 { + if b, err := json.Marshal(signals); err == nil { + sig = string(b) + } + } + e := &store.GuardrailEvent{ + Actor: actor, Kind: kind, Reason: reason, Signals: sig, + Method: c.Request.Method, Path: c.Request.URL.Path, IP: c.ClientIP(), + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = db.AppendGuardrailEvent(ctx, e) +} + // RateLimit 基于 Redis 的会话级限流(按客户端 IP,每分钟上限)。 // 上限经 RATE_LIMIT_PER_MIN 配置(缺省 120);压测可调高。Redis 降级时始终放行,不阻断业务。 func RateLimit(cache *store.Redis) gin.HandlerFunc { diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 21f9f92..1063742 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -25,7 +25,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. r.Use(cors()) // 桌面端/浏览器跨源访问 r.Use(middleware.RateLimit(cache)) r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断) - r.Use(middleware.Guardrail()) // Harness: Input Guardrail + r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event) h := handler.New(db, cache, bus, blobStore) @@ -96,7 +96,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 - admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页) + admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页) + admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页) } } return r diff --git a/sundynix-gateway/internal/store/audit.go b/sundynix-gateway/internal/store/audit.go index db0e10b..c85766d 100644 --- a/sundynix-gateway/internal/store/audit.go +++ b/sundynix-gateway/internal/store/audit.go @@ -15,13 +15,38 @@ func (p *Postgres) ListAudit(ctx context.Context, limit, offset int) ([]AuditLog if p.db == nil { return nil, errStoreDisabled } + limit, offset = clampPage(limit, offset) + var out []AuditLog + err := p.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error + return out, err +} + +// AppendGuardrailEvent 追加一条护栏命中(best-effort)。 +func (p *Postgres) AppendGuardrailEvent(ctx context.Context, e *GuardrailEvent) error { + if p.db == nil || e == nil { + return errStoreDisabled + } + return p.db.WithContext(ctx).Create(e).Error +} + +// ListGuardrailEvents 倒序列出护栏事件(管理端安全事件流)。 +func (p *Postgres) ListGuardrailEvents(ctx context.Context, limit, offset int) ([]GuardrailEvent, error) { + if p.db == nil { + return nil, errStoreDisabled + } + limit, offset = clampPage(limit, offset) + var out []GuardrailEvent + err := p.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error + return out, err +} + +// clampPage 归一分页参数:limit ∈ [1,200](默认 50),offset ≥ 0。 +func clampPage(limit, offset int) (int, int) { if limit <= 0 || limit > 200 { limit = 50 } if offset < 0 { offset = 0 } - var out []AuditLog - err := p.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error - return out, err + return limit, offset } diff --git a/sundynix-gateway/internal/store/models.go b/sundynix-gateway/internal/store/models.go index 90921fb..70483f9 100644 --- a/sundynix-gateway/internal/store/models.go +++ b/sundynix-gateway/internal/store/models.go @@ -57,3 +57,18 @@ type AuditLog struct { } func (AuditLog) TableName() string { return "sundynix_audit_log" } + +// GuardrailEvent 是一次输入护栏命中(硬拦截 blocked / 灰区 suspect)。 +// 由 Guardrail 中间件命中时 best-effort 写入;供安全溯源"谁触发多少次护栏"。 +type GuardrailEvent struct { + BaseModel + Actor string `gorm:"size:64;index"` // 操作者 uid(未登录留空) + Kind string `gorm:"size:16;index"` // blocked(硬拦)/ suspect(灰区放行) + Reason string `gorm:"size:256"` // 拦截原因(blocked) + Signals string `gorm:"type:text"` // 命中软信号 JSON 数组(suspect) + Method string `gorm:"size:8"` + Path string `gorm:"size:256"` + IP string `gorm:"size:64"` +} + +func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" } diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 455a1db..2006d1d 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres { migrateLegacyIntIDs(db) migrateDocLinkToID(db) - if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}); err != nil { + if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}); err != nil { log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) return &Postgres{} }