package httpapi import ( "crypto/sha256" "encoding/json" "errors" "fmt" "net/http" "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 { TransmitterIssuer string `json:"transmitter_issuer"` ManagementClientID string `json:"management_client_id"` ManagementClientSecret string `json:"management_client_secret"` } 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()}) 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) { 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 } 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), "/") request.ManagementClientID = strings.TrimSpace(request.ManagementClientID) if (request.ManagementClientID == "") != (request.ManagementClientSecret == "") || len(request.ManagementClientID) > 200 || len(request.ManagementClientSecret) > 512 { writeError(w, http.StatusBadRequest, "machine Client ID and Secret must be provided together", "invalid_machine_credential") return } secret := []byte(request.ManagementClientSecret) defer clear(secret) secretDigest := sha256.Sum256(secret) requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:])) 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, request.ManagementClientID, secret, idempotencyKey) if err != nil { s.writeSecurityEventConnectionError(w, r, "connect", traceID, err) return } s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection) } func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) { 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 { s.writeSecurityEventConnectionError(w, r, "verify", traceID, err) return } s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection) } func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) { 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 { s.writeSecurityEventConnectionError(w, r, "rotate", traceID, err) return } s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection) } func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) { 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 { s.writeSecurityEventConnectionError(w, r, "disconnect", traceID, err) return } s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, 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, "credentialInputSupported": true, } } 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, 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 } 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 { s.writeSecurityEventConnectionError(w, r, operation, traceID, 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)) 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, 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 { 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 (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): return http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found" case errors.Is(err, store.ErrSecurityEventConnectionConflict): 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"): return http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing" default: 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 }