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