feat(ssf): 增加连接操作审计与追踪证据
安全事件连接的成功和运行时失败现在写入脱敏审计,响应携带 Trace ID 与 Audit ID,管理页面展示最近一次成功操作证据。审计仅记录连接状态、健康模式和错误类别,不采集授权头、Token、Bearer 或 Secret。\n\n验证:HTTP API、Store、Security Events Go 测试以及 Web Vitest、TypeScript 类型检查通过。
This commit is contained in:
parent
42ddc70b27
commit
2ccf041b35
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -471,6 +471,8 @@ function SecurityEventConnectionPanel(props: {
|
||||
<span>Stream ID: {connection.streamId ?? '正在创建'}</span>
|
||||
<span>健康模式: {securityEventHealthLabel(connection.healthMode)}</span>
|
||||
<span>最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'}</span>
|
||||
{props.connection?.traceId && <span>最近操作 Trace ID: {props.connection.traceId}</span>}
|
||||
{props.connection?.auditId && <span>最近操作 Audit ID: {props.connection.auditId}</span>}
|
||||
{connection.lastErrorCategory && <span>最近错误: {connection.lastErrorCategory}</span>}
|
||||
</div>
|
||||
<div className="fileStorageToolbar">
|
||||
|
||||
@ -980,6 +980,8 @@ export interface SecurityEventConnectionPrerequisites {
|
||||
|
||||
export interface SecurityEventConnectionResponse {
|
||||
connected: boolean;
|
||||
traceId?: string;
|
||||
auditId?: string;
|
||||
lifecycleStatus?: 'disconnected' | string;
|
||||
connection?: SecurityEventConnection;
|
||||
prerequisites?: SecurityEventConnectionPrerequisites;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user