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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user