feat(ssf): 实现安全事件一键连接与凭据托管

新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。

增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
This commit is contained in:
2026-07-15 17:25:00 +08:00
parent f30aaeb2d4
commit 9efeb16fd1
31 changed files with 2738 additions and 227 deletions
@@ -0,0 +1,234 @@
package httpapi
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type securityEventConnectionRequest struct {
TransmitterIssuer string `json:"transmitter_issuer"`
}
type securityEventConnectionResponse struct {
Connected bool `json:"connected"`
Connection ssfreceiver.ConnectionView `json:"connection"`
}
func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
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()})
return
}
connection, err := s.securityEventManager.Get(r.Context())
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
return
}
if err != nil {
writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable")
return
}
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version))
writeJSON(w, http.StatusOK, map[string]any{"connected": true, "connection": connection, "prerequisites": s.securityEventPrerequisites()})
}
func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
if s.securityEventManager == nil {
writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing")
return
}
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
if !ok {
return
}
var request securityEventConnectionRequest
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || strings.TrimSpace(request.TransmitterIssuer) == "" {
writeError(w, http.StatusBadRequest, "invalid security event connection request", "invalid_request")
return
}
request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/")
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer)
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
return
}
if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) {
return
}
connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, idempotencyKey)
if err != nil {
writeSecurityEventConnectionError(w, err)
return
}
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, connection)
}
func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) {
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify")
if !ok {
return
}
connection, err := s.securityEventManager.Verify(r.Context())
if err != nil {
writeSecurityEventConnectionError(w, err)
return
}
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, connection)
}
func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) {
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate")
if !ok {
return
}
connection, err := s.securityEventManager.RotateCredential(r.Context())
if err != nil {
writeSecurityEventConnectionError(w, err)
return
}
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, connection)
}
func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect")
if !ok {
return
}
connection, err := s.securityEventManager.Disconnect(r.Context())
if err != nil {
writeSecurityEventConnectionError(w, err)
return
}
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, connection)
}
func (s *Server) securityEventPrerequisites() map[string]any {
return map[string]any{
"oidcConfigured": s.cfg.OIDCEnabled && s.cfg.OIDCIssuer != "" && s.cfg.OIDCTenantID != "",
"introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "",
"publicBaseUrlConfigured": s.cfg.PublicBaseURL != "",
"managementClientId": s.cfg.OIDCIntrospectionClientID,
}
}
func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) {
value := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if value == "" || len(value) > 255 {
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "idempotency_key_required")
return "", false
}
return value, true
}
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation 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
}
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
if !ok {
return "", "", false
}
requestHash := securityEventOperationHash(operation, normalizedConnectionETag(r.Header.Get("If-Match")))
if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) {
return "", "", false
}
connection, err := s.securityEventManager.Get(r.Context())
if err != nil {
writeSecurityEventConnectionError(w, err)
return "", "", false
}
return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version)
}
func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string) bool {
if s.store == nil {
return false
}
record, err := s.store.SecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return false
}
if err != nil {
writeError(w, http.StatusServiceUnavailable, "security event idempotency state is unavailable", "security_event_state_unavailable")
return true
}
if record.RequestHash != requestHash {
writeError(w, http.StatusConflict, "Idempotency-Key was already used for a different request", "idempotency_key_reused")
return true
}
var payload securityEventConnectionResponse
if json.Unmarshal(record.Response, &payload) != nil {
writeError(w, http.StatusServiceUnavailable, "security event idempotency response is unavailable", "security_event_state_unavailable")
return true
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Idempotent-Replayed", "true")
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, payload.Connection.Version))
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}
encoded, _ := json.Marshal(payload)
if s.store != nil {
if err := s.store.RecordSecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey, requestHash, encoded); err != nil && s.logger != nil {
s.logger.Error("security event idempotency result could not be recorded", "error_category", "idempotency_store_failed", "operation", operation)
}
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version))
writeJSON(w, http.StatusAccepted, payload)
}
func securityEventOperationHash(operation, canonicalRequest string) string {
digest := sha256.Sum256([]byte(operation + "\x00" + canonicalRequest))
return fmt.Sprintf("%x", digest[:])
}
func normalizedConnectionETag(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "W/")
return strings.Trim(value, `"`)
}
func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int64) bool {
value := strings.TrimSpace(r.Header.Get("If-Match"))
value = strings.TrimPrefix(value, "W/")
value = strings.Trim(value, `"`)
version, err := strconv.ParseInt(value, 10, 64)
if err != nil {
writeError(w, http.StatusPreconditionRequired, "If-Match is required", "if_match_required")
return false
}
if version != expected {
writeError(w, http.StatusPreconditionFailed, "security event connection version changed", "version_conflict")
return false
}
return true
}
func writeSecurityEventConnectionError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrSecurityEventConnectionNotFound):
writeError(w, 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")
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")
default:
writeError(w, http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable")
}
}
@@ -0,0 +1,51 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
)
func TestSecurityEventConnectionWriteHeaders(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
recorder := httptest.NewRecorder()
if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest {
t.Fatalf("missing Idempotency-Key status=%d", recorder.Code)
}
request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
request.Header.Set("If-Match", `W/"7"`)
recorder = httptest.NewRecorder()
if !matchConnectionVersion(recorder, request, 7) {
t.Fatalf("valid weak ETag was rejected: status=%d", recorder.Code)
}
request.Header.Set("If-Match", `"6"`)
recorder = httptest.NewRecorder()
if matchConnectionVersion(recorder, request, 7) || recorder.Code != http.StatusPreconditionFailed {
t.Fatalf("stale ETag status=%d", recorder.Code)
}
if normalizedConnectionETag(`W/"7"`) != "7" ||
securityEventOperationHash("verify", "7") == securityEventOperationHash("verify", "8") {
t.Fatal("security event idempotency request fingerprint is not stable")
}
}
func TestSecurityEventPrerequisitesNeverExposeSecrets(t *testing.T) {
server := &Server{cfg: config.Config{
OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer/shared", OIDCTenantID: "stable-tenant",
OIDCIntrospectionClientID: "gateway-machine", OIDCIntrospectionClientSecret: "must-not-escape",
PublicBaseURL: "https://gateway.example",
}}
payload, err := json.Marshal(server.securityEventPrerequisites())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(payload), "must-not-escape") || strings.Contains(strings.ToLower(string(payload)), "secret") {
t.Fatalf("secret escaped in prerequisites: %s", payload)
}
}
@@ -18,5 +18,9 @@ import "net/http"
// @Failure 503 {object} map[string]string
// @Router /api/v1/security-events/ssf [post]
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
if s.securityEventReceiver == nil {
http.NotFound(w, r)
return
}
s.securityEventReceiver.ServeHTTP(w, r)
}
+42 -36
View File
@@ -30,6 +30,7 @@ type Server struct {
logger *slog.Logger
geminiUploadSessions sync.Map
securityEventReceiver http.Handler
securityEventManager *ssfreceiver.ConnectionManager
}
type oidcPublicClient interface {
@@ -66,46 +67,29 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
securityEventMetrics := &ssfreceiver.Metrics{}
var securityEventService *ssfreceiver.Service
if cfg.OIDCSecurityEventsEnabled {
ssfVerifier, err := ssfreceiver.NewVerifier(ssfreceiver.VerifierConfig{
TransmitterIssuer: cfg.OIDCSecurityEventsTransmitterIssuer,
Audience: cfg.OIDCSecurityEventsReceiverAudience, SubjectIssuer: cfg.OIDCIssuer,
TenantID: cfg.OIDCTenantID, StreamID: cfg.OIDCSecurityEventsStreamID,
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
})
if cfg.OIDCEnabled {
secretStore, err := securityEventSecretStore(cfg)
if err != nil {
panic("invalid OIDC security event verifier configuration: " + err.Error())
panic("invalid OIDC security event secret store: " + err.Error())
}
ssfVerifier.SetMetrics(securityEventMetrics)
securityEventService, err = ssfreceiver.NewService(db, ssfreceiver.ServiceConfig{
Issuer: cfg.OIDCSecurityEventsTransmitterIssuer, Audience: cfg.OIDCSecurityEventsReceiverAudience,
StreamID: cfg.OIDCSecurityEventsStreamID, ManagementTokenURL: cfg.OIDCSecurityEventsManagementTokenURL,
ManagementClientID: cfg.OIDCSecurityEventsManagementClientID, ManagementSecret: cfg.OIDCSecurityEventsManagementClientSecret,
manager, err := ssfreceiver.NewConnectionManager(ctx, db, secretStore, ssfreceiver.ConnectionManagerConfig{
AppEnv: cfg.AppEnv, OIDCEnabled: cfg.OIDCEnabled, OIDCIssuer: cfg.OIDCIssuer, OIDCTenantID: cfg.OIDCTenantID,
ManagementClientID: cfg.OIDCIntrospectionClientID, ManagementClientSecret: cfg.OIDCIntrospectionClientSecret,
PublicBaseURL: cfg.PublicBaseURL,
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
})
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
}, securityEventMetrics)
if err != nil {
panic("invalid OIDC security event heartbeat configuration: " + err.Error())
panic("initialize OIDC security event connection manager: " + err.Error())
}
securityEventService.SetMetrics(securityEventMetrics)
if err := securityEventService.Start(ctx); err != nil {
panic("OIDC security event state initialization failed")
}
receiver, err := ssfreceiver.NewHandler(
ssfVerifier, db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience,
cfg.OIDCSecurityEventsStreamID, cfg.OIDCSecurityEventsBearerSecret, cfg.OIDCSecurityEventsBearerSecretNext,
)
if err != nil {
panic("invalid OIDC security event receiver configuration: " + err.Error())
}
receiver.SetMetrics(securityEventMetrics)
server.securityEventReceiver = receiver
server.securityEventManager = manager
server.securityEventReceiver = manager
}
if cfg.OIDCEnabled {
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
if securityEventService != nil {
evaluator = securityEventService.Evaluate
if server.securityEventManager != nil {
evaluator = server.securityEventManager.Evaluate
}
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
@@ -164,7 +148,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.Handle("GET /metrics", securityEventMetrics.Handler(db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, cfg.OIDCSecurityEventsEnabled))
mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
@@ -175,9 +159,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
if server.securityEventReceiver != nil {
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
}
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels)))
@@ -247,6 +229,11 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings)))
mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings)))
mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection)))
mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection)))
mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection)))
mux.Handle("POST /api/admin/system/identity/security-events/connection/rotate-credential", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rotateSecurityEventConnectionCredential)))
mux.Handle("DELETE /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteSecurityEventConnection)))
mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels)))
mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel)))
mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel)))
@@ -324,6 +311,25 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
}
func securityEventSecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) {
switch strings.ToLower(strings.TrimSpace(cfg.OIDCSecurityEventsSecretStore)) {
case "", "file":
directory := cfg.OIDCSecurityEventsSecretDir
if directory == "" {
directory = ".local-secrets/ssf"
}
return ssfreceiver.NewFileSecretStore(directory)
case "kubernetes":
return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{
Namespace: cfg.OIDCSecurityEventsKubernetesNamespace, SecretName: cfg.OIDCSecurityEventsKubernetesSecretName,
APIServer: cfg.OIDCSecurityEventsKubernetesAPIServer, TokenFile: cfg.OIDCSecurityEventsKubernetesTokenFile,
CAFile: cfg.OIDCSecurityEventsKubernetesCAFile,
})
default:
return nil, errors.New("unsupported OIDC security event SecretStore")
}
}
func oidcSessionRequestError(err error) error {
switch {
case err == nil:
@@ -361,7 +367,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, If-Match, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {