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
+10 -4
View File
@@ -48,6 +48,7 @@ type OIDCSecurityEventIdentity struct {
}
type OIDCSecurityEventEvaluation struct {
Enabled bool
Revoked bool
RequireIntrospection bool
}
@@ -91,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if (config.IntrospectionEnabled || config.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
@@ -145,9 +146,6 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
return nil, oidcUnauthorized("nbf is missing", nil)
}
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
if v.config.SecurityEventEvaluator != nil && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
scopes := scopeClaims(claims)
if !containsAll(scopes, v.config.RequiredScopes) {
return nil, oidcUnauthorized("required scope is missing", nil)
@@ -166,6 +164,9 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
}
if evaluation.Enabled && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
if evaluation.Revoked {
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
}
@@ -177,6 +178,11 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if !active {
return nil, oidcUnauthorized("token is inactive", nil)
}
} else if !evaluation.Enabled && v.config.IntrospectionEnabled {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil || !active {
return nil, oidcUnauthorized("token is inactive", introspectionErr)
}
}
} else if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
+16 -1
View File
@@ -179,9 +179,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
verifier, err := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true,
IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret",
SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) {
if identity.Subject != "platform-subject" || identity.IssuedAt.IsZero() {
if identity.Subject != "platform-subject" {
t.Fatal("security event evaluator received incomplete identity")
}
return evaluation, nil
@@ -194,6 +195,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("healthy push token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Revoked: true}
if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 {
t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls)
@@ -205,6 +210,16 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable {
t.Fatalf("introspection outage error=%T %v", err, err)
}
withoutIssuedAt := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { delete(claims, "iat") })
evaluation = OIDCSecurityEventEvaluation{}
introspectionAvailable = true
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err != nil {
t.Fatalf("unconfigured security events unexpectedly required iat: %v", err)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err == nil {
t.Fatal("configured security events accepted a token without iat")
}
}
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
+40 -94
View File
@@ -8,8 +8,6 @@ import (
"os"
"strconv"
"strings"
"github.com/google/uuid"
)
const (
@@ -38,16 +36,13 @@ type Config struct {
OIDCIntrospectionEnabled bool
OIDCIntrospectionClientID string
OIDCIntrospectionClientSecret string
OIDCSecurityEventsEnabled bool
OIDCSecurityEventsTransmitterIssuer string
OIDCSecurityEventsReceiverAudience string
OIDCSecurityEventsBearerSecret string
OIDCSecurityEventsBearerSecretNext string
OIDCSecurityEventsPublicEndpoint string
OIDCSecurityEventsStreamID string
OIDCSecurityEventsManagementTokenURL string
OIDCSecurityEventsManagementClientID string
OIDCSecurityEventsManagementClientSecret string
OIDCSecurityEventsSecretStore string
OIDCSecurityEventsSecretDir string
OIDCSecurityEventsKubernetesNamespace string
OIDCSecurityEventsKubernetesSecretName string
OIDCSecurityEventsKubernetesAPIServer string
OIDCSecurityEventsKubernetesTokenFile string
OIDCSecurityEventsKubernetesCAFile string
OIDCSecurityEventsHeartbeatIntervalSeconds int
OIDCSecurityEventsStaleAfterSeconds int
OIDCSecurityEventsClockSkewSeconds int
@@ -80,6 +75,9 @@ type Config struct {
func Load() Config {
globalProxy := LoadGlobalHTTPProxyStatus()
appEnv := env("APP_ENV", "development")
oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/")
introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "")
introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "")
return Config{
AppEnv: appEnv,
HTTPAddr: env("HTTP_ADDR", ":8088"),
@@ -94,7 +92,7 @@ func Load() Config {
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
OIDCIssuer: oidcIssuer,
OIDCAudience: env("OIDC_AUDIENCE", ""),
OIDCTenantID: env("OIDC_TENANT_ID", ""),
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
@@ -102,18 +100,15 @@ func Load() Config {
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true",
OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"),
OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""),
OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""),
OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""),
OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""),
OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""),
OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""),
OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""),
OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""),
OIDCIntrospectionClientID: introspectionClientID,
OIDCIntrospectionClientSecret: introspectionClientSecret,
OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"),
OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"),
OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")),
OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"),
OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"),
OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
@@ -149,6 +144,26 @@ func Load() Config {
}
func (c Config) Validate() error {
switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) {
case "":
case "file":
if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" {
return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore")
}
case "kubernetes":
if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" {
return errors.New("Kubernetes security event SecretStore requires namespace and Secret name")
}
default:
return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes")
}
if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 {
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
}
}
if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" {
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
}
@@ -185,59 +200,9 @@ func (c Config) Validate() error {
}
}
}
if c.OIDCSecurityEventsEnabled {
if !c.OIDCEnabled {
return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled")
}
if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) ||
!validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) ||
!validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) {
return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs")
}
if _, err := uuid.Parse(c.OIDCTenantID); err != nil {
return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled")
}
applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:")
if applicationID == c.OIDCSecurityEventsReceiverAudience {
return errors.New("OIDC security event receiver audience and stream ID are required")
}
if _, err := uuid.Parse(applicationID); err != nil {
return errors.New("OIDC security event receiver audience must contain an application UUID")
}
if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil {
return errors.New("OIDC security event stream ID must be a UUID")
}
if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) ||
c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) {
return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters")
}
if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" {
return errors.New("OIDC security event management machine client credentials are required")
}
if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" {
return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled")
}
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
}
}
return nil
}
func validBearerSecret(value string) bool {
if len(value) < 16 || len(value) > 4096 {
return false
}
for _, character := range []byte(value) {
if character < 0x21 || character > 0x7e {
return false
}
}
return true
}
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
@@ -260,25 +225,6 @@ func validPublicRedirectURL(raw string) bool {
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validServiceURL(raw, appEnv string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return false
}
if parsed.Scheme == "https" {
return true
}
return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validSecurityEventReceiverURL(raw, appEnv string) bool {
if !validServiceURL(raw, appEnv) {
return false
}
parsed, err := url.Parse(strings.TrimSpace(raw))
return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == ""
}
func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
+37 -24
View File
@@ -137,33 +137,46 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
}
}
func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) {
func TestSecurityEventsUseDurableConnectionInsteadOfAnEnableFlag(t *testing.T) {
t.Setenv("OIDC_SECURITY_EVENTS_ENABLED", "true")
t.Setenv("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", "https://untrusted.example/ssf")
t.Setenv("OIDC_SECURITY_EVENTS_BEARER_SECRET", "must-not-be-loaded")
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_STORE", "file")
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_DIR", ".test-secrets/ssf")
cfg := Load()
if cfg.OIDCSecurityEventsSecretStore != "file" || cfg.OIDCSecurityEventsSecretDir != ".test-secrets/ssf" {
t.Fatalf("secret directory=%q", cfg.OIDCSecurityEventsSecretDir)
}
if err := (Config{}).Validate(); err != nil {
t.Fatalf("disabled security events changed existing config: %v", err)
t.Fatalf("an absent security event connection changed existing config: %v", err)
}
cfg := Config{AppEnv: "test", OIDCEnabled: true, OIDCSecurityEventsEnabled: true}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "issuer") {
t.Fatalf("partial security event config error = %v", err)
}
func TestValidateKubernetesSecurityEventSecretStore(t *testing.T) {
config := Config{OIDCSecurityEventsSecretStore: "kubernetes", OIDCSecurityEventsKubernetesSecretName: "easyai-gateway-security-events"}
if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") {
t.Fatalf("Validate() error=%v, want missing namespace", err)
}
cfg.OIDCSecurityEventsTransmitterIssuer = "http://localhost:8080/ssf"
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/api/v1/security-events/ssf"
cfg.OIDCSecurityEventsManagementTokenURL = "http://localhost:8080/oauth/token"
cfg.OIDCTenantID = "00000000-0000-4000-8000-000000000003"
cfg.OIDCSecurityEventsReceiverAudience = "urn:easyai:ssf:receiver:00000000-0000-4000-8000-000000000002"
cfg.OIDCSecurityEventsStreamID = "00000000-0000-4000-8000-000000000001"
cfg.OIDCSecurityEventsBearerSecret = "receiver-secret-at-least-16"
cfg.OIDCSecurityEventsManagementClientID = "gateway-ssf"
cfg.OIDCSecurityEventsManagementClientSecret = "management-secret"
cfg.OIDCIntrospectionClientID = "gateway-introspection"
cfg.OIDCIntrospectionClientSecret = "introspection-secret"
cfg.OIDCSecurityEventsHeartbeatIntervalSeconds = 60
cfg.OIDCSecurityEventsStaleAfterSeconds = 180
cfg.OIDCSecurityEventsClockSkewSeconds = 60
if err := cfg.Validate(); err != nil {
t.Fatalf("valid security event config: %v", err)
config.OIDCSecurityEventsKubernetesNamespace = "easyai"
if err := config.Validate(); err != nil {
t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err)
}
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/wrong-path"
if err := cfg.Validate(); err == nil {
t.Fatal("non-canonical security event receiver path was accepted")
}
func TestValidateSecurityEventTiming(t *testing.T) {
config := Config{OIDCSecurityEventsHeartbeatIntervalSeconds: 60, OIDCSecurityEventsStaleAfterSeconds: 60, OIDCSecurityEventsClockSkewSeconds: 60}
if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") {
t.Fatalf("Validate() error=%v, want invalid stale threshold", err)
}
}
func TestLoadSecurityEventsReusesOnlyTheRFC7662MachineClient(t *testing.T) {
t.Setenv("OIDC_INTROSPECTION_CLIENT_ID", "gateway-service")
t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "service-secret")
cfg := Load()
if cfg.OIDCIntrospectionClientID != "gateway-service" || cfg.OIDCIntrospectionClientSecret != "service-secret" {
t.Fatal("RFC 7662 machine credentials were not preserved")
}
}
@@ -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 {
@@ -0,0 +1,940 @@
package securityevents
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
const (
sessionRevokedEvent = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
pushDeliveryMethod = "urn:ietf:rfc:8935"
)
type ConnectionRepository interface {
Repository
StateRepository
CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error)
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error)
UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error)
SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error)
PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error)
DeleteSecurityEventConnection(context.Context, string) error
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
}
type ConnectionManagerConfig struct {
AppEnv string
OIDCEnabled bool
OIDCIssuer string
OIDCTenantID string
ManagementClientID string
ManagementClientSecret string
PublicBaseURL string
HeartbeatInterval time.Duration
StaleAfter time.Duration
ClockSkew time.Duration
BootstrapDuration time.Duration
CredentialOverlapDuration time.Duration
RetirementDuration time.Duration
HTTPClient *http.Client
}
type ConnectionView struct {
ConnectionID string `json:"connectionId"`
TransmitterIssuer string `json:"transmitterIssuer"`
ReceiverEndpoint string `json:"receiverEndpoint"`
Audience *string `json:"audience,omitempty"`
StreamID *string `json:"streamId,omitempty"`
LifecycleStatus string `json:"lifecycleStatus"`
HealthMode string `json:"healthMode"`
ManagementClientID string `json:"managementClientId"`
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
Version int64 `json:"version"`
}
type connectionRuntime struct {
handler *Handler
service *Service
cancel context.CancelFunc
}
type ConnectionManager struct {
ctx context.Context
repository ConnectionRepository
secrets SecretStore
config ConnectionManagerConfig
metrics *Metrics
mutex sync.RWMutex
operation sync.Mutex
runtime *connectionRuntime
}
type transmitterConfiguration struct {
SpecVersion string `json:"spec_version"`
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
ConfigurationEndpoint string `json:"configuration_endpoint"`
StatusEndpoint string `json:"status_endpoint"`
VerificationEndpoint string `json:"verification_endpoint"`
DeliveryMethodsSupported []string `json:"delivery_methods_supported"`
}
type streamConfiguration struct {
StreamID string `json:"stream_id"`
Issuer string `json:"iss"`
Audience string `json:"aud"`
EventsRequested []string `json:"events_requested"`
Delivery struct {
Method string `json:"method"`
EndpointURL string `json:"endpoint_url"`
} `json:"delivery"`
Description *string `json:"description,omitempty"`
}
type remoteHTTPError struct{ status int }
func (e remoteHTTPError) Error() string {
return fmt.Sprintf("SSF endpoint returned HTTP %d", e.status)
}
func NewConnectionManager(ctx context.Context, repository ConnectionRepository, secrets SecretStore, config ConnectionManagerConfig, metrics *Metrics) (*ConnectionManager, error) {
if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || config.OIDCTenantID == "" {
return nil, errors.New("security event connection prerequisites are incomplete")
}
if config.HeartbeatInterval <= 0 {
config.HeartbeatInterval = time.Minute
}
if config.StaleAfter < 2*config.HeartbeatInterval {
config.StaleAfter = 3 * time.Minute
}
if config.ClockSkew == 0 {
config.ClockSkew = time.Minute
}
if config.BootstrapDuration == 0 {
config.BootstrapDuration = 6 * time.Minute
}
if config.CredentialOverlapDuration == 0 {
config.CredentialOverlapDuration = 3 * time.Minute
}
if config.RetirementDuration == 0 {
config.RetirementDuration = 6 * time.Minute
}
manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets, config: config, metrics: metrics}
connection, err := repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return manager, nil
}
if err != nil {
return nil, fmt.Errorf("load security event connection: %w", err)
}
if connection.LifecycleStatus == "retiring" {
if connection.StreamID != nil && connection.Audience != nil {
_ = manager.activate(ctx, connection)
}
go manager.finishRetirement(connection)
return manager, nil
}
if connection.LifecycleStatus == "disconnect_pending" {
if connection.StreamID != nil && connection.Audience != nil {
_ = manager.activate(ctx, connection)
}
go manager.retryDisconnect(connection.ConnectionID)
return manager, nil
}
if connection.StreamID != nil && connection.Audience != nil {
if err := manager.activate(ctx, connection); err != nil {
category := "credential_unavailable"
_, _ = repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "degraded", &category)
}
}
return manager, nil
}
func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) {
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return ConnectionView{}, err
}
return m.view(connection), nil
}
func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey string) (ConnectionView, error) {
m.operation.Lock()
defer m.operation.Unlock()
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
if idempotencyKey == "" {
return ConnectionView{}, errors.New("idempotency key is required")
}
if err := m.validatePrerequisites(issuer); err != nil {
return ConnectionView{}, err
}
existing, err := m.repository.SecurityEventConnection(ctx)
if err == nil {
if existing.TransmitterIssuer != issuer {
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
}
if existing.StreamID != nil {
return m.view(existing), nil
}
return m.resumeConnecting(ctx, existing)
}
if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return ConnectionView{}, err
}
connectionID := uuid.NewString()
reference := "ssf-push-" + connectionID
secret, err := randomPushBearer()
if err != nil {
return ConnectionView{}, err
}
defer clear(secret)
if err := m.secrets.Put(ctx, reference, secret); err != nil {
return ConnectionView{}, err
}
endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf"
connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint,
CredentialRef: reference, IdempotencyKey: idempotencyKey,
})
if err != nil {
_ = m.secrets.Delete(ctx, reference)
return ConnectionView{}, err
}
return m.resumeConnecting(ctx, connection)
}
func (m *ConnectionManager) resumeConnecting(ctx context.Context, connection store.SecurityEventConnection) (ConnectionView, error) {
secret, err := m.secrets.Get(ctx, connection.CredentialRef)
if err != nil {
return ConnectionView{}, err
}
defer clear(secret)
configuration, client, err := m.discover(ctx, connection.TransmitterIssuer)
if err != nil {
return ConnectionView{}, m.connectionError(ctx, connection, "discovery_failed", err)
}
token, err := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
if err != nil {
return ConnectionView{}, m.connectionError(ctx, connection, "management_token_failed", err)
}
description := "easyai-gateway:" + connection.ConnectionID
stream, err := m.findStream(ctx, client, configuration, token, description, connection.EndpointURL)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
stream, err = m.createStream(ctx, client, configuration, token, connection.EndpointURL, string(secret), description)
}
if err != nil {
return ConnectionView{}, m.connectionError(ctx, connection, "stream_create_failed", err)
}
if err := validateCreatedStream(stream, connection.TransmitterIssuer, connection.EndpointURL); err != nil {
return ConnectionView{}, m.connectionError(ctx, connection, "stream_response_invalid", err)
}
connection, err = m.repository.BindSecurityEventConnection(ctx, connection.ConnectionID, stream.Audience, stream.StreamID, "verifying", connection.Version)
if err != nil {
return ConnectionView{}, err
}
started := time.Now().UTC()
if err := m.activate(ctx, connection); err != nil {
return ConnectionView{}, m.connectionError(ctx, connection, "receiver_activation_failed", err)
}
go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, false)
return m.view(connection), nil
}
func (m *ConnectionManager) Verify(ctx context.Context) (ConnectionView, error) {
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return ConnectionView{}, err
}
m.mutex.RLock()
runtime := m.runtime
m.mutex.RUnlock()
if runtime == nil || runtime.service == nil {
return ConnectionView{}, errors.New("security event receiver is unavailable")
}
started := time.Now().UTC()
runtime.service.RequestVerification(ctx)
if connection.LifecycleStatus == "verifying" || connection.LifecycleStatus == "rotating" {
configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer)
if discoverErr != nil {
return ConnectionView{}, discoverErr
}
token, tokenErr := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
if tokenErr != nil {
return ConnectionView{}, tokenErr
}
go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, connection.LifecycleStatus == "rotating")
}
return m.view(connection), nil
}
func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionView, error) {
m.operation.Lock()
defer m.operation.Unlock()
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return ConnectionView{}, err
}
if connection.StreamID == nil || connection.Audience == nil {
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
}
var nextReference string
var next []byte
if connection.NextCredentialRef == nil {
nextReference = "ssf-push-" + connection.ConnectionID + "-next-" + uuid.NewString()
next, err = randomPushBearer()
if err != nil {
return ConnectionView{}, err
}
if err := m.secrets.Put(ctx, nextReference, next); err != nil {
clear(next)
return ConnectionView{}, err
}
connection, err = m.repository.SetSecurityEventNextCredential(ctx, connection.ConnectionID, nextReference)
if err != nil {
clear(next)
_ = m.secrets.Delete(ctx, nextReference)
return ConnectionView{}, err
}
} else {
nextReference = *connection.NextCredentialRef
next, err = m.secrets.Get(ctx, nextReference)
if err != nil {
return ConnectionView{}, err
}
}
defer clear(next)
configuration, client, err := m.discover(ctx, connection.TransmitterIssuer)
if err != nil {
return ConnectionView{}, err
}
token, err := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
if err != nil {
return ConnectionView{}, err
}
if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "paused", "credential_rotation"); err != nil {
return ConnectionView{}, err
}
if err := m.patchStreamCredential(ctx, client, configuration, token, *connection.StreamID, connection.EndpointURL, string(next)); err != nil {
return ConnectionView{}, err
}
started := time.Now().UTC()
if err := m.activate(ctx, connection); err != nil {
return ConnectionView{}, err
}
go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, true)
return m.view(connection), nil
}
func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, error) {
m.operation.Lock()
defer m.operation.Unlock()
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return ConnectionView{}, err
}
if connection.StreamID != nil {
configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer)
if discoverErr == nil {
token, tokenErr := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
if tokenErr == nil {
discoverErr = m.deleteStream(ctx, client, configuration, token, *connection.StreamID)
if isRemoteHTTPStatus(discoverErr, http.StatusNotFound) {
discoverErr = nil
}
}
}
if discoverErr != nil {
category := "remote_disconnect_failed"
connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "disconnect_pending", &category)
go m.retryDisconnect(connection.ConnectionID)
return m.view(connection), nil
}
}
connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "retiring", nil)
if err != nil {
return ConnectionView{}, err
}
go m.finishRetirement(connection)
return m.view(connection), nil
}
func (m *ConnectionManager) retryDisconnect(connectionID string) {
delay := time.Second
for {
select {
case <-m.ctx.Done():
return
case <-time.After(delay):
}
connection, err := m.repository.SecurityEventConnection(m.ctx)
if err != nil || connection.ConnectionID != connectionID || connection.LifecycleStatus != "disconnect_pending" || connection.StreamID == nil {
return
}
configuration, client, err := m.discover(m.ctx, connection.TransmitterIssuer)
if err == nil {
var token string
token, err = m.managementToken(m.ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
if err == nil {
err = m.deleteStream(m.ctx, client, configuration, token, *connection.StreamID)
if isRemoteHTTPStatus(err, http.StatusNotFound) {
err = nil
}
}
}
if err == nil {
connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "retiring", nil)
if err == nil {
go m.finishRetirement(connection)
}
return
}
if delay < time.Minute {
delay *= 2
}
}
}
func (m *ConnectionManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.mutex.RLock()
runtime := m.runtime
m.mutex.RUnlock()
if runtime == nil || runtime.handler == nil {
w.Header().Set("Cache-Control", "no-store")
if _, err := m.repository.SecurityEventConnection(r.Context()); errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
http.NotFound(w, r)
return
}
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
runtime.handler.ServeHTTP(w, r)
}
func (m *ConnectionManager) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
connection, err := m.repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return auth.OIDCSecurityEventEvaluation{}, nil
}
if err != nil {
return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, err
}
m.mutex.RLock()
runtime := m.runtime
m.mutex.RUnlock()
if runtime == nil || runtime.service == nil {
if connection.Audience == nil {
return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, nil
}
result, evaluateErr := m.repository.EvaluateOIDCSecurityEvent(
ctx, connection.TransmitterIssuer, *connection.Audience, identity.Issuer, identity.TenantID, identity.Subject,
identity.IssuedAt, time.Now().UTC(), m.config.StaleAfter,
)
if result.Revoked && m.metrics != nil {
m.metrics.ObserveWatermarkRejection()
}
return auth.OIDCSecurityEventEvaluation{Enabled: true, Revoked: result.Revoked, RequireIntrospection: true}, evaluateErr
}
evaluation, err := runtime.service.Evaluate(ctx, identity)
evaluation.Enabled = true
if connection.LifecycleStatus != "enabled" && connection.LifecycleStatus != "bootstrap" {
evaluation.RequireIntrospection = true
}
return evaluation, err
}
func (m *ConnectionManager) activate(ctx context.Context, connection store.SecurityEventConnection) error {
if connection.StreamID == nil || connection.Audience == nil {
return errors.New("security event connection binding is incomplete")
}
current, err := m.secrets.Get(ctx, connection.CredentialRef)
if err != nil {
return err
}
defer clear(current)
var next []byte
if connection.NextCredentialRef != nil {
next, err = m.secrets.Get(ctx, *connection.NextCredentialRef)
if err != nil {
return err
}
defer clear(next)
}
issuerURL, err := validatedIssuerURL(connection.TransmitterIssuer, m.config.AppEnv)
if err != nil {
return err
}
safeClient := m.config.HTTPClient
if safeClient == nil {
safeClient = safeHTTPClient(issuerURL, m.config.AppEnv)
}
verifier, err := NewVerifier(VerifierConfig{
TransmitterIssuer: connection.TransmitterIssuer, Audience: *connection.Audience,
SubjectIssuer: m.config.OIDCIssuer, TenantID: m.config.OIDCTenantID, StreamID: *connection.StreamID,
ClockSkew: m.config.ClockSkew, HTTPClient: safeClient,
})
if err != nil {
return err
}
verifier.SetMetrics(m.metrics)
handler, err := NewHandler(verifier, m.repository, connection.TransmitterIssuer, *connection.Audience, *connection.StreamID, string(current), string(next))
if err != nil {
return err
}
handler.SetMetrics(m.metrics)
service, err := NewService(m.repository, ServiceConfig{
Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID,
ManagementTokenURL: strings.TrimRight(m.config.OIDCIssuer, "/") + "/token",
ManagementClientID: m.config.ManagementClientID, ManagementSecret: m.config.ManagementClientSecret,
HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter,
HTTPClient: safeClient,
})
if err != nil {
return err
}
service.SetMetrics(m.metrics)
runtimeContext, cancel := context.WithCancel(m.ctx)
if err := service.Initialize(runtimeContext); err != nil {
cancel()
return err
}
m.mutex.Lock()
previous := m.runtime
m.runtime = &connectionRuntime{handler: handler, service: service, cancel: cancel}
m.mutex.Unlock()
if previous != nil && previous.cancel != nil {
previous.cancel()
}
go service.Run(runtimeContext)
return nil
}
func (m *ConnectionManager) stopRuntime() {
m.mutex.Lock()
previous := m.runtime
m.runtime = nil
m.mutex.Unlock()
if previous != nil && previous.cancel != nil {
previous.cancel()
}
}
func (m *ConnectionManager) finishAutomaticVerification(connectionID string, configuration transmitterConfiguration, client *http.Client, token string, started time.Time, rotating bool) {
ctx, cancel := context.WithTimeout(m.ctx, 90*time.Second)
defer cancel()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil || connection.ConnectionID != connectionID || connection.StreamID == nil {
return
}
_, verifiedAt, _ := m.repository.SecurityEventMetrics(ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience))
if verifiedAt != nil && !verifiedAt.Before(started) {
if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "enabled", "verification_succeeded"); err != nil {
category := "stream_enable_failed"
lifecycle := "verifying"
if rotating {
lifecycle = "rotating"
}
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, lifecycle, &category)
return
}
if rotating && connection.NextCredentialRef != nil {
oldReference, nextReference := connection.CredentialRef, *connection.NextCredentialRef
select {
case <-m.ctx.Done():
return
case <-time.After(m.config.CredentialOverlapDuration):
}
connection, err = m.repository.PromoteSecurityEventCredential(m.ctx, connectionID, nextReference)
if err == nil {
_ = m.secrets.Delete(m.ctx, oldReference)
_ = m.activate(m.ctx, connection)
go m.finishBootstrap(connectionID)
}
return
}
connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, "bootstrap", nil)
go m.finishBootstrap(connectionID)
return
}
select {
case <-ctx.Done():
category := "verification_timeout"
lifecycle := "verifying"
if rotating {
lifecycle = "rotating"
}
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, lifecycle, &category)
return
case <-ticker.C:
}
}
}
func (m *ConnectionManager) finishBootstrap(connectionID string) {
select {
case <-m.ctx.Done():
return
case <-time.After(m.config.BootstrapDuration):
}
connection, err := m.repository.SecurityEventConnection(m.ctx)
if err == nil && connection.ConnectionID == connectionID && connection.LifecycleStatus == "bootstrap" {
mode, _, metricsErr := m.repository.SecurityEventMetrics(m.ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience))
if metricsErr == nil && mode == "push_healthy" {
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "enabled", nil)
return
}
category := "bootstrap_verification_stale"
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "degraded", &category)
}
}
func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConnection) {
elapsed := time.Since(connection.UpdatedAt)
delay := m.config.RetirementDuration - elapsed
if delay > 0 {
select {
case <-m.ctx.Done():
return
case <-time.After(delay):
}
}
delay = time.Second
for {
if err := m.repository.DeleteSecurityEventConnection(m.ctx, connection.ConnectionID); err == nil || errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
break
}
select {
case <-m.ctx.Done():
return
case <-time.After(delay):
}
if delay < time.Minute {
delay *= 2
}
}
m.stopRuntime()
_ = m.secrets.Delete(m.ctx, connection.CredentialRef)
if connection.NextCredentialRef != nil {
_ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef)
}
}
func (m *ConnectionManager) validatePrerequisites(issuer string) error {
if !m.config.OIDCEnabled || m.config.ManagementClientID == "" || m.config.ManagementClientSecret == "" {
return errors.New("OIDC and RFC 7662 machine client must be configured")
}
if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil {
return errors.New("OIDC tenant id is invalid")
}
if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil {
return err
}
endpoint, err := url.Parse(strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf")
if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
return errors.New("AI_GATEWAY_PUBLIC_BASE_URL is invalid")
}
if endpoint.Scheme != "https" && !(isLocalEnvironment(m.config.AppEnv) && endpoint.Scheme == "http" && isLocalHostname(endpoint.Hostname())) {
return errors.New("AI_GATEWAY_PUBLIC_BASE_URL must use HTTPS")
}
return nil
}
func (m *ConnectionManager) discover(ctx context.Context, issuer string) (transmitterConfiguration, *http.Client, error) {
issuerURL, err := validatedIssuerURL(issuer, m.config.AppEnv)
if err != nil {
return transmitterConfiguration{}, nil, err
}
client := m.config.HTTPClient
if client == nil {
client = safeHTTPClient(issuerURL, m.config.AppEnv)
}
discoveryURL := *issuerURL
discoveryURL.Path = "/.well-known/ssf-configuration" + strings.TrimRight(issuerURL.EscapedPath(), "/")
var configuration transmitterConfiguration
if err := requestJSON(ctx, client, http.MethodGet, discoveryURL.String(), "", nil, &configuration); err != nil {
return transmitterConfiguration{}, nil, err
}
if configuration.SpecVersion != "1_0" || strings.TrimRight(configuration.Issuer, "/") != strings.TrimRight(issuer, "/") ||
!contains(configuration.DeliveryMethodsSupported, pushDeliveryMethod) {
return transmitterConfiguration{}, nil, errors.New("SSF discovery metadata is incompatible")
}
for _, endpoint := range []string{configuration.JWKSURI, configuration.ConfigurationEndpoint, configuration.StatusEndpoint, configuration.VerificationEndpoint} {
parsed, parseErr := url.Parse(endpoint)
if parseErr != nil || parsed.Scheme != issuerURL.Scheme || !strings.EqualFold(parsed.Host, issuerURL.Host) || parsed.User != nil || parsed.Fragment != "" {
return transmitterConfiguration{}, nil, errors.New("SSF discovery endpoint is not trusted")
}
}
return configuration, client, nil
}
func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Client, scopes string) (string, error) {
form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.SetBasicAuth(m.config.ManagementClientID, m.config.ManagementClientSecret)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return "", fmt.Errorf("machine token request returned HTTP %d", response.StatusCode)
}
var payload struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
if err := decodeLimitedJSON(response.Body, &payload); err != nil || payload.AccessToken == "" || !strings.EqualFold(payload.TokenType, "Bearer") {
return "", errors.New("machine token response is invalid")
}
return payload.AccessToken, nil
}
func (m *ConnectionManager) findStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, description, endpoint string) (streamConfiguration, error) {
var streams []streamConfiguration
if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil {
return streamConfiguration{}, err
}
for _, stream := range streams {
if stream.Description != nil && *stream.Description == description && stream.Delivery.EndpointURL == endpoint {
return stream, nil
}
}
return streamConfiguration{}, store.ErrSecurityEventConnectionNotFound
}
func (m *ConnectionManager) createStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, endpoint, secret, description string) (streamConfiguration, error) {
body := map[string]any{
"delivery": map[string]any{"method": pushDeliveryMethod, "endpoint_url": endpoint, "authorization_header": "Bearer " + secret},
"events_requested": []string{sessionRevokedEvent}, "description": description,
}
var stream streamConfiguration
err := requestJSON(ctx, client, http.MethodPost, configuration.ConfigurationEndpoint, token, body, &stream)
return stream, err
}
func (m *ConnectionManager) patchStreamCredential(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID, endpoint, secret string) error {
body := map[string]any{"stream_id": streamID, "delivery": map[string]any{
"method": pushDeliveryMethod, "endpoint_url": endpoint, "authorization_header": "Bearer " + secret,
}}
return requestJSON(ctx, client, http.MethodPatch, configuration.ConfigurationEndpoint, token, body, &streamConfiguration{})
}
func (m *ConnectionManager) setStreamStatus(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID, status, reason string) error {
body := map[string]any{"stream_id": streamID, "status": status, "reason": reason}
return requestJSON(ctx, client, http.MethodPost, configuration.StatusEndpoint, token, body, &map[string]any{})
}
func (m *ConnectionManager) deleteStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID string) error {
endpoint, _ := url.Parse(configuration.ConfigurationEndpoint)
query := endpoint.Query()
query.Set("stream_id", streamID)
endpoint.RawQuery = query.Encode()
return requestJSON(ctx, client, http.MethodDelete, endpoint.String(), token, nil, nil)
}
func (m *ConnectionManager) connectionError(ctx context.Context, connection store.SecurityEventConnection, category string, cause error) error {
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "error", &category)
return cause
}
func (m *ConnectionManager) view(connection store.SecurityEventConnection) ConnectionView {
return ConnectionView{
ConnectionID: connection.ConnectionID, TransmitterIssuer: connection.TransmitterIssuer,
ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID,
LifecycleStatus: connection.LifecycleStatus, HealthMode: connection.HealthMode,
ManagementClientID: m.config.ManagementClientID, LastVerificationAt: connection.LastVerificationAt,
LastErrorCategory: connection.LastErrorCategory, Version: connection.Version,
}
}
func validateCreatedStream(stream streamConfiguration, issuer, endpoint string) error {
if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" ||
stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint ||
len(stream.EventsRequested) != 1 || stream.EventsRequested[0] != sessionRevokedEvent {
return errors.New("created SSF stream does not match the requested receiver")
}
return nil
}
func randomPushBearer() ([]byte, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
encoded := make([]byte, base64.RawURLEncoding.EncodedLen(len(raw)))
base64.RawURLEncoding.Encode(encoded, raw)
clear(raw)
return encoded, nil
}
func requestJSON(ctx context.Context, client *http.Client, method, endpoint, token string, body, output any) error {
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(payload)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return remoteHTTPError{status: response.StatusCode}
}
if output == nil || response.StatusCode == http.StatusNoContent {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return nil
}
return decodeLimitedJSON(response.Body, output)
}
func isRemoteHTTPStatus(err error, status int) bool {
var remoteError remoteHTTPError
return errors.As(err, &remoteError) && remoteError.status == status
}
func decodeLimitedJSON(reader io.Reader, output any) error {
payload, err := io.ReadAll(io.LimitReader(reader, 64*1024+1))
if err != nil || len(payload) > 64*1024 {
return errors.New("security event response is too large")
}
if len(payload) == 0 {
return nil
}
return json.Unmarshal(payload, output)
}
func validatedIssuerURL(raw, appEnv string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("transmitter issuer is invalid")
}
if parsed.Scheme != "https" && !(isLocalEnvironment(appEnv) && parsed.Scheme == "http" && isLocalHostname(parsed.Hostname())) {
return nil, errors.New("transmitter issuer must use HTTPS")
}
return parsed, nil
}
func safeHTTPClient(issuer *url.URL, appEnv string) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DisableCompression = true
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, errors.New("transmitter DNS resolution failed")
}
allowLocal := isLocalEnvironment(appEnv) && isLocalHostname(issuer.Hostname())
for _, address := range addresses {
if blockedAddress(address.IP) && !allowLocal {
return nil, errors.New("transmitter resolved to a blocked network")
}
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port))
}
return &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
func blockedAddress(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() || ip.IsMulticast() {
return true
}
address, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
address = address.Unmap()
for _, prefix := range blockedNetworkPrefixes {
if prefix.Contains(address) {
return true
}
}
return false
}
var blockedNetworkPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("100::/64"), netip.MustParsePrefix("2001:db8::/32"),
}
func isLocalHostname(value string) bool {
return value == "localhost" || value == "127.0.0.1" || value == "::1"
}
func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}
func valueOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}
@@ -0,0 +1,293 @@
package securityevents
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
type memoryConnectionRepository struct {
mutex sync.Mutex
connection *store.SecurityEventConnection
mode string
verifiedAt *time.Time
evaluation store.SecurityEventEvaluation
}
func (*memoryConnectionRepository) ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) {
return store.ApplySecurityEventResult{}, nil
}
func (*memoryConnectionRepository) ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) {
return true, nil
}
func (*memoryConnectionRepository) ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) {
return true, nil
}
func (*memoryConnectionRepository) RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) {
return true, nil
}
func (r *memoryConnectionRepository) EnsureSecurityEventStreamState(context.Context, string, string, string) error {
r.mode = "bootstrap"
return nil
}
func (*memoryConnectionRepository) BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error {
return nil
}
func (*memoryConnectionRepository) RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error {
return nil
}
func (*memoryConnectionRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error {
return nil
}
func (r *memoryConnectionRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
if r.evaluation.Mode == "" {
return store.SecurityEventEvaluation{Mode: "bootstrap", RequireIntrospection: true}, nil
}
return r.evaluation, nil
}
func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Context, input store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.connection != nil {
return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict
}
now := time.Now().UTC()
value := store.SecurityEventConnection{ConnectionID: input.ConnectionID, TransmitterIssuer: input.TransmitterIssuer,
EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, LifecycleStatus: "connecting", Version: 1,
IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"}
r.connection = &value
return value, nil
}
func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.connection == nil {
return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound
}
value := *r.connection
value.HealthMode, value.LastVerificationAt = r.mode, r.verifiedAt
return value, nil
}
func (r *memoryConnectionRepository) BindSecurityEventConnection(_ context.Context, id, audience, streamID, lifecycle string, expected int64) (store.SecurityEventConnection, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.connection == nil || r.connection.ConnectionID != id || r.connection.Version != expected {
return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict
}
r.connection.Audience, r.connection.StreamID = &audience, &streamID
r.connection.LifecycleStatus, r.connection.Version = lifecycle, r.connection.Version+1
return *r.connection, nil
}
func (r *memoryConnectionRepository) UpdateSecurityEventConnectionLifecycle(_ context.Context, id, lifecycle string, category *string) (store.SecurityEventConnection, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.connection == nil || r.connection.ConnectionID != id {
return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound
}
r.connection.LifecycleStatus, r.connection.LastErrorCategory = lifecycle, category
r.connection.Version++
r.connection.UpdatedAt = time.Now().UTC()
return *r.connection, nil
}
func (r *memoryConnectionRepository) SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error) {
return store.SecurityEventConnection{}, errors.New("not used")
}
func TestConnectionManagerBootstrapOnlyBecomesEnabledWhenPushIsHealthy(t *testing.T) {
for _, test := range []struct {
mode, wantLifecycle string
}{
{mode: "push_healthy", wantLifecycle: "enabled"},
{mode: "introspection_fallback", wantLifecycle: "degraded"},
} {
t.Run(test.mode, func(t *testing.T) {
audience := "urn:easyai:ssf:receiver:" + uuid.NewString()
repository := &memoryConnectionRepository{mode: test.mode, connection: &store.SecurityEventConnection{
ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", Audience: &audience,
LifecycleStatus: "bootstrap", Version: 1, UpdatedAt: time.Now().UTC(),
}}
manager := &ConnectionManager{ctx: context.Background(), repository: repository,
config: ConnectionManagerConfig{BootstrapDuration: time.Millisecond}}
manager.finishBootstrap(repository.connection.ConnectionID)
connection, err := repository.SecurityEventConnection(context.Background())
if err != nil || connection.LifecycleStatus != test.wantLifecycle {
t.Fatalf("connection=%#v err=%v", connection, err)
}
})
}
}
func TestRemoteNotFoundIsRecognizedForIdempotentDisconnect(t *testing.T) {
if !isRemoteHTTPStatus(remoteHTTPError{status: http.StatusNotFound}, http.StatusNotFound) {
t.Fatal("remote 404 was not recognized as an already-deleted Stream")
}
}
func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) {
for _, address := range []string{
"127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1",
} {
if !blockedAddress(net.ParseIP(address)) {
t.Fatalf("reserved address %s was allowed", address)
}
}
if blockedAddress(net.ParseIP("8.8.8.8")) {
t.Fatal("public address was blocked")
}
}
func TestRetiringConnectionStillAppliesRevocationWatermark(t *testing.T) {
audience := "urn:easyai:ssf:receiver:" + uuid.NewString()
repository := &memoryConnectionRepository{
evaluation: store.SecurityEventEvaluation{Mode: "introspection_fallback", Revoked: true, RequireIntrospection: true},
connection: &store.SecurityEventConnection{
ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", Audience: &audience,
LifecycleStatus: "retiring", Version: 1,
},
}
manager := &ConnectionManager{ctx: context.Background(), repository: repository, config: ConnectionManagerConfig{StaleAfter: 3 * time.Minute}}
evaluation, err := manager.Evaluate(context.Background(), auth.OIDCSecurityEventIdentity{IssuedAt: time.Now().Add(-time.Minute)})
if err != nil || !evaluation.Enabled || !evaluation.Revoked || !evaluation.RequireIntrospection {
t.Fatalf("retiring evaluation=%#v err=%v", evaluation, err)
}
}
func (r *memoryConnectionRepository) PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error) {
return store.SecurityEventConnection{}, errors.New("not used")
}
func (r *memoryConnectionRepository) DeleteSecurityEventConnection(context.Context, string) error {
r.connection = nil
return nil
}
func (r *memoryConnectionRepository) SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) {
return r.mode, r.verifiedAt, nil
}
type memorySecretStore struct {
values map[string][]byte
}
func (s *memorySecretStore) Put(_ context.Context, reference string, value []byte) error {
if s.values == nil {
s.values = map[string][]byte{}
}
s.values[reference] = append([]byte(nil), value...)
return nil
}
func (s *memorySecretStore) Get(_ context.Context, reference string) ([]byte, error) {
value, ok := s.values[reference]
if !ok {
return nil, ErrSecretNotFound
}
return append([]byte(nil), value...), nil
}
func (s *memorySecretStore) Delete(_ context.Context, reference string) error {
delete(s.values, reference)
return nil
}
func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testing.T) {
repository := &memoryConnectionRepository{}
secrets := &memorySecretStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var transmitter *httptest.Server
verificationRequested := make(chan struct{}, 1)
var managementScopeRequested atomic.Bool
transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/ssf-configuration/ssf":
_ = json.NewEncoder(w).Encode(map[string]any{
"spec_version": "1_0", "issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json",
"configuration_endpoint": transmitter.URL + "/ssf/v1/stream", "status_endpoint": transmitter.URL + "/ssf/v1/status",
"verification_endpoint": transmitter.URL + "/ssf/v1/verify", "delivery_methods_supported": []string{pushDeliveryMethod},
})
case "/oidc/token":
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "gateway-machine" || secret != "machine-secret" {
t.Fatal("RFC 7662 machine client was not reused")
}
_ = r.ParseForm()
if strings.Contains(r.Form.Get("scope"), "ssf.stream.manage") {
managementScopeRequested.Store(true)
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "machine-token", "token_type": "Bearer"})
case "/ssf/v1/stream":
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[]`))
return
}
var request struct {
Delivery struct {
AuthorizationHeader string `json:"authorization_header"`
EndpointURL string `json:"endpoint_url"`
} `json:"delivery"`
Description string `json:"description"`
}
_ = json.NewDecoder(r.Body).Decode(&request)
if len(strings.TrimPrefix(request.Delivery.AuthorizationHeader, "Bearer ")) < 43 {
t.Fatal("generated Push Bearer has less than 256 bits")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"stream_id": uuid.NewString(), "iss": transmitter.URL + "/ssf",
"aud": "urn:easyai:ssf:receiver:" + uuid.NewString(), "events_requested": []string{sessionRevokedEvent},
"delivery": map[string]any{"method": pushDeliveryMethod, "endpoint_url": request.Delivery.EndpointURL},
"description": request.Description,
})
case "/ssf/v1/verify":
w.WriteHeader(http.StatusNoContent)
select {
case verificationRequested <- struct{}{}:
default:
}
default:
http.NotFound(w, r)
}
}))
defer transmitter.Close()
manager, err := NewConnectionManager(ctx, repository, secrets, ConnectionManagerConfig{
AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/oidc", OIDCTenantID: uuid.NewString(),
ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret",
PublicBaseURL: transmitter.URL, HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: transmitter.Client(),
}, &Metrics{})
if err != nil {
t.Fatal(err)
}
evaluation, err := manager.Evaluate(ctx, auth.OIDCSecurityEventIdentity{})
if err != nil || evaluation.Enabled {
t.Fatalf("unconfigured manager evaluation=%#v err=%v", evaluation, err)
}
view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "connect-once")
if err != nil {
t.Fatal(err)
}
if view.LifecycleStatus != "verifying" || view.StreamID == nil || view.Audience == nil {
t.Fatalf("connection view=%#v", view)
}
encoded, _ := json.Marshal(view)
if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret") || strings.Contains(string(encoded), "ssf-push-") {
t.Fatalf("secret metadata escaped in response: %s", encoded)
}
if len(secrets.values) != 1 {
t.Fatalf("secret count=%d", len(secrets.values))
}
if !managementScopeRequested.Load() {
t.Fatal("management scopes were not requested automatically")
}
select {
case <-verificationRequested:
case <-time.After(2 * time.Second):
t.Fatal("verification was not started without a restart")
}
}
@@ -0,0 +1,186 @@
package securityevents
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
const (
defaultKubernetesAPIServer = "https://kubernetes.default.svc"
defaultServiceAccountToken = "/var/run/secrets/kubernetes.io/serviceaccount/token"
defaultServiceAccountCA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
)
var kubernetesDNSNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`)
type KubernetesSecretStoreConfig struct {
Namespace string
SecretName string
APIServer string
TokenFile string
CAFile string
HTTPClient *http.Client
}
// KubernetesSecretStore keeps all generated Push Bearers as keys in one
// pre-provisioned Secret. It intentionally has no permission to create or
// delete Kubernetes Secret resources.
type KubernetesSecretStore struct {
endpoint string
tokenFile string
client *http.Client
}
func NewKubernetesSecretStore(config KubernetesSecretStoreConfig) (*KubernetesSecretStore, error) {
if !validKubernetesDNSName(config.Namespace) || !validKubernetesDNSName(config.SecretName) {
return nil, errors.New("Kubernetes security event Secret namespace or name is invalid")
}
if config.APIServer == "" {
config.APIServer = defaultKubernetesAPIServer
}
if config.TokenFile == "" {
config.TokenFile = defaultServiceAccountToken
}
parsed, err := url.Parse(strings.TrimRight(config.APIServer, "/"))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Scheme != "https" && config.HTTPClient == nil) {
return nil, errors.New("Kubernetes API server URL is invalid")
}
client := config.HTTPClient
if client == nil {
if config.CAFile == "" {
config.CAFile = defaultServiceAccountCA
}
certificate, readErr := os.ReadFile(config.CAFile)
if readErr != nil {
return nil, fmt.Errorf("read Kubernetes service account CA: %w", readErr)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(certificate) {
return nil, errors.New("Kubernetes service account CA is invalid")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DisableCompression = true
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}
client = &http.Client{Timeout: 10 * time.Second, Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
endpoint := fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", strings.TrimRight(config.APIServer, "/"), config.Namespace, config.SecretName)
return &KubernetesSecretStore{endpoint: endpoint, tokenFile: config.TokenFile, client: client}, nil
}
func (s *KubernetesSecretStore) Put(ctx context.Context, reference string, value []byte) error {
if !secretReferencePattern.MatchString(reference) {
return errors.New("security event secret reference is invalid")
}
if len(value) < 32 || len(value) > 4096 {
return errors.New("security event secret length is invalid")
}
return s.patch(ctx, map[string]any{reference: base64.StdEncoding.EncodeToString(value)})
}
func (s *KubernetesSecretStore) Get(ctx context.Context, reference string) ([]byte, error) {
if !secretReferencePattern.MatchString(reference) {
return nil, errors.New("security event secret reference is invalid")
}
request, err := s.request(ctx, http.MethodGet, nil)
if err != nil {
return nil, err
}
response, err := s.client.Do(request)
if err != nil {
return nil, fmt.Errorf("read Kubernetes security event Secret: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return nil, ErrSecretNotFound
}
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return nil, fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode)
}
var payload struct {
Data map[string]string `json:"data"`
}
if err := decodeLimitedJSON(response.Body, &payload); err != nil {
return nil, err
}
encoded, ok := payload.Data[reference]
if !ok {
return nil, ErrSecretNotFound
}
value, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(value) < 32 || len(value) > 4096 {
clear(value)
return nil, errors.New("Kubernetes security event Secret value is invalid")
}
return value, nil
}
func (s *KubernetesSecretStore) Delete(ctx context.Context, reference string) error {
if !secretReferencePattern.MatchString(reference) {
return errors.New("security event secret reference is invalid")
}
return s.patch(ctx, map[string]any{reference: nil})
}
func (s *KubernetesSecretStore) patch(ctx context.Context, data map[string]any) error {
payload, err := json.Marshal(map[string]any{"data": data})
if err != nil {
return err
}
request, err := s.request(ctx, http.MethodPatch, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/merge-patch+json")
response, err := s.client.Do(request)
if err != nil {
return fmt.Errorf("update Kubernetes security event Secret: %w", err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
if response.StatusCode == http.StatusNotFound {
return errors.New("Kubernetes security event Secret is not provisioned")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode)
}
return nil
}
func (s *KubernetesSecretStore) request(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
token, err := os.ReadFile(s.tokenFile)
if err != nil || strings.TrimSpace(string(token)) == "" {
clear(token)
return nil, errors.New("Kubernetes service account token is unavailable")
}
request, err := http.NewRequestWithContext(ctx, method, s.endpoint, body)
if err != nil {
clear(token)
return nil, err
}
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
request.Header.Set("Accept", "application/json")
clear(token)
return request, nil
}
func validKubernetesDNSName(value string) bool {
return len(value) > 0 && len(value) <= 253 && kubernetesDNSNamePattern.MatchString(value)
}
@@ -0,0 +1,95 @@
package securityevents
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
)
func TestKubernetesSecretStoreUsesOnePreprovisionedSecret(t *testing.T) {
var mutex sync.Mutex
data := map[string]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/namespaces/easyai/secrets/easyai-gateway-security-events" || r.Header.Get("Authorization") != "Bearer service-account-token" {
t.Fatalf("unexpected Kubernetes request: %s authorization=%t", r.URL.Path, r.Header.Get("Authorization") != "")
}
mutex.Lock()
defer mutex.Unlock()
switch r.Method {
case http.MethodGet:
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
case http.MethodPatch:
var patch struct {
Data map[string]*string `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
t.Fatal(err)
}
for key, value := range patch.Data {
if value == nil {
delete(data, key)
} else {
data[key] = *value
}
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
default:
http.Error(w, "unsupported", http.StatusMethodNotAllowed)
}
}))
defer server.Close()
tokenFile := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(tokenFile, []byte("service-account-token\n"), 0o600); err != nil {
t.Fatal(err)
}
secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{
Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL,
TokenFile: tokenFile, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
value := []byte("0123456789abcdef0123456789abcdef")
if err := secrets.Put(context.Background(), "ssf-push-connection", value); err != nil {
t.Fatal(err)
}
if data["ssf-push-connection"] != base64.StdEncoding.EncodeToString(value) {
t.Fatal("Push Bearer was not stored in Kubernetes Secret data")
}
loaded, err := secrets.Get(context.Background(), "ssf-push-connection")
if err != nil || string(loaded) != string(value) {
t.Fatalf("loaded secret mismatch: err=%v", err)
}
clear(loaded)
if err := secrets.Delete(context.Background(), "ssf-push-connection"); err != nil {
t.Fatal(err)
}
if _, ok := data["ssf-push-connection"]; ok {
t.Fatal("Push Bearer key was not removed")
}
}
func TestKubernetesSecretStoreRejectsUnprovisionedSecret(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.NotFound(w, nil) }))
defer server.Close()
tokenFile := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(tokenFile, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{
Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL,
TokenFile: tokenFile, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := secrets.Put(context.Background(), "ssf-push-connection", []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("missing pre-provisioned Kubernetes Secret was accepted")
}
}
@@ -2,16 +2,24 @@ package securityevents
import (
"context"
"errors"
"fmt"
"net/http"
"sync/atomic"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type MetricsSnapshotProvider interface {
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
}
type DynamicMetricsSnapshotProvider interface {
MetricsSnapshotProvider
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
}
type Metrics struct {
accepted atomic.Uint64
rejected atomic.Uint64
@@ -151,6 +159,25 @@ func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience str
})
}
func (m *Metrics) DynamicHandler(provider DynamicMetricsSnapshotProvider) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
connection, err := provider.SecurityEventConnection(r.Context())
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
m.Handler(provider, "", "", false).ServeHTTP(w, r)
return
}
if err != nil {
http.Error(w, "metrics unavailable", http.StatusServiceUnavailable)
return
}
if connection.Audience == nil {
m.Handler(provider, "", "", false).ServeHTTP(w, r)
return
}
m.Handler(provider, connection.TransmitterIssuer, *connection.Audience, true).ServeHTTP(w, r)
})
}
type outcomeValue struct {
name string
value uint64
@@ -0,0 +1,112 @@
package securityevents
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
)
var secretReferencePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
var ErrSecretNotFound = errors.New("security event secret not found")
type SecretStore interface {
Put(context.Context, string, []byte) error
Get(context.Context, string) ([]byte, error)
Delete(context.Context, string) error
}
type FileSecretStore struct{ directory string }
func NewFileSecretStore(directory string) (*FileSecretStore, error) {
if directory == "" {
return nil, errors.New("security event secret directory is required")
}
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, fmt.Errorf("create security event secret directory: %w", err)
}
if err := os.Chmod(directory, 0o700); err != nil {
return nil, fmt.Errorf("protect security event secret directory: %w", err)
}
return &FileSecretStore{directory: directory}, nil
}
func (s *FileSecretStore) Put(_ context.Context, reference string, value []byte) error {
path, err := s.path(reference)
if err != nil {
return err
}
if len(value) < 32 || len(value) > 4096 {
return errors.New("security event secret length is invalid")
}
temporary, err := os.CreateTemp(s.directory, ".ssf-secret-*")
if err != nil {
return fmt.Errorf("create temporary security event secret: %w", err)
}
temporaryName := temporary.Name()
defer func() { _ = os.Remove(temporaryName) }()
if err := temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(value); err != nil {
_ = temporary.Close()
return fmt.Errorf("write security event secret: %w", err)
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryName, path); err != nil {
return fmt.Errorf("publish security event secret: %w", err)
}
return os.Chmod(path, 0o600)
}
func (s *FileSecretStore) Get(_ context.Context, reference string) ([]byte, error) {
path, err := s.path(reference)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrSecretNotFound
}
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 {
return nil, errors.New("security event secret file is not protected")
}
value, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read security event secret: %w", err)
}
if len(value) < 32 || len(value) > 4096 {
clear(value)
return nil, errors.New("security event secret length is invalid")
}
return value, nil
}
func (s *FileSecretStore) Delete(_ context.Context, reference string) error {
path, err := s.path(reference)
if err != nil {
return err
}
err = os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func (s *FileSecretStore) path(reference string) (string, error) {
if !secretReferencePattern.MatchString(reference) {
return "", errors.New("security event secret reference is invalid")
}
return filepath.Join(s.directory, reference), nil
}
@@ -0,0 +1,40 @@
package securityevents
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func TestFileSecretStoreCreatesProtectedFilesAndNeverAcceptsTraversal(t *testing.T) {
directory := filepath.Join(t.TempDir(), "ssf")
secrets, err := NewFileSecretStore(directory)
if err != nil {
t.Fatal(err)
}
value := bytes.Repeat([]byte{0x41}, 32)
if err := secrets.Put(context.Background(), "ssf-push-connection", value); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(directory, "ssf-push-connection"))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("secret mode=%v err=%v", info.Mode().Perm(), err)
}
loaded, err := secrets.Get(context.Background(), "ssf-push-connection")
if err != nil || !bytes.Equal(loaded, value) {
t.Fatalf("secret mismatch err=%v", err)
}
clear(loaded)
if err := secrets.Put(context.Background(), "../escape", value); err == nil {
t.Fatal("path traversal reference was accepted")
}
if err := secrets.Delete(context.Background(), "ssf-push-connection"); err != nil {
t.Fatal(err)
}
if _, err := secrets.Get(context.Background(), "ssf-push-connection"); !errors.Is(err, ErrSecretNotFound) {
t.Fatalf("deleted secret error=%v", err)
}
}
+13 -2
View File
@@ -75,13 +75,24 @@ func NewService(repository StateRepository, config ServiceConfig) (*Service, err
}
func (s *Service) Start(ctx context.Context) error {
if err := s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID); err != nil {
if err := s.Initialize(ctx); err != nil {
return err
}
go s.run(ctx)
go s.Run(ctx)
return nil
}
func (s *Service) Initialize(ctx context.Context) error {
return s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID)
}
func (s *Service) Run(ctx context.Context) { s.run(ctx) }
// RequestVerification asks for an immediate verification without changing the
// remote stream status. In particular, this never re-enables a stream paused
// by an authentication-center administrator.
func (s *Service) RequestVerification(ctx context.Context) { s.sendVerification(ctx) }
func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
result, err := s.repository.EvaluateOIDCSecurityEvent(
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject,
@@ -0,0 +1,175 @@
package store
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var (
ErrSecurityEventConnectionNotFound = errors.New("security event connection not found")
ErrSecurityEventConnectionConflict = errors.New("security event connection conflicts with current state")
)
type SecurityEventConnection struct {
ConnectionID string `json:"connectionId"`
TransmitterIssuer string `json:"transmitterIssuer"`
EndpointURL string `json:"endpointUrl"`
Audience *string `json:"audience,omitempty"`
StreamID *string `json:"streamId,omitempty"`
CredentialRef string `json:"-"`
NextCredentialRef *string `json:"-"`
LifecycleStatus string `json:"lifecycleStatus"`
Version int64 `json:"version"`
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
IdempotencyKey string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
HealthMode string `json:"healthMode"`
}
type CreateSecurityEventConnectionInput struct {
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, IdempotencyKey string
}
type SecurityEventConnectionIdempotency struct {
RequestHash string
Response json.RawMessage
}
func (s *Store) SecurityEventConnectionIdempotency(ctx context.Context, operation, key string) (SecurityEventConnectionIdempotency, error) {
var value SecurityEventConnectionIdempotency
err := s.pool.QueryRow(ctx, `SELECT request_hash,response FROM gateway_security_event_connection_idempotency
WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan(&value.RequestHash, &value.Response)
if errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnectionIdempotency{}, ErrSecurityEventConnectionNotFound
}
return value, err
}
func (s *Store) RecordSecurityEventConnectionIdempotency(ctx context.Context, operation, key, requestHash string, response []byte) error {
if !json.Valid(response) {
return errors.New("security event idempotency response is invalid")
}
_, err := s.pool.Exec(ctx, `INSERT INTO gateway_security_event_connection_idempotency(operation,idempotency_key,request_hash,response)
VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, operation, key, requestHash, response)
return err
}
func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateSecurityEventConnectionInput) (SecurityEventConnection, error) {
var value SecurityEventConnection
err := s.pool.QueryRow(ctx, `
INSERT INTO gateway_security_event_connections(
connection_id,transmitter_issuer,endpoint_url,credential_ref,lifecycle_status,idempotency_key
) VALUES($1::uuid,$2,$3,$4,'connecting',$5)
RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref,
next_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`,
input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, input.IdempotencyKey,
).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
&value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version,
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt)
if err != nil {
if isUniqueViolation(err) {
existing, getErr := s.SecurityEventConnection(ctx)
if getErr == nil && existing.IdempotencyKey == input.IdempotencyKey && existing.TransmitterIssuer == input.TransmitterIssuer {
return existing, nil
}
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
return SecurityEventConnection{}, err
}
return value, nil
}
func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConnection, error) {
var value SecurityEventConnection
err := s.pool.QueryRow(ctx, `
SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience,
connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,connection.lifecycle_status,
connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at,
state.last_verification_at,COALESCE(state.mode,'disabled')
FROM gateway_security_event_connections connection
LEFT JOIN gateway_security_event_stream_state state
ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience
WHERE connection.singleton=true`).Scan(
&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
&value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version,
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt,
&value.LastVerificationAt, &value.HealthMode,
)
if errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
}
return value, err
}
func (s *Store) BindSecurityEventConnection(ctx context.Context, connectionID, audience, streamID, lifecycle string, expectedVersion int64) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET audience=$2,stream_id=$3::uuid,lifecycle_status=$4,last_error_category=NULL,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID, lifecycle, expectedVersion)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) UpdateSecurityEventConnectionLifecycle(ctx context.Context, connectionID, lifecycle string, errorCategory *string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid`, connectionID, reference)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL,
version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND next_credential_ref=$2`, connectionID, nextReference)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) DeleteSecurityEventConnection(ctx context.Context, connectionID string) error {
if _, err := uuid.Parse(connectionID); err != nil {
return ErrSecurityEventConnectionNotFound
}
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrSecurityEventConnectionNotFound
}
return nil
}
@@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS gateway_security_event_connections (
connection_id uuid PRIMARY KEY,
singleton boolean NOT NULL DEFAULT true UNIQUE CHECK (singleton),
transmitter_issuer text NOT NULL,
endpoint_url text NOT NULL,
audience text,
stream_id uuid,
credential_ref text NOT NULL,
next_credential_ref text,
lifecycle_status text NOT NULL,
version bigint NOT NULL DEFAULT 1,
last_error_category text,
idempotency_key text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT gateway_security_event_connection_status CHECK (lifecycle_status IN (
'connecting','verifying','bootstrap','enabled','degraded','rotating',
'disconnect_pending','retiring','error'
)),
CONSTRAINT gateway_security_event_connection_stream_pair CHECK (
(audience IS NULL AND stream_id IS NULL) OR (audience IS NOT NULL AND stream_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_security_event_connections_idempotency
ON gateway_security_event_connections(idempotency_key);
CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency (
operation text NOT NULL,
idempotency_key text NOT NULL,
request_hash text NOT NULL,
response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (operation, idempotency_key),
CONSTRAINT gateway_security_event_connection_idempotency_operation
CHECK (operation IN ('connect','verify','rotate','disconnect'))
);
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_connection_idempotency_created
ON gateway_security_event_connection_idempotency(created_at);
+58 -2
View File
@@ -32,6 +32,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroupUpsertRequest,
UserGroup,
WalletRechargeRequest,
@@ -46,6 +47,7 @@ import {
createPlatform,
createTenant,
createUserGroup,
connectSecurityEventTransmitter,
deleteAccessRule,
deleteApiKey,
deleteFileStorageChannel,
@@ -53,6 +55,7 @@ import {
deletePlatform,
deleteTenant,
deleteUserGroup,
disconnectSecurityEventConnection,
GatewayApiError,
getClientCustomizationSettings,
getHealth,
@@ -61,6 +64,7 @@ import {
getFileStorageSettings,
getNetworkProxyConfig,
getRunnerPolicy,
getSecurityEventConnection,
getWalletSummary,
listAccessRules,
listAuditLogs,
@@ -93,6 +97,7 @@ import {
rechargeUserWalletBalance,
replacePlatformModels,
restoreModelRuntimeStatus,
rotateSecurityEventConnectionCredential,
type HealthResponse,
updateAccessRule,
updateApiKeyScopes,
@@ -104,6 +109,7 @@ import {
updatePlatformDynamicPriority,
updateTenant,
updateUserGroup,
verifySecurityEventConnection,
} from './api';
import type { ConsoleData, StatItem } from './app-state';
import { AppShell } from './components/layout/AppShell';
@@ -173,6 +179,7 @@ type DataKey =
| 'clientCustomizationSettings'
| 'fileStorageChannels'
| 'fileStorageSettings'
| 'securityEventConnection'
| 'platforms'
| 'models'
| 'providers'
@@ -222,6 +229,7 @@ export function App() {
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
const [fileStorageSettings, setFileStorageSettings] = useState<FileStorageSettings | null>(null);
const [securityEventConnection, setSecurityEventConnection] = useState<SecurityEventConnectionResponse | null>(null);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
@@ -383,6 +391,7 @@ export function App() {
modelRateLimits,
modelRateLimitsUpdatedAt,
runtimePolicySets,
securityEventConnection,
taskResult,
tasks,
tenants,
@@ -390,7 +399,7 @@ export function App() {
users,
walletAccounts,
walletTransactions,
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -480,6 +489,9 @@ export function App() {
case 'fileStorageSettings':
setFileStorageSettings(await getFileStorageSettings(nextToken));
return;
case 'securityEventConnection':
setSecurityEventConnection(await getSecurityEventConnection(nextToken));
return;
case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items);
return;
@@ -1017,6 +1029,44 @@ export function App() {
}
}
async function connectSecurityEvents(transmitterIssuer: string) {
setCoreState('loading');
setCoreMessage('');
try {
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer);
setSecurityEventConnection(connection);
setCoreState('ready');
setCoreMessage('认证中心安全事件连接正在验证。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '连接认证中心失败');
throw err;
}
}
async function refreshSecurityEvents() {
const connection = await getSecurityEventConnection(token);
setSecurityEventConnection(connection);
}
async function verifySecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await verifySecurityEventConnection(token, connection.version));
}
async function rotateSecurityEventsCredential() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await rotateSecurityEventConnectionCredential(token, connection.version));
}
async function disconnectSecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await disconnectSecurityEventConnection(token, connection.version));
}
async function removeFileStorageChannel(channelId: string) {
setCoreState('loading');
setCoreMessage('');
@@ -1095,6 +1145,7 @@ export function App() {
setPlaygroundModels([]);
setNetworkProxyConfig(null);
setFileStorageChannels([]);
setSecurityEventConnection(null);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
@@ -1332,6 +1383,11 @@ export function App() {
onSaveFileStorageChannel={saveFileStorageChannel}
onSaveFileStorageSettings={saveFileStorageSettings}
onSaveClientCustomizationSettings={saveClientCustomizationSettings}
onConnectSecurityEvents={connectSecurityEvents}
onDisconnectSecurityEvents={disconnectSecurityEvents}
onRefreshSecurityEvents={refreshSecurityEvents}
onRotateSecurityEventsCredential={rotateSecurityEventsCredential}
onVerifySecurityEvents={verifySecurityEvents}
onSaveTenant={saveTenant}
onSaveUser={saveUser}
onRechargeUserWalletBalance={rechargeUserWallet}
@@ -1553,7 +1609,7 @@ function dataKeysForRoute(
case 'accessRules':
return ['accessRules', 'userGroups', 'platforms', 'models'];
case 'systemSettings':
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels'];
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels', 'securityEventConnection'];
default:
return [];
}
+24
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
connectSecurityEventTransmitter,
deleteOIDCBrowserSession,
GatewayApiError,
gatewayErrorMessage,
@@ -34,6 +35,29 @@ describe('Gateway provisioning errors', () => {
});
});
describe('security event connection transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('sends only the public transmitter issuer and concurrency headers', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' });
expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i);
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-');
});
});
describe('OIDC browser session transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
+35
View File
@@ -43,6 +43,7 @@ import type {
RateLimitWindow,
RuntimePolicySet,
RuntimePolicySetUpsertRequest,
SecurityEventConnectionResponse,
UserGroup,
UserGroupUpsertRequest,
WalletAdjustmentResponse,
@@ -1000,6 +1001,40 @@ export async function updateClientCustomizationSettings(
});
}
const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection';
export async function getSecurityEventConnection(token: string): Promise<SecurityEventConnectionResponse> {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
}
export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string): Promise<SecurityEventConnectionResponse> {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token,
headers: { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` },
});
}
export async function verifySecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/verify', version, 'POST', 'verify');
}
export async function rotateSecurityEventConnectionCredential(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/rotate-credential', version, 'POST', 'rotate');
}
export async function disconnectSecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '', version, 'DELETE', 'disconnect');
}
function securityEventConnectionWrite(token: string, suffix: string, version: number, method: string, action: string) {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath + suffix, {
method, token, headers: {
'Idempotency-Key': `ssf-${action}-${crypto.randomUUID()}`,
'If-Match': `W/"${version}"`,
},
});
}
export async function getPublicClientCustomization(): Promise<ClientCustomizationSettings> {
return request<ClientCustomizationSettings>('/api/v1/public/client-customization', { auth: false });
}
+2
View File
@@ -23,6 +23,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroup,
} from '@easyai-ai-gateway/contracts';
@@ -48,6 +49,7 @@ export interface ConsoleData {
modelRateLimits: ModelRateLimitStatus[];
modelRateLimitsUpdatedAt: number | null;
runtimePolicySets: RuntimePolicySet[];
securityEventConnection: SecurityEventConnectionResponse | null;
taskResult: GatewayTask | null;
tasks: GatewayTask[];
tenants: GatewayTenant[];
+11
View File
@@ -82,6 +82,11 @@ export function AdminPage(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
@@ -189,12 +194,18 @@ export function AdminPage(props: {
channels={props.data.fileStorageChannels}
clientCustomizationSettings={props.data.clientCustomizationSettings}
settings={props.data.fileStorageSettings}
securityEventConnection={props.data.securityEventConnection}
message={props.operationMessage}
state={props.state}
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings}
onConnectSecurityEvents={props.onConnectSecurityEvents}
onDisconnectSecurityEvents={props.onDisconnectSecurityEvents}
onRefreshSecurityEvents={props.onRefreshSecurityEvents}
onRotateSecurityEventsCredential={props.onRotateSecurityEventsCredential}
onVerifySecurityEvents={props.onVerifySecurityEvents}
/>
)}
</div>
@@ -1,10 +1,10 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, Trash2 } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents';
type ClientCustomizationForm = {
clientEnglishName: string;
@@ -58,11 +58,17 @@ export function SystemSettingsPanel(props: {
clientCustomizationSettings: ClientCustomizationSettings | null;
message: string;
settings: FileStorageSettings | null;
securityEventConnection: SecurityEventConnectionResponse | null;
state: LoadState;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
}) {
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
const [dialogOpen, setDialogOpen] = useState(false);
@@ -72,6 +78,8 @@ export function SystemSettingsPanel(props: {
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf');
const [disconnectOpen, setDisconnectOpen] = useState(false);
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
@@ -81,6 +89,13 @@ export function SystemSettingsPanel(props: {
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
}, [props.clientCustomizationSettings]);
useEffect(() => {
const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus;
if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined;
const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000);
return () => window.clearInterval(timer);
}, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]);
function openCreateDialog() {
setEditingChannel(null);
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
@@ -162,6 +177,7 @@ export function SystemSettingsPanel(props: {
tabs={[
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
{ value: 'securityEvents', label: '认证中心安全事件', icon: <ShieldCheck size={15} /> },
]}
onValueChange={setActiveTab}
/>
@@ -275,6 +291,20 @@ export function SystemSettingsPanel(props: {
</section>
)}
{activeTab === 'securityEvents' && (
<SecurityEventConnectionPanel
connection={props.securityEventConnection}
issuer={transmitterIssuer}
loading={props.state === 'loading'}
onIssuerChange={setTransmitterIssuer}
onConnect={props.onConnectSecurityEvents}
onDisconnect={() => setDisconnectOpen(true)}
onRefresh={props.onRefreshSecurityEvents}
onRotate={props.onRotateSecurityEventsCredential}
onVerify={props.onVerifySecurityEvents}
/>
)}
<FormDialog
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
bodyClassName="fileStorageDialogBody"
@@ -363,10 +393,106 @@ export function SystemSettingsPanel(props: {
onCancel={() => setPendingDeleteChannel(null)}
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
/>
<ConfirmDialog
confirmLabel="断开连接"
description="系统会先删除远端 Stream,并继续使用水位和 RFC 7662 至少 360 秒;不会让旧 Token 因断开而重新有效。"
loading={props.state === 'loading'}
open={disconnectOpen}
title="确认断开认证中心安全事件连接?"
onCancel={() => setDisconnectOpen(false)}
onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }}
/>
</div>
);
}
function SecurityEventConnectionPanel(props: {
connection: SecurityEventConnectionResponse | null;
issuer: string;
loading: boolean;
onIssuerChange(value: string): void;
onConnect(issuer: string): Promise<void>;
onDisconnect(): void;
onRefresh(): Promise<void>;
onRotate(): Promise<void>;
onVerify(): Promise<void>;
}) {
const connection = props.connection?.connection;
const prerequisites = props.connection?.prerequisites;
const missing = [
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
].filter(Boolean);
return (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong>SSF / CAEP </strong>
<span>Gateway Push Bearer RFC 7662 Client</span>
</div>
<Badge variant={connection?.lifecycleStatus === 'enabled' ? 'success' : connection ? 'secondary' : 'outline'}>
{securityEventLifecycleLabel(connection?.lifecycleStatus)}
</Badge>
<Button type="button" variant="outline" size="sm" onClick={() => void props.onRefresh()} disabled={props.loading}>
<RefreshCw size={14} />
</Button>
</div>
{!connection && (
<Card>
<CardContent className="pageStack">
<div>
<strong></strong>
<p className="mutedText"> Application Gateway </p>
</div>
{missing.length > 0 && <div className="formMessage">{missing.join('')}</div>}
<div className="fileStorageMeta">
<span> Client: {prerequisites?.managementClientId || '未配置'}</span>
</div>
<Label>
SSF Transmitter Issuer
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
<small> IssuerEndpointScopePush Bearer Stream </small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
<Link2 size={15} />
</Button>
</CardContent>
</Card>
)}
{connection && (
<Card>
<CardContent className="pageStack">
<div className="fileStorageMeta">
<span> Client: {connection.managementClientId}</span>
<span>Receiver Endpoint: {connection.receiverEndpoint}</span>
<span>Issuer: {connection.transmitterIssuer}</span>
<span>Audience: {connection.audience ?? '正在获取'}</span>
<span>Stream ID: {connection.streamId ?? '正在创建'}</span>
<span>: {securityEventHealthLabel(connection.healthMode)}</span>
<span> Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'}</span>
{connection.lastErrorCategory && <span>: {connection.lastErrorCategory}</span>}
</div>
<div className="fileStorageToolbar">
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={() => void props.onVerify()}><RefreshCw size={14} /></Button>
<Button type="button" variant="outline" size="sm" disabled={props.loading || connection.lifecycleStatus === 'rotating'} onClick={() => void props.onRotate()}><RotateCcw size={14} /></Button>
<Button type="button" variant="destructive" size="sm" disabled={props.loading || connection.lifecycleStatus === 'retiring'} onClick={props.onDisconnect}><Unplug size={14} /></Button>
</div>
</CardContent>
</Card>
)}
</section>
);
}
function securityEventLifecycleLabel(status?: string) {
return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record<string, string>)[status ?? ''] ?? '未连接';
}
function securityEventHealthLabel(mode: string) {
return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record<string, string>)[mode] ?? mode;
}
function defaultClientCustomizationForm(): ClientCustomizationForm {
return {
clientEnglishName: 'EasyAI AI Gateway',