From 2ccf041b35c5b94f7a5fe8c53ae12e09df1acf11 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Thu, 16 Jul 2026 10:55:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssf):=20=E5=A2=9E=E5=8A=A0=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=93=8D=E4=BD=9C=E5=AE=A1=E8=AE=A1=E4=B8=8E=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全事件连接的成功和运行时失败现在写入脱敏审计,响应携带 Trace ID 与 Audit ID,管理页面展示最近一次成功操作证据。审计仅记录连接状态、健康模式和错误类别,不采集授权头、Token、Bearer 或 Secret。\n\n验证:HTTP API、Store、Security Events Go 测试以及 Web Vitest、TypeScript 类型检查通过。 --- .../security_event_connection_handlers.go | 127 +++++++++++++++--- ...security_event_connection_handlers_test.go | 27 ++++ .../src/pages/admin/SystemSettingsPanel.tsx | 2 + packages/contracts/src/index.ts | 2 + 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go index bbd179a..ce0614c 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -9,8 +9,10 @@ import ( "strconv" "strings" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/google/uuid" ) type securityEventConnectionRequest struct { @@ -20,9 +22,12 @@ type securityEventConnectionRequest struct { type securityEventConnectionResponse struct { Connected bool `json:"connected"` Connection ssfreceiver.ConnectionView `json:"connection"` + TraceID string `json:"traceId,omitempty"` + AuditID string `json:"auditId,omitempty"` } func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) { + ensureSecurityEventTraceID(w, r) w.Header().Set("Cache-Control", "no-store") if s.securityEventManager == nil { writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) @@ -42,6 +47,7 @@ func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Reque } func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) { + traceID := ensureSecurityEventTraceID(w, r) if s.securityEventManager == nil { writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing") return @@ -68,49 +74,52 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque } connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, idempotencyKey) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "connect", traceID, err) return } - s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection) } func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) { - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify") + traceID := ensureSecurityEventTraceID(w, r) + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify", traceID) if !ok { return } connection, err := s.securityEventManager.Verify(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "verify", traceID, err) return } - s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection) } func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) { - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate") + traceID := ensureSecurityEventTraceID(w, r) + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate", traceID) if !ok { return } connection, err := s.securityEventManager.RotateCredential(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "rotate", traceID, err) return } - s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection) } func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) { - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect") + traceID := ensureSecurityEventTraceID(w, r) + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect", traceID) if !ok { return } connection, err := s.securityEventManager.Disconnect(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "disconnect", traceID, err) return } - s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection) } func (s *Server) securityEventPrerequisites() map[string]any { @@ -131,7 +140,7 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s return value, true } -func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation string) (string, string, bool) { +func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, traceID string) (string, string, bool) { if s.securityEventManager == nil { writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") return "", "", false @@ -146,7 +155,7 @@ func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Requ } connection, err := s.securityEventManager.Get(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, operation, traceID, err) return "", "", false } return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version) @@ -176,12 +185,19 @@ func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Req w.Header().Set("Cache-Control", "no-store") w.Header().Set("Idempotent-Replayed", "true") w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, payload.Connection.Version)) + if payload.AuditID != "" { + w.Header().Set("X-Audit-Id", payload.AuditID) + } writeJSON(w, http.StatusAccepted, payload) return true } -func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string, connection ssfreceiver.ConnectionView) { - payload := securityEventConnectionResponse{Connected: true, Connection: connection} +func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID string, connection ssfreceiver.ConnectionView) { + auditID := s.recordSecurityEventConnectionAudit(r, operation, "success", "", traceID, connection) + if auditID != "" { + w.Header().Set("X-Audit-Id", auditID) + } + payload := securityEventConnectionResponse{Connected: true, Connection: connection, TraceID: traceID, AuditID: auditID} encoded, _ := json.Marshal(payload) if s.store != nil { if err := s.store.RecordSecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey, requestHash, encoded); err != nil && s.logger != nil { @@ -220,15 +236,86 @@ func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int return true } -func writeSecurityEventConnectionError(w http.ResponseWriter, err error) { +func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, operation, traceID string, err error) { + status, message, code := securityEventConnectionErrorProjection(err) + connection := ssfreceiver.ConnectionView{} + if s.securityEventManager != nil { + connection, _ = s.securityEventManager.Get(r.Context()) + } + errorCategory := code + if connection.LastErrorCategory != nil && *connection.LastErrorCategory != "" { + errorCategory = *connection.LastErrorCategory + } + if auditID := s.recordSecurityEventConnectionAudit(r, operation, "failure", errorCategory, traceID, connection); auditID != "" { + w.Header().Set("X-Audit-Id", auditID) + } + writeError(w, status, message, code) +} + +func securityEventConnectionErrorProjection(err error) (int, string, string) { switch { case errors.Is(err, store.ErrSecurityEventConnectionNotFound): - writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") + return http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found" case errors.Is(err, store.ErrSecurityEventConnectionConflict): - writeError(w, http.StatusConflict, "security event connection conflicts with current state", "security_event_connection_conflict") + return http.StatusConflict, "security event connection conflicts with current state", "security_event_connection_conflict" case strings.Contains(err.Error(), "configured"), strings.Contains(err.Error(), "invalid"), strings.Contains(err.Error(), "HTTPS"): - writeError(w, http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing") + return http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing" default: - writeError(w, http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable") + return http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable" } } + +func ensureSecurityEventTraceID(w http.ResponseWriter, r *http.Request) string { + traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")) + if !validSecurityEventDiagnosticID(traceID) { + traceID = uuid.NewString() + } + w.Header().Set("X-Trace-Id", traceID) + return traceID +} + +func validSecurityEventDiagnosticID(value string) bool { + if len(value) < 8 || len(value) > 128 { + return false + } + for _, character := range value { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || strings.ContainsRune("-_.", character) { + continue + } + return false + } + return true +} + +func (s *Server) recordSecurityEventConnectionAudit(r *http.Request, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) string { + if s.store == nil { + return "" + } + actor, _ := auth.UserFromContext(r.Context()) + input := securityEventConnectionAuditInput(r, actor, operation, outcome, errorCategory, traceID, connection) + audit, err := s.store.RecordAuditLog(r.Context(), input) + if err != nil { + if s.logger != nil { + s.logger.WarnContext(r.Context(), "record security event connection audit failed", "operation", operation, "outcome", outcome, "error_category", "audit_store_failed", "trace_id", traceID) + } + return "" + } + return audit.ID +} + +func securityEventConnectionAuditInput(r *http.Request, actor *auth.User, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) store.AuditLogInput { + input := store.AuditLogInput{ + Category: "identity", Action: "identity.security_event_connection." + operation, + TargetType: "security_event_connection", TargetID: firstNonEmptyText(connection.ConnectionID, "singleton"), + RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512), + AfterState: map[string]any{"lifecycleStatus": connection.LifecycleStatus, "healthMode": connection.HealthMode}, + Metadata: map[string]any{"outcome": outcome, "errorCategory": errorCategory, "traceId": traceID}, + } + if actor != nil { + input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID)) + input.ActorUserID, input.ActorUsername, input.ActorSource = actor.ID, actor.Username, actor.Source + input.ActorRoles = actor.Roles + } + return input +} diff --git a/apps/api/internal/httpapi/security_event_connection_handlers_test.go b/apps/api/internal/httpapi/security_event_connection_handlers_test.go index f92ade8..f8b6a8c 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers_test.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers_test.go @@ -7,9 +7,36 @@ import ( "strings" "testing" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" ) +func TestSecurityEventConnectionTraceAndAuditProjection(t *testing.T) { + request := httptest.NewRequest(http.MethodPut, "/connection", nil) + request.Header.Set("Authorization", "Bearer must-not-escape") + recorder := httptest.NewRecorder() + traceID := ensureSecurityEventTraceID(recorder, request) + if traceID == "" || recorder.Header().Get("X-Trace-Id") != traceID { + t.Fatalf("trace header=%q trace=%q", recorder.Header().Get("X-Trace-Id"), traceID) + } + actor := &auth.User{ID: "admin-id", Username: "admin", Source: "local", Roles: []string{"manager"}} + connection := ssfreceiver.ConnectionView{ConnectionID: "connection-id", LifecycleStatus: "error", HealthMode: "introspection_fallback"} + input := securityEventConnectionAuditInput(request, actor, "connect", "failure", "management_token_failed", traceID, connection) + payload, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + if input.Action != "identity.security_event_connection.connect" || input.TargetID != "connection-id" || input.Metadata["traceId"] != traceID { + t.Fatalf("audit input=%#v", input) + } + for _, forbidden := range []string{"must-not-escape", "authorization_header", "push_bearer", "credential_ref"} { + if strings.Contains(strings.ToLower(string(payload)), forbidden) { + t.Fatalf("audit payload exposed forbidden field %q: %s", forbidden, payload) + } + } +} + func TestSecurityEventConnectionWriteHeaders(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/connection/verify", nil) recorder := httptest.NewRecorder() diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 37b20e2..dbc947f 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -471,6 +471,8 @@ function SecurityEventConnectionPanel(props: { Stream ID: {connection.streamId ?? '正在创建'} 健康模式: {securityEventHealthLabel(connection.healthMode)} 最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'} + {props.connection?.traceId && 最近操作 Trace ID: {props.connection.traceId}} + {props.connection?.auditId && 最近操作 Audit ID: {props.connection.auditId}} {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d229616..8b81266 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -980,6 +980,8 @@ export interface SecurityEventConnectionPrerequisites { export interface SecurityEventConnectionResponse { connected: boolean; + traceId?: string; + auditId?: string; lifecycleStatus?: 'disconnected' | string; connection?: SecurityEventConnection; prerequisites?: SecurityEventConnectionPrerequisites;