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:
parent
f30aaeb2d4
commit
9efeb16fd1
23
.env.example
23
.env.example
@ -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
1
.gitignore
vendored
@ -5,6 +5,7 @@ node_modules/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.local-secrets/
|
||||
.gateway-local-password
|
||||
*.log
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ OIDC_SESSION_ENCRYPTION_KEY=<base64 编码的独立 32 字节随机密钥>
|
||||
|
||||
Web Console 使用公共 Client + PKCE + Gateway BFF Session:Gateway 服务端换码并加密保存 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 Closed;API 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 Closed;API Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。
|
||||
|
||||
`pnpm dev` 会先创建数据库并执行 migration,然后并行启动:
|
||||
|
||||
|
||||
@ -48,6 +48,7 @@ type OIDCSecurityEventIdentity struct {
|
||||
}
|
||||
|
||||
type OIDCSecurityEventEvaluation struct {
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
RequireIntrospection bool
|
||||
}
|
||||
@ -91,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
|
||||
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
|
||||
return nil, errors.New("issuer, audience, tenant and role prefix are required")
|
||||
}
|
||||
if (config.IntrospectionEnabled || config.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
|
||||
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
|
||||
return nil, errors.New("introspection client credentials are required when introspection is enabled")
|
||||
}
|
||||
if config.JWKSCacheTTL <= 0 {
|
||||
@ -145,9 +146,6 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
return nil, oidcUnauthorized("nbf is missing", nil)
|
||||
}
|
||||
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
|
||||
if v.config.SecurityEventEvaluator != nil && !hasIssuedAt {
|
||||
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
|
||||
}
|
||||
scopes := scopeClaims(claims)
|
||||
if !containsAll(scopes, v.config.RequiredScopes) {
|
||||
return nil, oidcUnauthorized("required scope is missing", nil)
|
||||
@ -166,6 +164,9 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
if evaluateErr != nil {
|
||||
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
|
||||
}
|
||||
if evaluation.Enabled && !hasIssuedAt {
|
||||
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
|
||||
}
|
||||
if evaluation.Revoked {
|
||||
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
|
||||
}
|
||||
@ -177,6 +178,11 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
if !active {
|
||||
return nil, oidcUnauthorized("token is inactive", nil)
|
||||
}
|
||||
} else if !evaluation.Enabled && v.config.IntrospectionEnabled {
|
||||
active, introspectionErr := v.introspect(ctx, raw)
|
||||
if introspectionErr != nil || !active {
|
||||
return nil, oidcUnauthorized("token is inactive", introspectionErr)
|
||||
}
|
||||
}
|
||||
} else if v.config.IntrospectionEnabled {
|
||||
active, err := v.introspect(ctx, raw)
|
||||
|
||||
@ -179,9 +179,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
IntrospectionEnabled: true,
|
||||
IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret",
|
||||
SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) {
|
||||
if identity.Subject != "platform-subject" || identity.IssuedAt.IsZero() {
|
||||
if identity.Subject != "platform-subject" {
|
||||
t.Fatal("security event evaluator received incomplete identity")
|
||||
}
|
||||
return evaluation, nil
|
||||
@ -194,6 +195,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
|
||||
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
|
||||
t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls)
|
||||
}
|
||||
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
|
||||
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
|
||||
t.Fatalf("healthy push token error=%v calls=%d", err, introspectionCalls)
|
||||
}
|
||||
evaluation = OIDCSecurityEventEvaluation{Revoked: true}
|
||||
if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 {
|
||||
t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls)
|
||||
@ -205,6 +210,16 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
|
||||
if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("introspection outage error=%T %v", err, err)
|
||||
}
|
||||
withoutIssuedAt := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { delete(claims, "iat") })
|
||||
evaluation = OIDCSecurityEventEvaluation{}
|
||||
introspectionAvailable = true
|
||||
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err != nil {
|
||||
t.Fatalf("unconfigured security events unexpectedly required iat: %v", err)
|
||||
}
|
||||
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
|
||||
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err == nil {
|
||||
t.Fatal("configured security events accepted a token without iat")
|
||||
}
|
||||
}
|
||||
|
||||
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
|
||||
|
||||
@ -8,8 +8,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -38,16 +36,13 @@ type Config struct {
|
||||
OIDCIntrospectionEnabled bool
|
||||
OIDCIntrospectionClientID string
|
||||
OIDCIntrospectionClientSecret string
|
||||
OIDCSecurityEventsEnabled bool
|
||||
OIDCSecurityEventsTransmitterIssuer string
|
||||
OIDCSecurityEventsReceiverAudience string
|
||||
OIDCSecurityEventsBearerSecret string
|
||||
OIDCSecurityEventsBearerSecretNext string
|
||||
OIDCSecurityEventsPublicEndpoint string
|
||||
OIDCSecurityEventsStreamID string
|
||||
OIDCSecurityEventsManagementTokenURL string
|
||||
OIDCSecurityEventsManagementClientID string
|
||||
OIDCSecurityEventsManagementClientSecret string
|
||||
OIDCSecurityEventsSecretStore string
|
||||
OIDCSecurityEventsSecretDir string
|
||||
OIDCSecurityEventsKubernetesNamespace string
|
||||
OIDCSecurityEventsKubernetesSecretName string
|
||||
OIDCSecurityEventsKubernetesAPIServer string
|
||||
OIDCSecurityEventsKubernetesTokenFile string
|
||||
OIDCSecurityEventsKubernetesCAFile string
|
||||
OIDCSecurityEventsHeartbeatIntervalSeconds int
|
||||
OIDCSecurityEventsStaleAfterSeconds int
|
||||
OIDCSecurityEventsClockSkewSeconds int
|
||||
@ -80,6 +75,9 @@ type Config struct {
|
||||
func Load() Config {
|
||||
globalProxy := LoadGlobalHTTPProxyStatus()
|
||||
appEnv := env("APP_ENV", "development")
|
||||
oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/")
|
||||
introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "")
|
||||
introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "")
|
||||
return Config{
|
||||
AppEnv: appEnv,
|
||||
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
||||
@ -94,7 +92,7 @@ func Load() Config {
|
||||
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
||||
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
||||
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
|
||||
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
||||
OIDCIssuer: oidcIssuer,
|
||||
OIDCAudience: env("OIDC_AUDIENCE", ""),
|
||||
OIDCTenantID: env("OIDC_TENANT_ID", ""),
|
||||
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
|
||||
@ -102,18 +100,15 @@ func Load() Config {
|
||||
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
|
||||
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
|
||||
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
|
||||
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
|
||||
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
|
||||
OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true",
|
||||
OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"),
|
||||
OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""),
|
||||
OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""),
|
||||
OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""),
|
||||
OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""),
|
||||
OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""),
|
||||
OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""),
|
||||
OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""),
|
||||
OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""),
|
||||
OIDCIntrospectionClientID: introspectionClientID,
|
||||
OIDCIntrospectionClientSecret: introspectionClientSecret,
|
||||
OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"),
|
||||
OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"),
|
||||
OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")),
|
||||
OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"),
|
||||
OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"),
|
||||
OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
|
||||
OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
|
||||
OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
|
||||
OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
|
||||
OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
|
||||
@ -149,6 +144,26 @@ func Load() Config {
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) {
|
||||
case "":
|
||||
case "file":
|
||||
if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" {
|
||||
return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore")
|
||||
}
|
||||
case "kubernetes":
|
||||
if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" {
|
||||
return errors.New("Kubernetes security event SecretStore requires namespace and Secret name")
|
||||
}
|
||||
default:
|
||||
return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes")
|
||||
}
|
||||
if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 {
|
||||
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
|
||||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
|
||||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
|
||||
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
|
||||
}
|
||||
}
|
||||
if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" {
|
||||
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
|
||||
}
|
||||
@ -185,59 +200,9 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.OIDCSecurityEventsEnabled {
|
||||
if !c.OIDCEnabled {
|
||||
return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled")
|
||||
}
|
||||
if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) ||
|
||||
!validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) ||
|
||||
!validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) {
|
||||
return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs")
|
||||
}
|
||||
if _, err := uuid.Parse(c.OIDCTenantID); err != nil {
|
||||
return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled")
|
||||
}
|
||||
applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:")
|
||||
if applicationID == c.OIDCSecurityEventsReceiverAudience {
|
||||
return errors.New("OIDC security event receiver audience and stream ID are required")
|
||||
}
|
||||
if _, err := uuid.Parse(applicationID); err != nil {
|
||||
return errors.New("OIDC security event receiver audience must contain an application UUID")
|
||||
}
|
||||
if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil {
|
||||
return errors.New("OIDC security event stream ID must be a UUID")
|
||||
}
|
||||
if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) ||
|
||||
c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) {
|
||||
return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters")
|
||||
}
|
||||
if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" {
|
||||
return errors.New("OIDC security event management machine client credentials are required")
|
||||
}
|
||||
if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" {
|
||||
return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled")
|
||||
}
|
||||
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
|
||||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
|
||||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
|
||||
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validBearerSecret(value string) bool {
|
||||
if len(value) < 16 || len(value) > 4096 {
|
||||
return false
|
||||
}
|
||||
for _, character := range []byte(value) {
|
||||
if character < 0x21 || character > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
|
||||
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
|
||||
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
|
||||
@ -260,25 +225,6 @@ func validPublicRedirectURL(raw string) bool {
|
||||
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
|
||||
}
|
||||
|
||||
func validServiceURL(raw, appEnv string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return false
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return true
|
||||
}
|
||||
return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
|
||||
}
|
||||
|
||||
func validSecurityEventReceiverURL(raw, appEnv string) bool {
|
||||
if !validServiceURL(raw, appEnv) {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == ""
|
||||
}
|
||||
|
||||
func isLocalEnvironment(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "development", "dev", "local", "test":
|
||||
|
||||
@ -137,33 +137,46 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) {
|
||||
func TestSecurityEventsUseDurableConnectionInsteadOfAnEnableFlag(t *testing.T) {
|
||||
t.Setenv("OIDC_SECURITY_EVENTS_ENABLED", "true")
|
||||
t.Setenv("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", "https://untrusted.example/ssf")
|
||||
t.Setenv("OIDC_SECURITY_EVENTS_BEARER_SECRET", "must-not-be-loaded")
|
||||
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_STORE", "file")
|
||||
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_DIR", ".test-secrets/ssf")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.OIDCSecurityEventsSecretStore != "file" || cfg.OIDCSecurityEventsSecretDir != ".test-secrets/ssf" {
|
||||
t.Fatalf("secret directory=%q", cfg.OIDCSecurityEventsSecretDir)
|
||||
}
|
||||
if err := (Config{}).Validate(); err != nil {
|
||||
t.Fatalf("disabled security events changed existing config: %v", err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
234
apps/api/internal/httpapi/security_event_connection_handlers.go
Normal file
234
apps/api/internal/httpapi/security_event_connection_handlers.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityEventConnectionWriteHeaders(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing Idempotency-Key status=%d", recorder.Code)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
|
||||
request.Header.Set("If-Match", `W/"7"`)
|
||||
recorder = httptest.NewRecorder()
|
||||
if !matchConnectionVersion(recorder, request, 7) {
|
||||
t.Fatalf("valid weak ETag was rejected: status=%d", recorder.Code)
|
||||
}
|
||||
|
||||
request.Header.Set("If-Match", `"6"`)
|
||||
recorder = httptest.NewRecorder()
|
||||
if matchConnectionVersion(recorder, request, 7) || recorder.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("stale ETag status=%d", recorder.Code)
|
||||
}
|
||||
if normalizedConnectionETag(`W/"7"`) != "7" ||
|
||||
securityEventOperationHash("verify", "7") == securityEventOperationHash("verify", "8") {
|
||||
t.Fatal("security event idempotency request fingerprint is not stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventPrerequisitesNeverExposeSecrets(t *testing.T) {
|
||||
server := &Server{cfg: config.Config{
|
||||
OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer/shared", OIDCTenantID: "stable-tenant",
|
||||
OIDCIntrospectionClientID: "gateway-machine", OIDCIntrospectionClientSecret: "must-not-escape",
|
||||
PublicBaseURL: "https://gateway.example",
|
||||
}}
|
||||
payload, err := json.Marshal(server.securityEventPrerequisites())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(payload), "must-not-escape") || strings.Contains(strings.ToLower(string(payload)), "secret") {
|
||||
t.Fatalf("secret escaped in prerequisites: %s", payload)
|
||||
}
|
||||
}
|
||||
@ -18,5 +18,9 @@ import "net/http"
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/security-events/ssf [post]
|
||||
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if s.securityEventReceiver == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.securityEventReceiver.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ type Server struct {
|
||||
logger *slog.Logger
|
||||
geminiUploadSessions sync.Map
|
||||
securityEventReceiver http.Handler
|
||||
securityEventManager *ssfreceiver.ConnectionManager
|
||||
}
|
||||
|
||||
type oidcPublicClient interface {
|
||||
@ -66,46 +67,29 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
|
||||
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
|
||||
securityEventMetrics := &ssfreceiver.Metrics{}
|
||||
var securityEventService *ssfreceiver.Service
|
||||
if cfg.OIDCSecurityEventsEnabled {
|
||||
ssfVerifier, err := ssfreceiver.NewVerifier(ssfreceiver.VerifierConfig{
|
||||
TransmitterIssuer: cfg.OIDCSecurityEventsTransmitterIssuer,
|
||||
Audience: cfg.OIDCSecurityEventsReceiverAudience, SubjectIssuer: cfg.OIDCIssuer,
|
||||
TenantID: cfg.OIDCTenantID, StreamID: cfg.OIDCSecurityEventsStreamID,
|
||||
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
|
||||
})
|
||||
if cfg.OIDCEnabled {
|
||||
secretStore, err := securityEventSecretStore(cfg)
|
||||
if err != nil {
|
||||
panic("invalid OIDC security event verifier configuration: " + err.Error())
|
||||
panic("invalid OIDC security event secret store: " + err.Error())
|
||||
}
|
||||
ssfVerifier.SetMetrics(securityEventMetrics)
|
||||
securityEventService, err = ssfreceiver.NewService(db, ssfreceiver.ServiceConfig{
|
||||
Issuer: cfg.OIDCSecurityEventsTransmitterIssuer, Audience: cfg.OIDCSecurityEventsReceiverAudience,
|
||||
StreamID: cfg.OIDCSecurityEventsStreamID, ManagementTokenURL: cfg.OIDCSecurityEventsManagementTokenURL,
|
||||
ManagementClientID: cfg.OIDCSecurityEventsManagementClientID, ManagementSecret: cfg.OIDCSecurityEventsManagementClientSecret,
|
||||
manager, err := ssfreceiver.NewConnectionManager(ctx, db, secretStore, ssfreceiver.ConnectionManagerConfig{
|
||||
AppEnv: cfg.AppEnv, OIDCEnabled: cfg.OIDCEnabled, OIDCIssuer: cfg.OIDCIssuer, OIDCTenantID: cfg.OIDCTenantID,
|
||||
ManagementClientID: cfg.OIDCIntrospectionClientID, ManagementClientSecret: cfg.OIDCIntrospectionClientSecret,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
|
||||
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
|
||||
})
|
||||
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
|
||||
}, securityEventMetrics)
|
||||
if err != nil {
|
||||
panic("invalid OIDC security event heartbeat configuration: " + err.Error())
|
||||
panic("initialize OIDC security event connection manager: " + err.Error())
|
||||
}
|
||||
securityEventService.SetMetrics(securityEventMetrics)
|
||||
if err := securityEventService.Start(ctx); err != nil {
|
||||
panic("OIDC security event state initialization failed")
|
||||
}
|
||||
receiver, err := ssfreceiver.NewHandler(
|
||||
ssfVerifier, db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience,
|
||||
cfg.OIDCSecurityEventsStreamID, cfg.OIDCSecurityEventsBearerSecret, cfg.OIDCSecurityEventsBearerSecretNext,
|
||||
)
|
||||
if err != nil {
|
||||
panic("invalid OIDC security event receiver configuration: " + err.Error())
|
||||
}
|
||||
receiver.SetMetrics(securityEventMetrics)
|
||||
server.securityEventReceiver = receiver
|
||||
server.securityEventManager = manager
|
||||
server.securityEventReceiver = manager
|
||||
}
|
||||
if cfg.OIDCEnabled {
|
||||
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
|
||||
if securityEventService != nil {
|
||||
evaluator = securityEventService.Evaluate
|
||||
if server.securityEventManager != nil {
|
||||
evaluator = server.securityEventManager.Evaluate
|
||||
}
|
||||
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
||||
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
|
||||
@ -164,7 +148,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", server.health)
|
||||
mux.HandleFunc("GET /readyz", server.ready)
|
||||
mux.Handle("GET /metrics", securityEventMetrics.Handler(db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, cfg.OIDCSecurityEventsEnabled))
|
||||
mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
|
||||
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
|
||||
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
|
||||
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
|
||||
@ -175,9 +159,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
|
||||
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
|
||||
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
|
||||
if server.securityEventReceiver != nil {
|
||||
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
|
||||
}
|
||||
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
|
||||
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
|
||||
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
|
||||
mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels)))
|
||||
@ -247,6 +229,11 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
|
||||
mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings)))
|
||||
mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings)))
|
||||
mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection)))
|
||||
mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection)))
|
||||
mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection)))
|
||||
mux.Handle("POST /api/admin/system/identity/security-events/connection/rotate-credential", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rotateSecurityEventConnectionCredential)))
|
||||
mux.Handle("DELETE /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteSecurityEventConnection)))
|
||||
mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels)))
|
||||
mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel)))
|
||||
mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel)))
|
||||
@ -324,6 +311,25 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
|
||||
}
|
||||
|
||||
func securityEventSecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.OIDCSecurityEventsSecretStore)) {
|
||||
case "", "file":
|
||||
directory := cfg.OIDCSecurityEventsSecretDir
|
||||
if directory == "" {
|
||||
directory = ".local-secrets/ssf"
|
||||
}
|
||||
return ssfreceiver.NewFileSecretStore(directory)
|
||||
case "kubernetes":
|
||||
return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{
|
||||
Namespace: cfg.OIDCSecurityEventsKubernetesNamespace, SecretName: cfg.OIDCSecurityEventsKubernetesSecretName,
|
||||
APIServer: cfg.OIDCSecurityEventsKubernetesAPIServer, TokenFile: cfg.OIDCSecurityEventsKubernetesTokenFile,
|
||||
CAFile: cfg.OIDCSecurityEventsKubernetesCAFile,
|
||||
})
|
||||
default:
|
||||
return nil, errors.New("unsupported OIDC security event SecretStore")
|
||||
}
|
||||
}
|
||||
|
||||
func oidcSessionRequestError(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
@ -361,7 +367,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, If-Match, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
|
||||
940
apps/api/internal/securityevents/connection_manager.go
Normal file
940
apps/api/internal/securityevents/connection_manager.go
Normal 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
|
||||
}
|
||||
293
apps/api/internal/securityevents/connection_manager_test.go
Normal file
293
apps/api/internal/securityevents/connection_manager_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
186
apps/api/internal/securityevents/kubernetes_secret_store.go
Normal file
186
apps/api/internal/securityevents/kubernetes_secret_store.go
Normal 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)
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package securityevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKubernetesSecretStoreUsesOnePreprovisionedSecret(t *testing.T) {
|
||||
var mutex sync.Mutex
|
||||
data := map[string]string{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/namespaces/easyai/secrets/easyai-gateway-security-events" || r.Header.Get("Authorization") != "Bearer service-account-token" {
|
||||
t.Fatalf("unexpected Kubernetes request: %s authorization=%t", r.URL.Path, r.Header.Get("Authorization") != "")
|
||||
}
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
case http.MethodPatch:
|
||||
var patch struct {
|
||||
Data map[string]*string `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range patch.Data {
|
||||
if value == nil {
|
||||
delete(data, key)
|
||||
} else {
|
||||
data[key] = *value
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
default:
|
||||
http.Error(w, "unsupported", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(tokenFile, []byte("service-account-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{
|
||||
Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL,
|
||||
TokenFile: tokenFile, HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := []byte("0123456789abcdef0123456789abcdef")
|
||||
if err := secrets.Put(context.Background(), "ssf-push-connection", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data["ssf-push-connection"] != base64.StdEncoding.EncodeToString(value) {
|
||||
t.Fatal("Push Bearer was not stored in Kubernetes Secret data")
|
||||
}
|
||||
loaded, err := secrets.Get(context.Background(), "ssf-push-connection")
|
||||
if err != nil || string(loaded) != string(value) {
|
||||
t.Fatalf("loaded secret mismatch: err=%v", err)
|
||||
}
|
||||
clear(loaded)
|
||||
if err := secrets.Delete(context.Background(), "ssf-push-connection"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := data["ssf-push-connection"]; ok {
|
||||
t.Fatal("Push Bearer key was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubernetesSecretStoreRejectsUnprovisionedSecret(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.NotFound(w, nil) }))
|
||||
defer server.Close()
|
||||
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(tokenFile, []byte("token"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{
|
||||
Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL,
|
||||
TokenFile: tokenFile, HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := secrets.Put(context.Background(), "ssf-push-connection", []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("missing pre-provisioned Kubernetes Secret was accepted")
|
||||
}
|
||||
}
|
||||
@ -2,16 +2,24 @@ package securityevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type MetricsSnapshotProvider interface {
|
||||
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
|
||||
}
|
||||
|
||||
type DynamicMetricsSnapshotProvider interface {
|
||||
MetricsSnapshotProvider
|
||||
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
accepted atomic.Uint64
|
||||
rejected atomic.Uint64
|
||||
@ -151,6 +159,25 @@ func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience str
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Metrics) DynamicHandler(provider DynamicMetricsSnapshotProvider) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
connection, err := provider.SecurityEventConnection(r.Context())
|
||||
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
m.Handler(provider, "", "", false).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "metrics unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if connection.Audience == nil {
|
||||
m.Handler(provider, "", "", false).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
m.Handler(provider, connection.TransmitterIssuer, *connection.Audience, true).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type outcomeValue struct {
|
||||
name string
|
||||
value uint64
|
||||
|
||||
112
apps/api/internal/securityevents/secret_store.go
Normal file
112
apps/api/internal/securityevents/secret_store.go
Normal 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
|
||||
}
|
||||
40
apps/api/internal/securityevents/secret_store_test.go
Normal file
40
apps/api/internal/securityevents/secret_store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
175
apps/api/internal/store/security_event_connections.go
Normal file
175
apps/api/internal/store/security_event_connections.go
Normal 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
|
||||
}
|
||||
40
apps/api/migrations/0063_security_event_connections.sql
Normal file
40
apps/api/migrations/0063_security_event_connections.sql
Normal 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);
|
||||
@ -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 [];
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -82,6 +82,11 @@ export function AdminPage(props: {
|
||||
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
||||
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
|
||||
onDisconnectSecurityEvents: () => Promise<void>;
|
||||
onRefreshSecurityEvents: () => Promise<void>;
|
||||
onRotateSecurityEventsCredential: () => Promise<void>;
|
||||
onVerifySecurityEvents: () => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||
@ -189,12 +194,18 @@ export function AdminPage(props: {
|
||||
channels={props.data.fileStorageChannels}
|
||||
clientCustomizationSettings={props.data.clientCustomizationSettings}
|
||||
settings={props.data.fileStorageSettings}
|
||||
securityEventConnection={props.data.securityEventConnection}
|
||||
message={props.operationMessage}
|
||||
state={props.state}
|
||||
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
|
||||
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
|
||||
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
|
||||
onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings}
|
||||
onConnectSecurityEvents={props.onConnectSecurityEvents}
|
||||
onDisconnectSecurityEvents={props.onDisconnectSecurityEvents}
|
||||
onRefreshSecurityEvents={props.onRefreshSecurityEvents}
|
||||
onRotateSecurityEventsCredential={props.onRotateSecurityEventsCredential}
|
||||
onVerifySecurityEvents={props.onVerifySecurityEvents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, Trash2 } from 'lucide-react';
|
||||
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react';
|
||||
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type SystemSettingsTab = 'fileStorage' | 'clientCustomization';
|
||||
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents';
|
||||
|
||||
type ClientCustomizationForm = {
|
||||
clientEnglishName: string;
|
||||
@ -58,11 +58,17 @@ export function SystemSettingsPanel(props: {
|
||||
clientCustomizationSettings: ClientCustomizationSettings | null;
|
||||
message: string;
|
||||
settings: FileStorageSettings | null;
|
||||
securityEventConnection: SecurityEventConnectionResponse | null;
|
||||
state: LoadState;
|
||||
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
|
||||
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
||||
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
|
||||
onDisconnectSecurityEvents: () => Promise<void>;
|
||||
onRefreshSecurityEvents: () => Promise<void>;
|
||||
onRotateSecurityEventsCredential: () => Promise<void>;
|
||||
onVerifySecurityEvents: () => Promise<void>;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@ -72,6 +78,8 @@ export function SystemSettingsPanel(props: {
|
||||
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
||||
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf');
|
||||
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||
@ -81,6 +89,13 @@ export function SystemSettingsPanel(props: {
|
||||
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
||||
}, [props.clientCustomizationSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus;
|
||||
if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined;
|
||||
const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingChannel(null);
|
||||
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
|
||||
@ -162,6 +177,7 @@ export function SystemSettingsPanel(props: {
|
||||
tabs={[
|
||||
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
|
||||
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
|
||||
{ value: 'securityEvents', label: '认证中心安全事件', icon: <ShieldCheck size={15} /> },
|
||||
]}
|
||||
onValueChange={setActiveTab}
|
||||
/>
|
||||
@ -275,6 +291,20 @@ export function SystemSettingsPanel(props: {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'securityEvents' && (
|
||||
<SecurityEventConnectionPanel
|
||||
connection={props.securityEventConnection}
|
||||
issuer={transmitterIssuer}
|
||||
loading={props.state === 'loading'}
|
||||
onIssuerChange={setTransmitterIssuer}
|
||||
onConnect={props.onConnectSecurityEvents}
|
||||
onDisconnect={() => setDisconnectOpen(true)}
|
||||
onRefresh={props.onRefreshSecurityEvents}
|
||||
onRotate={props.onRotateSecurityEventsCredential}
|
||||
onVerify={props.onVerifySecurityEvents}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
|
||||
bodyClassName="fileStorageDialogBody"
|
||||
@ -363,10 +393,106 @@ export function SystemSettingsPanel(props: {
|
||||
onCancel={() => setPendingDeleteChannel(null)}
|
||||
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
confirmLabel="断开连接"
|
||||
description="系统会先删除远端 Stream,并继续使用水位和 RFC 7662 至少 360 秒;不会让旧 Token 因断开而重新有效。"
|
||||
loading={props.state === 'loading'}
|
||||
open={disconnectOpen}
|
||||
title="确认断开认证中心安全事件连接?"
|
||||
onCancel={() => setDisconnectOpen(false)}
|
||||
onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityEventConnectionPanel(props: {
|
||||
connection: SecurityEventConnectionResponse | null;
|
||||
issuer: string;
|
||||
loading: boolean;
|
||||
onIssuerChange(value: string): void;
|
||||
onConnect(issuer: string): Promise<void>;
|
||||
onDisconnect(): void;
|
||||
onRefresh(): Promise<void>;
|
||||
onRotate(): Promise<void>;
|
||||
onVerify(): Promise<void>;
|
||||
}) {
|
||||
const connection = props.connection?.connection;
|
||||
const prerequisites = props.connection?.prerequisites;
|
||||
const missing = [
|
||||
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
|
||||
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
|
||||
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<section className="fileStoragePanel">
|
||||
<div className="fileStorageSettingsCard">
|
||||
<div>
|
||||
<strong>SSF / CAEP 实时会话撤销</strong>
|
||||
<span>只需连接认证中心。Gateway 后端自动生成并托管 Push Bearer,复用现有 RFC 7662 机器 Client,不需要重启。</span>
|
||||
</div>
|
||||
<Badge variant={connection?.lifecycleStatus === 'enabled' ? 'success' : connection ? 'secondary' : 'outline'}>
|
||||
{securityEventLifecycleLabel(connection?.lifecycleStatus)}
|
||||
</Badge>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void props.onRefresh()} disabled={props.loading}>
|
||||
<RefreshCw size={14} />刷新
|
||||
</Button>
|
||||
</div>
|
||||
{!connection && (
|
||||
<Card>
|
||||
<CardContent className="pageStack">
|
||||
<div>
|
||||
<strong>连接认证中心</strong>
|
||||
<p className="mutedText">请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。</p>
|
||||
</div>
|
||||
{missing.length > 0 && <div className="formMessage">{missing.join(';')}</div>}
|
||||
<div className="fileStorageMeta">
|
||||
<span>复用机器 Client: {prerequisites?.managementClientId || '未配置'}</span>
|
||||
</div>
|
||||
<Label>
|
||||
SSF Transmitter Issuer
|
||||
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
|
||||
<small>页面只提交公开 Issuer;Endpoint、Scope、Push 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',
|
||||
|
||||
34
deploy/kubernetes/security-events-secret-rbac.yaml
Normal file
34
deploy/kubernetes/security-events-secret-rbac.yaml
Normal 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
|
||||
@ -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}
|
||||
|
||||
@ -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 Stream,Audience 为 `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 返回可审计的 503,API 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 Closed,API Key 继续工作。
|
||||
|
||||
## 验收证据
|
||||
Verification 成功后仅在首次连接和主动轮换时自动启用 Stream。认证中心管理员之后手工暂停或禁用 Stream,Gateway 的“重新验证”只检查链路,不会擅自恢复远端状态。
|
||||
|
||||
真实链路验收只能使用明确标记为测试用途的 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`。
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user