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:
chengcheng 2026-07-15 17:25:00 +08:00
parent f30aaeb2d4
commit 9efeb16fd1
31 changed files with 2741 additions and 230 deletions

View File

@ -35,19 +35,16 @@ OIDC_ACCEPT_LEGACY_HS256=true
OIDC_INTROSPECTION_ENABLED=false
OIDC_INTROSPECTION_CLIENT_ID=
OIDC_INTROSPECTION_CLIENT_SECRET=
# Optional SSF/CAEP real-time revocation receiver. Enabling it also requires
# the RFC 7662 credentials above so bootstrap and fail-closed fallback work.
OIDC_SECURITY_EVENTS_ENABLED=false
OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER=https://auth.51easyai.com/ssf
OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE=
OIDC_SECURITY_EVENTS_BEARER_SECRET=
# During zero-downtime rotation, set next before removing current.
OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT=
OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT=https://api.51easyai.com/api/v1/security-events/ssf
OIDC_SECURITY_EVENTS_STREAM_ID=
OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL=
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID=
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET=
# SSF/CAEP is activated by the durable connection created in System Settings;
# there is no enable flag and no Push Bearer in environment variables.
AI_GATEWAY_PUBLIC_BASE_URL=http://localhost:8088
OIDC_SECURITY_EVENTS_SECRET_STORE=file
OIDC_SECURITY_EVENTS_SECRET_DIR=.local-secrets/ssf
# Kubernetes uses one pre-provisioned empty Secret and narrowly scoped RBAC.
# OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes
# OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai
# OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events
# OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER=https://kubernetes.default.svc
OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60
OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS=180
OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ node_modules/
.DS_Store
.env
.env.local
.local-secrets/
.gateway-local-password
*.log

View File

@ -56,7 +56,7 @@ OIDC_SESSION_ENCRYPTION_KEY=<base64 编码的独立 32 字节随机密钥>
Web Console 使用公共 Client + PKCE + Gateway BFF SessionGateway 服务端换码并加密保存 Access/Refresh/ID Token浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。
可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token并删除同一租户 Subject 的 BFF Session。推送不健康时自动切换 RFC 7662内省也不可用时 OIDC Fail ClosedAPI Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。
可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token并删除同一租户 Subject 的 BFF Session。功能由“系统设置”中的持久连接启用不使用环境变量开关Gateway 后端自动生成和托管 Push Bearer并复用现有 RFC 7662 机器 Client。推送不健康时自动切换 RFC 7662内省也不可用时 OIDC Fail ClosedAPI Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。
`pnpm dev` 会先创建数据库并执行 migration然后并行启动

View File

@ -48,6 +48,7 @@ type OIDCSecurityEventIdentity struct {
}
type OIDCSecurityEventEvaluation struct {
Enabled bool
Revoked bool
RequireIntrospection bool
}
@ -91,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if (config.IntrospectionEnabled || config.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
@ -145,9 +146,6 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
return nil, oidcUnauthorized("nbf is missing", nil)
}
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
if v.config.SecurityEventEvaluator != nil && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
scopes := scopeClaims(claims)
if !containsAll(scopes, v.config.RequiredScopes) {
return nil, oidcUnauthorized("required scope is missing", nil)
@ -166,6 +164,9 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
}
if evaluation.Enabled && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
if evaluation.Revoked {
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
}
@ -177,6 +178,11 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if !active {
return nil, oidcUnauthorized("token is inactive", nil)
}
} else if !evaluation.Enabled && v.config.IntrospectionEnabled {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil || !active {
return nil, oidcUnauthorized("token is inactive", introspectionErr)
}
}
} else if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)

View File

@ -179,9 +179,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
verifier, err := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true,
IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret",
SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) {
if identity.Subject != "platform-subject" || identity.IssuedAt.IsZero() {
if identity.Subject != "platform-subject" {
t.Fatal("security event evaluator received incomplete identity")
}
return evaluation, nil
@ -194,6 +195,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("healthy push token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Revoked: true}
if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 {
t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls)
@ -205,6 +210,16 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable {
t.Fatalf("introspection outage error=%T %v", err, err)
}
withoutIssuedAt := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { delete(claims, "iat") })
evaluation = OIDCSecurityEventEvaluation{}
introspectionAvailable = true
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err != nil {
t.Fatalf("unconfigured security events unexpectedly required iat: %v", err)
}
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err == nil {
t.Fatal("configured security events accepted a token without iat")
}
}
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {

View File

@ -8,8 +8,6 @@ import (
"os"
"strconv"
"strings"
"github.com/google/uuid"
)
const (
@ -38,16 +36,13 @@ type Config struct {
OIDCIntrospectionEnabled bool
OIDCIntrospectionClientID string
OIDCIntrospectionClientSecret string
OIDCSecurityEventsEnabled bool
OIDCSecurityEventsTransmitterIssuer string
OIDCSecurityEventsReceiverAudience string
OIDCSecurityEventsBearerSecret string
OIDCSecurityEventsBearerSecretNext string
OIDCSecurityEventsPublicEndpoint string
OIDCSecurityEventsStreamID string
OIDCSecurityEventsManagementTokenURL string
OIDCSecurityEventsManagementClientID string
OIDCSecurityEventsManagementClientSecret string
OIDCSecurityEventsSecretStore string
OIDCSecurityEventsSecretDir string
OIDCSecurityEventsKubernetesNamespace string
OIDCSecurityEventsKubernetesSecretName string
OIDCSecurityEventsKubernetesAPIServer string
OIDCSecurityEventsKubernetesTokenFile string
OIDCSecurityEventsKubernetesCAFile string
OIDCSecurityEventsHeartbeatIntervalSeconds int
OIDCSecurityEventsStaleAfterSeconds int
OIDCSecurityEventsClockSkewSeconds int
@ -80,6 +75,9 @@ type Config struct {
func Load() Config {
globalProxy := LoadGlobalHTTPProxyStatus()
appEnv := env("APP_ENV", "development")
oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/")
introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "")
introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "")
return Config{
AppEnv: appEnv,
HTTPAddr: env("HTTP_ADDR", ":8088"),
@ -94,7 +92,7 @@ func Load() Config {
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
OIDCIssuer: oidcIssuer,
OIDCAudience: env("OIDC_AUDIENCE", ""),
OIDCTenantID: env("OIDC_TENANT_ID", ""),
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
@ -102,18 +100,15 @@ func Load() Config {
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true",
OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"),
OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""),
OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""),
OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""),
OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""),
OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""),
OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""),
OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""),
OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""),
OIDCIntrospectionClientID: introspectionClientID,
OIDCIntrospectionClientSecret: introspectionClientSecret,
OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"),
OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"),
OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")),
OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"),
OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"),
OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
@ -149,6 +144,26 @@ func Load() Config {
}
func (c Config) Validate() error {
switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) {
case "":
case "file":
if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" {
return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore")
}
case "kubernetes":
if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" {
return errors.New("Kubernetes security event SecretStore requires namespace and Secret name")
}
default:
return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes")
}
if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 {
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
}
}
if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" {
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
}
@ -185,59 +200,9 @@ func (c Config) Validate() error {
}
}
}
if c.OIDCSecurityEventsEnabled {
if !c.OIDCEnabled {
return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled")
}
if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) ||
!validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) ||
!validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) {
return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs")
}
if _, err := uuid.Parse(c.OIDCTenantID); err != nil {
return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled")
}
applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:")
if applicationID == c.OIDCSecurityEventsReceiverAudience {
return errors.New("OIDC security event receiver audience and stream ID are required")
}
if _, err := uuid.Parse(applicationID); err != nil {
return errors.New("OIDC security event receiver audience must contain an application UUID")
}
if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil {
return errors.New("OIDC security event stream ID must be a UUID")
}
if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) ||
c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) {
return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters")
}
if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" {
return errors.New("OIDC security event management machine client credentials are required")
}
if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" {
return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled")
}
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
}
}
return nil
}
func validBearerSecret(value string) bool {
if len(value) < 16 || len(value) > 4096 {
return false
}
for _, character := range []byte(value) {
if character < 0x21 || character > 0x7e {
return false
}
}
return true
}
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
@ -260,25 +225,6 @@ func validPublicRedirectURL(raw string) bool {
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validServiceURL(raw, appEnv string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return false
}
if parsed.Scheme == "https" {
return true
}
return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validSecurityEventReceiverURL(raw, appEnv string) bool {
if !validServiceURL(raw, appEnv) {
return false
}
parsed, err := url.Parse(strings.TrimSpace(raw))
return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == ""
}
func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":

View File

@ -137,33 +137,46 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
}
}
func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) {
func TestSecurityEventsUseDurableConnectionInsteadOfAnEnableFlag(t *testing.T) {
t.Setenv("OIDC_SECURITY_EVENTS_ENABLED", "true")
t.Setenv("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", "https://untrusted.example/ssf")
t.Setenv("OIDC_SECURITY_EVENTS_BEARER_SECRET", "must-not-be-loaded")
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_STORE", "file")
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_DIR", ".test-secrets/ssf")
cfg := Load()
if cfg.OIDCSecurityEventsSecretStore != "file" || cfg.OIDCSecurityEventsSecretDir != ".test-secrets/ssf" {
t.Fatalf("secret directory=%q", cfg.OIDCSecurityEventsSecretDir)
}
if err := (Config{}).Validate(); err != nil {
t.Fatalf("disabled security events changed existing config: %v", err)
}
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)
}
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)
}
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/wrong-path"
if err := cfg.Validate(); err == nil {
t.Fatal("non-canonical security event receiver path was accepted")
t.Fatalf("an absent security event connection changed existing config: %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)
}
config.OIDCSecurityEventsKubernetesNamespace = "easyai"
if err := config.Validate(); err != nil {
t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err)
}
}
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")
}
}

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -30,6 +30,7 @@ type Server struct {
logger *slog.Logger
geminiUploadSessions sync.Map
securityEventReceiver http.Handler
securityEventManager *ssfreceiver.ConnectionManager
}
type oidcPublicClient interface {
@ -66,46 +67,29 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
securityEventMetrics := &ssfreceiver.Metrics{}
var securityEventService *ssfreceiver.Service
if cfg.OIDCSecurityEventsEnabled {
ssfVerifier, err := ssfreceiver.NewVerifier(ssfreceiver.VerifierConfig{
TransmitterIssuer: cfg.OIDCSecurityEventsTransmitterIssuer,
Audience: cfg.OIDCSecurityEventsReceiverAudience, SubjectIssuer: cfg.OIDCIssuer,
TenantID: cfg.OIDCTenantID, StreamID: cfg.OIDCSecurityEventsStreamID,
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
})
if cfg.OIDCEnabled {
secretStore, err := securityEventSecretStore(cfg)
if err != nil {
panic("invalid OIDC security event verifier configuration: " + err.Error())
panic("invalid OIDC security event secret store: " + err.Error())
}
ssfVerifier.SetMetrics(securityEventMetrics)
securityEventService, err = ssfreceiver.NewService(db, ssfreceiver.ServiceConfig{
Issuer: cfg.OIDCSecurityEventsTransmitterIssuer, Audience: cfg.OIDCSecurityEventsReceiverAudience,
StreamID: cfg.OIDCSecurityEventsStreamID, ManagementTokenURL: cfg.OIDCSecurityEventsManagementTokenURL,
ManagementClientID: cfg.OIDCSecurityEventsManagementClientID, ManagementSecret: cfg.OIDCSecurityEventsManagementClientSecret,
manager, err := ssfreceiver.NewConnectionManager(ctx, db, secretStore, ssfreceiver.ConnectionManagerConfig{
AppEnv: cfg.AppEnv, OIDCEnabled: cfg.OIDCEnabled, OIDCIssuer: cfg.OIDCIssuer, OIDCTenantID: cfg.OIDCTenantID,
ManagementClientID: cfg.OIDCIntrospectionClientID, ManagementClientSecret: cfg.OIDCIntrospectionClientSecret,
PublicBaseURL: cfg.PublicBaseURL,
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
})
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
}, securityEventMetrics)
if err != nil {
panic("invalid OIDC security event heartbeat configuration: " + err.Error())
panic("initialize OIDC security event connection manager: " + err.Error())
}
securityEventService.SetMetrics(securityEventMetrics)
if err := securityEventService.Start(ctx); err != nil {
panic("OIDC security event state initialization failed")
}
receiver, err := ssfreceiver.NewHandler(
ssfVerifier, db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience,
cfg.OIDCSecurityEventsStreamID, cfg.OIDCSecurityEventsBearerSecret, cfg.OIDCSecurityEventsBearerSecretNext,
)
if err != nil {
panic("invalid OIDC security event receiver configuration: " + err.Error())
}
receiver.SetMetrics(securityEventMetrics)
server.securityEventReceiver = receiver
server.securityEventManager = manager
server.securityEventReceiver = manager
}
if cfg.OIDCEnabled {
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
if securityEventService != nil {
evaluator = securityEventService.Evaluate
if server.securityEventManager != nil {
evaluator = server.securityEventManager.Evaluate
}
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
@ -164,7 +148,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.Handle("GET /metrics", securityEventMetrics.Handler(db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, cfg.OIDCSecurityEventsEnabled))
mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
@ -175,9 +159,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
if server.securityEventReceiver != nil {
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
}
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels)))
@ -247,6 +229,11 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings)))
mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings)))
mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection)))
mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection)))
mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection)))
mux.Handle("POST /api/admin/system/identity/security-events/connection/rotate-credential", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rotateSecurityEventConnectionCredential)))
mux.Handle("DELETE /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteSecurityEventConnection)))
mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels)))
mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel)))
mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel)))
@ -324,6 +311,25 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
}
func securityEventSecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) {
switch strings.ToLower(strings.TrimSpace(cfg.OIDCSecurityEventsSecretStore)) {
case "", "file":
directory := cfg.OIDCSecurityEventsSecretDir
if directory == "" {
directory = ".local-secrets/ssf"
}
return ssfreceiver.NewFileSecretStore(directory)
case "kubernetes":
return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{
Namespace: cfg.OIDCSecurityEventsKubernetesNamespace, SecretName: cfg.OIDCSecurityEventsKubernetesSecretName,
APIServer: cfg.OIDCSecurityEventsKubernetesAPIServer, TokenFile: cfg.OIDCSecurityEventsKubernetesTokenFile,
CAFile: cfg.OIDCSecurityEventsKubernetesCAFile,
})
default:
return nil, errors.New("unsupported OIDC security event SecretStore")
}
}
func oidcSessionRequestError(err error) error {
switch {
case err == nil:
@ -361,7 +367,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, If-Match, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -75,13 +75,24 @@ func NewService(repository StateRepository, config ServiceConfig) (*Service, err
}
func (s *Service) Start(ctx context.Context) error {
if err := s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID); err != nil {
if err := s.Initialize(ctx); err != nil {
return err
}
go s.run(ctx)
go s.Run(ctx)
return nil
}
func (s *Service) Initialize(ctx context.Context) error {
return s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID)
}
func (s *Service) Run(ctx context.Context) { s.run(ctx) }
// RequestVerification asks for an immediate verification without changing the
// remote stream status. In particular, this never re-enables a stream paused
// by an authentication-center administrator.
func (s *Service) RequestVerification(ctx context.Context) { s.sendVerification(ctx) }
func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
result, err := s.repository.EvaluateOIDCSecurityEvent(
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject,

View File

@ -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
}

View File

@ -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);

View File

@ -32,6 +32,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroupUpsertRequest,
UserGroup,
WalletRechargeRequest,
@ -46,6 +47,7 @@ import {
createPlatform,
createTenant,
createUserGroup,
connectSecurityEventTransmitter,
deleteAccessRule,
deleteApiKey,
deleteFileStorageChannel,
@ -53,6 +55,7 @@ import {
deletePlatform,
deleteTenant,
deleteUserGroup,
disconnectSecurityEventConnection,
GatewayApiError,
getClientCustomizationSettings,
getHealth,
@ -61,6 +64,7 @@ import {
getFileStorageSettings,
getNetworkProxyConfig,
getRunnerPolicy,
getSecurityEventConnection,
getWalletSummary,
listAccessRules,
listAuditLogs,
@ -93,6 +97,7 @@ import {
rechargeUserWalletBalance,
replacePlatformModels,
restoreModelRuntimeStatus,
rotateSecurityEventConnectionCredential,
type HealthResponse,
updateAccessRule,
updateApiKeyScopes,
@ -104,6 +109,7 @@ import {
updatePlatformDynamicPriority,
updateTenant,
updateUserGroup,
verifySecurityEventConnection,
} from './api';
import type { ConsoleData, StatItem } from './app-state';
import { AppShell } from './components/layout/AppShell';
@ -173,6 +179,7 @@ type DataKey =
| 'clientCustomizationSettings'
| 'fileStorageChannels'
| 'fileStorageSettings'
| 'securityEventConnection'
| 'platforms'
| 'models'
| 'providers'
@ -222,6 +229,7 @@ export function App() {
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
const [fileStorageSettings, setFileStorageSettings] = useState<FileStorageSettings | null>(null);
const [securityEventConnection, setSecurityEventConnection] = useState<SecurityEventConnectionResponse | null>(null);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
@ -383,6 +391,7 @@ export function App() {
modelRateLimits,
modelRateLimitsUpdatedAt,
runtimePolicySets,
securityEventConnection,
taskResult,
tasks,
tenants,
@ -390,7 +399,7 @@ export function App() {
users,
walletAccounts,
walletTransactions,
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@ -480,6 +489,9 @@ export function App() {
case 'fileStorageSettings':
setFileStorageSettings(await getFileStorageSettings(nextToken));
return;
case 'securityEventConnection':
setSecurityEventConnection(await getSecurityEventConnection(nextToken));
return;
case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items);
return;
@ -1017,6 +1029,44 @@ export function App() {
}
}
async function connectSecurityEvents(transmitterIssuer: string) {
setCoreState('loading');
setCoreMessage('');
try {
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer);
setSecurityEventConnection(connection);
setCoreState('ready');
setCoreMessage('认证中心安全事件连接正在验证。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '连接认证中心失败');
throw err;
}
}
async function refreshSecurityEvents() {
const connection = await getSecurityEventConnection(token);
setSecurityEventConnection(connection);
}
async function verifySecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await verifySecurityEventConnection(token, connection.version));
}
async function rotateSecurityEventsCredential() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await rotateSecurityEventConnectionCredential(token, connection.version));
}
async function disconnectSecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await disconnectSecurityEventConnection(token, connection.version));
}
async function removeFileStorageChannel(channelId: string) {
setCoreState('loading');
setCoreMessage('');
@ -1095,6 +1145,7 @@ export function App() {
setPlaygroundModels([]);
setNetworkProxyConfig(null);
setFileStorageChannels([]);
setSecurityEventConnection(null);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
@ -1332,6 +1383,11 @@ export function App() {
onSaveFileStorageChannel={saveFileStorageChannel}
onSaveFileStorageSettings={saveFileStorageSettings}
onSaveClientCustomizationSettings={saveClientCustomizationSettings}
onConnectSecurityEvents={connectSecurityEvents}
onDisconnectSecurityEvents={disconnectSecurityEvents}
onRefreshSecurityEvents={refreshSecurityEvents}
onRotateSecurityEventsCredential={rotateSecurityEventsCredential}
onVerifySecurityEvents={verifySecurityEvents}
onSaveTenant={saveTenant}
onSaveUser={saveUser}
onRechargeUserWalletBalance={rechargeUserWallet}
@ -1553,7 +1609,7 @@ function dataKeysForRoute(
case 'accessRules':
return ['accessRules', 'userGroups', 'platforms', 'models'];
case 'systemSettings':
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels'];
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels', 'securityEventConnection'];
default:
return [];
}

View File

@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
connectSecurityEventTransmitter,
deleteOIDCBrowserSession,
GatewayApiError,
gatewayErrorMessage,
@ -34,6 +35,29 @@ describe('Gateway provisioning errors', () => {
});
});
describe('security event connection transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('sends only the public transmitter issuer and concurrency headers', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' });
expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i);
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-');
});
});
describe('OIDC browser session transport', () => {
afterEach(() => {
vi.unstubAllGlobals();

View File

@ -43,6 +43,7 @@ import type {
RateLimitWindow,
RuntimePolicySet,
RuntimePolicySetUpsertRequest,
SecurityEventConnectionResponse,
UserGroup,
UserGroupUpsertRequest,
WalletAdjustmentResponse,
@ -1000,6 +1001,40 @@ export async function updateClientCustomizationSettings(
});
}
const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection';
export async function getSecurityEventConnection(token: string): Promise<SecurityEventConnectionResponse> {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
}
export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string): Promise<SecurityEventConnectionResponse> {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token,
headers: { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` },
});
}
export async function verifySecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/verify', version, 'POST', 'verify');
}
export async function rotateSecurityEventConnectionCredential(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/rotate-credential', version, 'POST', 'rotate');
}
export async function disconnectSecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '', version, 'DELETE', 'disconnect');
}
function securityEventConnectionWrite(token: string, suffix: string, version: number, method: string, action: string) {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath + suffix, {
method, token, headers: {
'Idempotency-Key': `ssf-${action}-${crypto.randomUUID()}`,
'If-Match': `W/"${version}"`,
},
});
}
export async function getPublicClientCustomization(): Promise<ClientCustomizationSettings> {
return request<ClientCustomizationSettings>('/api/v1/public/client-customization', { auth: false });
}

View File

@ -23,6 +23,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroup,
} from '@easyai-ai-gateway/contracts';
@ -48,6 +49,7 @@ export interface ConsoleData {
modelRateLimits: ModelRateLimitStatus[];
modelRateLimitsUpdatedAt: number | null;
runtimePolicySets: RuntimePolicySet[];
securityEventConnection: SecurityEventConnectionResponse | null;
taskResult: GatewayTask | null;
tasks: GatewayTask[];
tenants: GatewayTenant[];

View File

@ -82,6 +82,11 @@ export function AdminPage(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
@ -189,12 +194,18 @@ export function AdminPage(props: {
channels={props.data.fileStorageChannels}
clientCustomizationSettings={props.data.clientCustomizationSettings}
settings={props.data.fileStorageSettings}
securityEventConnection={props.data.securityEventConnection}
message={props.operationMessage}
state={props.state}
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings}
onConnectSecurityEvents={props.onConnectSecurityEvents}
onDisconnectSecurityEvents={props.onDisconnectSecurityEvents}
onRefreshSecurityEvents={props.onRefreshSecurityEvents}
onRotateSecurityEventsCredential={props.onRotateSecurityEventsCredential}
onVerifySecurityEvents={props.onVerifySecurityEvents}
/>
)}
</div>

View File

@ -1,10 +1,10 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, Trash2 } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents';
type ClientCustomizationForm = {
clientEnglishName: string;
@ -58,11 +58,17 @@ export function SystemSettingsPanel(props: {
clientCustomizationSettings: ClientCustomizationSettings | null;
message: string;
settings: FileStorageSettings | null;
securityEventConnection: SecurityEventConnectionResponse | null;
state: LoadState;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
}) {
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
const [dialogOpen, setDialogOpen] = useState(false);
@ -72,6 +78,8 @@ export function SystemSettingsPanel(props: {
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf');
const [disconnectOpen, setDisconnectOpen] = useState(false);
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
@ -81,6 +89,13 @@ export function SystemSettingsPanel(props: {
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
}, [props.clientCustomizationSettings]);
useEffect(() => {
const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus;
if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined;
const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000);
return () => window.clearInterval(timer);
}, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]);
function openCreateDialog() {
setEditingChannel(null);
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
@ -162,6 +177,7 @@ export function SystemSettingsPanel(props: {
tabs={[
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
{ value: 'securityEvents', label: '认证中心安全事件', icon: <ShieldCheck size={15} /> },
]}
onValueChange={setActiveTab}
/>
@ -275,6 +291,20 @@ export function SystemSettingsPanel(props: {
</section>
)}
{activeTab === 'securityEvents' && (
<SecurityEventConnectionPanel
connection={props.securityEventConnection}
issuer={transmitterIssuer}
loading={props.state === 'loading'}
onIssuerChange={setTransmitterIssuer}
onConnect={props.onConnectSecurityEvents}
onDisconnect={() => setDisconnectOpen(true)}
onRefresh={props.onRefreshSecurityEvents}
onRotate={props.onRotateSecurityEventsCredential}
onVerify={props.onVerifySecurityEvents}
/>
)}
<FormDialog
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
bodyClassName="fileStorageDialogBody"
@ -363,10 +393,106 @@ export function SystemSettingsPanel(props: {
onCancel={() => setPendingDeleteChannel(null)}
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
/>
<ConfirmDialog
confirmLabel="断开连接"
description="系统会先删除远端 Stream并继续使用水位和 RFC 7662 至少 360 秒;不会让旧 Token 因断开而重新有效。"
loading={props.state === 'loading'}
open={disconnectOpen}
title="确认断开认证中心安全事件连接?"
onCancel={() => setDisconnectOpen(false)}
onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }}
/>
</div>
);
}
function SecurityEventConnectionPanel(props: {
connection: SecurityEventConnectionResponse | null;
issuer: string;
loading: boolean;
onIssuerChange(value: string): void;
onConnect(issuer: string): Promise<void>;
onDisconnect(): void;
onRefresh(): Promise<void>;
onRotate(): Promise<void>;
onVerify(): Promise<void>;
}) {
const connection = props.connection?.connection;
const prerequisites = props.connection?.prerequisites;
const missing = [
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
].filter(Boolean);
return (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong>SSF / CAEP </strong>
<span>Gateway Push Bearer RFC 7662 Client</span>
</div>
<Badge variant={connection?.lifecycleStatus === 'enabled' ? 'success' : connection ? 'secondary' : 'outline'}>
{securityEventLifecycleLabel(connection?.lifecycleStatus)}
</Badge>
<Button type="button" variant="outline" size="sm" onClick={() => void props.onRefresh()} disabled={props.loading}>
<RefreshCw size={14} />
</Button>
</div>
{!connection && (
<Card>
<CardContent className="pageStack">
<div>
<strong></strong>
<p className="mutedText"> Application Gateway </p>
</div>
{missing.length > 0 && <div className="formMessage">{missing.join('')}</div>}
<div className="fileStorageMeta">
<span> Client: {prerequisites?.managementClientId || '未配置'}</span>
</div>
<Label>
SSF Transmitter Issuer
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
<small> IssuerEndpointScopePush Bearer Stream </small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
<Link2 size={15} />
</Button>
</CardContent>
</Card>
)}
{connection && (
<Card>
<CardContent className="pageStack">
<div className="fileStorageMeta">
<span> Client: {connection.managementClientId}</span>
<span>Receiver Endpoint: {connection.receiverEndpoint}</span>
<span>Issuer: {connection.transmitterIssuer}</span>
<span>Audience: {connection.audience ?? '正在获取'}</span>
<span>Stream ID: {connection.streamId ?? '正在创建'}</span>
<span>: {securityEventHealthLabel(connection.healthMode)}</span>
<span> Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'}</span>
{connection.lastErrorCategory && <span>: {connection.lastErrorCategory}</span>}
</div>
<div className="fileStorageToolbar">
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={() => void props.onVerify()}><RefreshCw size={14} /></Button>
<Button type="button" variant="outline" size="sm" disabled={props.loading || connection.lifecycleStatus === 'rotating'} onClick={() => void props.onRotate()}><RotateCcw size={14} /></Button>
<Button type="button" variant="destructive" size="sm" disabled={props.loading || connection.lifecycleStatus === 'retiring'} onClick={props.onDisconnect}><Unplug size={14} /></Button>
</div>
</CardContent>
</Card>
)}
</section>
);
}
function securityEventLifecycleLabel(status?: string) {
return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record<string, string>)[status ?? ''] ?? '未连接';
}
function securityEventHealthLabel(mode: string) {
return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record<string, string>)[mode] ?? mode;
}
function defaultClientCustomizationForm(): ClientCustomizationForm {
return {
clientEnglishName: 'EasyAI AI Gateway',

View File

@ -0,0 +1,34 @@
# Reference overlay for installations that run the Gateway in Kubernetes.
# Keep the Secret empty: the Gateway writes only generated Push Bearers.
apiVersion: v1
kind: Secret
metadata:
name: easyai-gateway-security-events
namespace: easyai
type: Opaque
data: {}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: easyai-gateway-security-events
namespace: easyai
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["easyai-gateway-security-events"]
verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: easyai-gateway-security-events
namespace: easyai
subjects:
- kind: ServiceAccount
name: easyai-ai-gateway
namespace: easyai
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: easyai-gateway-security-events

View File

@ -17,16 +17,8 @@ x-api-environment: &api-environment
OIDC_INTROSPECTION_ENABLED: ${OIDC_INTROSPECTION_ENABLED:-false}
OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-}
OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-}
OIDC_SECURITY_EVENTS_ENABLED: ${OIDC_SECURITY_EVENTS_ENABLED:-false}
OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER: ${OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER:-}
OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE: ${OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE:-}
OIDC_SECURITY_EVENTS_BEARER_SECRET: ${OIDC_SECURITY_EVENTS_BEARER_SECRET:-}
OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT: ${OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT:-}
OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT: ${OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT:-}
OIDC_SECURITY_EVENTS_STREAM_ID: ${OIDC_SECURITY_EVENTS_STREAM_ID:-}
OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL: ${OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL:-}
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID:-}
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET:-}
OIDC_SECURITY_EVENTS_SECRET_STORE: file
OIDC_SECURITY_EVENTS_SECRET_DIR: /app/data/security-events/secrets
OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60}
OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180}
OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60}

View File

@ -1,56 +1,67 @@
# SSF/CAEP 实时会话撤销运行手册
Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token并处理 OpenID CAEP `session-revoked`默认关闭;关闭时不会注册接收路由或启动状态机,也不会改变 OIDC、BFF、API Key 或 Legacy JWT 行为
Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token并处理 OpenID CAEP `session-revoked`功能由数据库中的连接资源控制:没有连接时保持原有 OIDC、BFF、API Key 和 Legacy JWT 行为;不再使用 `OIDC_SECURITY_EVENTS_ENABLED`
## 启用前提
## 用户接入流程
- Auth Center 已创建属于当前 Application/租户的 paused StreamAudience 为 `urn:easyai:ssf:receiver:{application-public-id}`
- Gateway OIDC `iss`、`tid` 与 Stream 绑定完全一致Stream ID 为公开 UUID不使用认证内核 ID。
- 接收 Bearer、Management Client Secret、RFC 7662 Client Secret 仅从 Git 忽略的 `.env`、Kubernetes Secret 或 Secret Manager 注入。
- `OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT` 必须是外部可访问的精确 HTTPS 地址;本地测试环境才允许 localhost HTTP。
- Management Client 至少有 `ssf.stream.read ssf.stream.verify`,不能使用浏览器 PKCE Client。
用户只执行两项操作:
完整配置键见仓库根目录 `.env.example`。启用时 `OIDC_SECURITY_EVENTS_ENABLED=true`,且 RFC 7662 Client ID/Secret 必须同时存在,否则启动失败。
1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope不会创建第二个 Client。
2. 在 Gateway“系统设置 → 认证中心安全事件”中填写认证中心公开 SSF Issuer点击“连接认证中心”。
## 启用顺序
Gateway 后端随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream并进入 360 秒 RFC 7662 bootstrap。浏览器看不到 Push Bearer、OAuth Secret 或 Secret 引用,管理员不编辑 Secret 文件,也不需要重启 Gateway。
1. 运行数据库迁移,部署仍保持功能关闭。
2. 在 Secret Store 创建接收 Bearer、Management Client Secret 和内省 Client Secret不要把值放进日志或工单。
3. 启用 Gateway。启动模式为 `bootstrap`,所有 OIDC Token 都先内省。
4. 在 Auth Center 对 paused Stream 发起 Verification确认 Gateway 返回空 Body 的 `202`
5. 启用 Stream。首次 Verification 成功后仍内省 360 秒,覆盖启用前已签发的最长 300 秒 Token。
6. 观察 `easyai_gateway_ssf_mode{mode="push_healthy"}` 变为 1再执行测试用户撤销。
前置配置只有既有 OIDC/RFC 7662 配置和 Gateway 公共地址:
健康时 Gateway 每 60 秒请求一次 Verification不按业务 QPS 请求 Auth Center。180 秒没有匹配的有效 Verification 会进入 `introspection_fallback`;只有最近收到的 Stream 状态为 `enabled` 时,连续两次 Verification 成功才会恢复,`paused`/`disabled` 状态下心跳不会错误恢复推送信任。降级期间内省不可用会对 OIDC 返回可审计的 503API Key 继续工作。
- `OIDC_ISSUER`、`OIDC_TENANT_ID`
- `OIDC_INTROSPECTION_ENABLED=true`
- `OIDC_INTROSPECTION_CLIENT_ID/SECRET`,与认证中心准备 Receiver 时选择的机器 Client 相同;
- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。
## 零停机 Bearer 轮换
第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。
1. 在 Gateway 写入 `OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT`,同时接受 current/next。
2. 更新 Auth Center 的 Stream `credential_ref` 指向 next。
3. 发起 Verification并确认投递和状态机健康。
4. 等待至少 180 秒后,将 next 提升为 current 并清空旧值。
## SecretStore
Gateway 使用常量时间比较两个值。响应、日志、审计和指标都不得包含 Authorization Header 或原始 SET
本地和 Docker 使用 `file` 驱动。服务自动创建目录并强制目录 `0700`、文件 `0600`Docker Compose 将目录放在持久化 `api_data` 卷中。用户不写入任何 `ssf-delivery-*` 文件。
## 监控与告警
Kubernetes 使用 `kubernetes` 驱动和一个部署时预创建的空 Secret
`GET /metrics` 输出以下低基数指标;生产入口应仅允许监控网络访问该路径:
```text
OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes
OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai
OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events
```
- `easyai_gateway_ssf_receipts_total{outcome}`accepted/rejected/duplicate。
- `easyai_gateway_ssf_sessions_deleted_total`:被撤销删除的 BFF Session。
- `easyai_gateway_ssf_watermark_rejections_total`:本地水位拒绝的 Token。
- `easyai_gateway_ssf_processing_duration_seconds`Receiver 数据库事务处理直方图,用于 P99 告警。
- `easyai_gateway_ssf_verification_age_seconds``easyai_gateway_ssf_mode{mode}`
- `easyai_gateway_ssf_heartbeat_requests_total{outcome}`
- `easyai_gateway_oidc_introspection_total{outcome}`
- `easyai_gateway_jwks_refresh_failures_total{source}`SSF/OIDC JWKS 刷新失败。
参考清单见 `deploy/kubernetes/security-events-secret-rbac.yaml`。ServiceAccount 只能对这个固定 Secret 执行 `get/update/patch`,不能创建、删除或读取其他 Secret。数据库、API、浏览器、日志和审计只保存或展示非敏感连接元数据。
至少配置以下告警Verification age 超过 120 秒、fallback 超过 5 分钟、内省 failed 增长、SET rejected 增长、撤销端到端 P99 超过 3 秒。日志只能携带脱敏 Trace ID、Audit ID 和错误类别。
## 动态运行与状态
## 回滚
Receiver 路由始终注册:没有连接时返回无敏感信息的 `404`;存在连接但凭据丢失时返回 `503` 并进入 `introspection_fallback`。OIDC Evaluator 始终挂载但在无连接时无副作用。
先在 Auth Center 暂停 Stream再保持 Gateway 启用但持续使用 RFC 7662确认没有遗漏撤销后才关闭接收功能。保留迁移表和 Secret不做破坏性数据库回滚。Legacy HS256 Token 和 API Key 不在本协议撤销范围内
连接生命周期包括 `connecting`、`verifying`、`bootstrap`、`enabled`、`degraded`、`rotating`、`disconnect_pending` 和 `retiring`。健康时每 60 秒一次 Verification不随业务请求 QPS 增长180 秒无匹配 Verification 时切换 RFC 7662。内省也不可用时 OIDC Fail ClosedAPI Key 继续工作。
## 验收证据
Verification 成功后仅在首次连接和主动轮换时自动启用 Stream。认证中心管理员之后手工暂停或禁用 StreamGateway 的“重新验证”只检查链路,不会擅自恢复远端状态。
真实链路验收只能使用明确标记为测试用途的 Application且必须在自动化测试通过后执行。报告需包含脱敏 Trace、Claims 结构、截图、Audit ID、投递延迟、fallback 与恢复证据。涉及人工、真实设备或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`Mock、契约和安全测试不得跳过。
## 轮换与断开
“轮换凭据”由 Gateway 自动完成:生成 next、Receiver 同时接受 current/next、暂停并更新远端 Stream、Verification、重新启用、保留旧值至少 180 秒,最后提升 next 并删除旧值。中途失败保留两个凭据和 `rotating` 状态,可安全重试。
“断开连接”先删除远端 Stream再进入至少 360 秒 `retiring`,期间继续应用撤销水位和 RFC 7662避免旧 Token 因关闭功能重新有效。远端不可用时保持 `disconnect_pending` 并指数退避重试;不会提前删除 Push Secret。收据、水位和脱敏审计数据不会清表。
所有写管理接口要求 Gateway `Manager` 权限、`Idempotency-Key` 和 `If-Match`
```text
GET /api/admin/system/identity/security-events/connection
PUT /api/admin/system/identity/security-events/connection
POST /api/admin/system/identity/security-events/connection/verify
POST /api/admin/system/identity/security-events/connection/rotate-credential
DELETE /api/admin/system/identity/security-events/connection
```
## 监控、回滚与验收
`GET /metrics` 输出接收结果、删除 Session 数、水位拒绝数、Verification 年龄、健康模式、内省结果、JWKS 失败和处理延迟。至少告警 Verification age 超过 120 秒、fallback 超过 5 分钟、内省失败、SET 拒绝增长以及撤销 P99 超过 3 秒。日志只记录脱敏 Trace/Audit ID 和错误类别。
回滚时先在认证中心暂停 Stream再通过 Gateway 安全断开保留迁移表、水位和审计。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。
真实链路只使用明确标记为测试用途的 Application并且必须在自动化测试后执行。报告包含脱敏 Trace、Claims、截图、Audit ID、投递延迟、fallback 和恢复证据;需要人工或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`

View File

@ -957,6 +957,34 @@ export interface ClientCustomizationSettingsUpdateRequest {
iconPath?: string;
}
export interface SecurityEventConnection {
connectionId: string;
transmitterIssuer: string;
receiverEndpoint: string;
audience?: string;
streamId?: string;
lifecycleStatus: 'connecting' | 'verifying' | 'bootstrap' | 'enabled' | 'degraded' | 'rotating' | 'disconnect_pending' | 'retiring' | 'error' | string;
healthMode: 'disabled' | 'bootstrap' | 'push_healthy' | 'introspection_fallback' | string;
managementClientId: string;
lastVerificationAt?: string;
lastErrorCategory?: string;
version: number;
}
export interface SecurityEventConnectionPrerequisites {
oidcConfigured: boolean;
introspectionClientConfigured: boolean;
publicBaseUrlConfigured: boolean;
managementClientId: string;
}
export interface SecurityEventConnectionResponse {
connected: boolean;
lifecycleStatus?: 'disconnected' | string;
connection?: SecurityEventConnection;
prerequisites?: SecurityEventConnectionPrerequisites;
}
export interface GatewayTask {
id: string;
kind: string;