新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。 增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
235 lines
9.4 KiB
Go
235 lines
9.4 KiB
Go
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")
|
|
}
|
|
}
|