feat(ssf): 实现安全事件一键连接与凭据托管

新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。

增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
This commit is contained in:
2026-07-15 17:25:00 +08:00
parent f30aaeb2d4
commit 9efeb16fd1
31 changed files with 2738 additions and 227 deletions
+40 -94
View File
@@ -8,8 +8,6 @@ import (
"os"
"strconv"
"strings"
"github.com/google/uuid"
)
const (
@@ -38,16 +36,13 @@ type Config struct {
OIDCIntrospectionEnabled bool
OIDCIntrospectionClientID string
OIDCIntrospectionClientSecret string
OIDCSecurityEventsEnabled bool
OIDCSecurityEventsTransmitterIssuer string
OIDCSecurityEventsReceiverAudience string
OIDCSecurityEventsBearerSecret string
OIDCSecurityEventsBearerSecretNext string
OIDCSecurityEventsPublicEndpoint string
OIDCSecurityEventsStreamID string
OIDCSecurityEventsManagementTokenURL string
OIDCSecurityEventsManagementClientID string
OIDCSecurityEventsManagementClientSecret string
OIDCSecurityEventsSecretStore string
OIDCSecurityEventsSecretDir string
OIDCSecurityEventsKubernetesNamespace string
OIDCSecurityEventsKubernetesSecretName string
OIDCSecurityEventsKubernetesAPIServer string
OIDCSecurityEventsKubernetesTokenFile string
OIDCSecurityEventsKubernetesCAFile string
OIDCSecurityEventsHeartbeatIntervalSeconds int
OIDCSecurityEventsStaleAfterSeconds int
OIDCSecurityEventsClockSkewSeconds int
@@ -80,6 +75,9 @@ type Config struct {
func Load() Config {
globalProxy := LoadGlobalHTTPProxyStatus()
appEnv := env("APP_ENV", "development")
oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/")
introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "")
introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "")
return Config{
AppEnv: appEnv,
HTTPAddr: env("HTTP_ADDR", ":8088"),
@@ -94,7 +92,7 @@ func Load() Config {
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
OIDCIssuer: oidcIssuer,
OIDCAudience: env("OIDC_AUDIENCE", ""),
OIDCTenantID: env("OIDC_TENANT_ID", ""),
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
@@ -102,18 +100,15 @@ func Load() Config {
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true",
OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"),
OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""),
OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""),
OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""),
OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""),
OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""),
OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""),
OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""),
OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""),
OIDCIntrospectionClientID: introspectionClientID,
OIDCIntrospectionClientSecret: introspectionClientSecret,
OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"),
OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"),
OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")),
OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"),
OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"),
OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
@@ -149,6 +144,26 @@ func Load() Config {
}
func (c Config) Validate() error {
switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) {
case "":
case "file":
if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" {
return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore")
}
case "kubernetes":
if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" {
return errors.New("Kubernetes security event SecretStore requires namespace and Secret name")
}
default:
return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes")
}
if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 {
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
}
}
if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" {
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
}
@@ -185,59 +200,9 @@ func (c Config) Validate() error {
}
}
}
if c.OIDCSecurityEventsEnabled {
if !c.OIDCEnabled {
return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled")
}
if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) ||
!validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) ||
!validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) {
return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs")
}
if _, err := uuid.Parse(c.OIDCTenantID); err != nil {
return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled")
}
applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:")
if applicationID == c.OIDCSecurityEventsReceiverAudience {
return errors.New("OIDC security event receiver audience and stream ID are required")
}
if _, err := uuid.Parse(applicationID); err != nil {
return errors.New("OIDC security event receiver audience must contain an application UUID")
}
if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil {
return errors.New("OIDC security event stream ID must be a UUID")
}
if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) ||
c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) {
return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters")
}
if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" {
return errors.New("OIDC security event management machine client credentials are required")
}
if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" {
return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled")
}
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
}
}
return nil
}
func validBearerSecret(value string) bool {
if len(value) < 16 || len(value) > 4096 {
return false
}
for _, character := range []byte(value) {
if character < 0x21 || character > 0x7e {
return false
}
}
return true
}
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
@@ -260,25 +225,6 @@ func validPublicRedirectURL(raw string) bool {
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validServiceURL(raw, appEnv string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return false
}
if parsed.Scheme == "https" {
return true
}
return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validSecurityEventReceiverURL(raw, appEnv string) bool {
if !validServiceURL(raw, appEnv) {
return false
}
parsed, err := url.Parse(strings.TrimSpace(raw))
return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == ""
}
func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
+37 -24
View File
@@ -137,33 +137,46 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
}
}
func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) {
func TestSecurityEventsUseDurableConnectionInsteadOfAnEnableFlag(t *testing.T) {
t.Setenv("OIDC_SECURITY_EVENTS_ENABLED", "true")
t.Setenv("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", "https://untrusted.example/ssf")
t.Setenv("OIDC_SECURITY_EVENTS_BEARER_SECRET", "must-not-be-loaded")
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_STORE", "file")
t.Setenv("OIDC_SECURITY_EVENTS_SECRET_DIR", ".test-secrets/ssf")
cfg := Load()
if cfg.OIDCSecurityEventsSecretStore != "file" || cfg.OIDCSecurityEventsSecretDir != ".test-secrets/ssf" {
t.Fatalf("secret directory=%q", cfg.OIDCSecurityEventsSecretDir)
}
if err := (Config{}).Validate(); err != nil {
t.Fatalf("disabled security events changed existing config: %v", err)
t.Fatalf("an absent security event connection changed existing config: %v", err)
}
cfg := Config{AppEnv: "test", OIDCEnabled: true, OIDCSecurityEventsEnabled: true}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "issuer") {
t.Fatalf("partial security event config error = %v", err)
}
func TestValidateKubernetesSecurityEventSecretStore(t *testing.T) {
config := Config{OIDCSecurityEventsSecretStore: "kubernetes", OIDCSecurityEventsKubernetesSecretName: "easyai-gateway-security-events"}
if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") {
t.Fatalf("Validate() error=%v, want missing namespace", err)
}
cfg.OIDCSecurityEventsTransmitterIssuer = "http://localhost:8080/ssf"
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/api/v1/security-events/ssf"
cfg.OIDCSecurityEventsManagementTokenURL = "http://localhost:8080/oauth/token"
cfg.OIDCTenantID = "00000000-0000-4000-8000-000000000003"
cfg.OIDCSecurityEventsReceiverAudience = "urn:easyai:ssf:receiver:00000000-0000-4000-8000-000000000002"
cfg.OIDCSecurityEventsStreamID = "00000000-0000-4000-8000-000000000001"
cfg.OIDCSecurityEventsBearerSecret = "receiver-secret-at-least-16"
cfg.OIDCSecurityEventsManagementClientID = "gateway-ssf"
cfg.OIDCSecurityEventsManagementClientSecret = "management-secret"
cfg.OIDCIntrospectionClientID = "gateway-introspection"
cfg.OIDCIntrospectionClientSecret = "introspection-secret"
cfg.OIDCSecurityEventsHeartbeatIntervalSeconds = 60
cfg.OIDCSecurityEventsStaleAfterSeconds = 180
cfg.OIDCSecurityEventsClockSkewSeconds = 60
if err := cfg.Validate(); err != nil {
t.Fatalf("valid security event config: %v", err)
config.OIDCSecurityEventsKubernetesNamespace = "easyai"
if err := config.Validate(); err != nil {
t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err)
}
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/wrong-path"
if err := cfg.Validate(); err == nil {
t.Fatal("non-canonical security event receiver path was accepted")
}
func TestValidateSecurityEventTiming(t *testing.T) {
config := Config{OIDCSecurityEventsHeartbeatIntervalSeconds: 60, OIDCSecurityEventsStaleAfterSeconds: 60, OIDCSecurityEventsClockSkewSeconds: 60}
if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") {
t.Fatalf("Validate() error=%v, want invalid stale threshold", err)
}
}
func TestLoadSecurityEventsReusesOnlyTheRFC7662MachineClient(t *testing.T) {
t.Setenv("OIDC_INTROSPECTION_CLIENT_ID", "gateway-service")
t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "service-secret")
cfg := Load()
if cfg.OIDCIntrospectionClientID != "gateway-service" || cfg.OIDCIntrospectionClientSecret != "service-secret" {
t.Fatal("RFC 7662 machine credentials were not preserved")
}
}