From ffb85b73af3a860e2632327ea2fdcf481f2dd855 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 09:13:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssf):=20=E6=89=98=E7=AE=A1=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E5=87=AD=E6=8D=AE=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway 连接表单接收认证中心一次性交付的 machine Client 凭据,后端立即写入 SecretStore,数据库和公开响应只保留引用及公开 Client ID。SSF 管理 Token、Verification 和 RFC 7662 内省统一从动态凭据读取,支持现有连接无重启修复及进程重启恢复,环境变量仅保留为旧部署回退。\n\n验证:go test ./apps/api/...;pnpm nx test web;真实重启、OIDC 登录与 session-revoked 1.29 秒失效验收。 --- .env.example | 2 + apps/api/internal/auth/oidc.go | 44 ++++-- apps/api/internal/auth/oidc_test.go | 6 +- .../security_event_connection_handlers.go | 17 ++- apps/api/internal/httpapi/server.go | 15 +- .../securityevents/connection_manager.go | 133 ++++++++++++++++-- .../securityevents/connection_manager_test.go | 42 +++++- .../store/security_event_connections.go | 64 ++++++--- ...6_security_event_management_credential.sql | 9 ++ apps/web/src/App.tsx | 4 +- apps/web/src/api.test.ts | 13 +- apps/web/src/api.ts | 14 +- .../src/pages/admin/SystemSettingsPanel.tsx | 42 ++++-- docs/security/ssf-session-revocation.md | 11 +- packages/contracts/src/index.ts | 1 + 15 files changed, 327 insertions(+), 90 deletions(-) create mode 100644 apps/api/migrations/0066_security_event_management_credential.sql diff --git a/.env.example b/.env.example index 5fe82c3..b2591c7 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,8 @@ OIDC_REQUIRED_SCOPES=gateway.access OIDC_JWKS_CACHE_TTL_SECONDS=300 OIDC_ACCEPT_LEGACY_HS256=true OIDC_INTROSPECTION_ENABLED=false +# Legacy/static fallback only. New SSF connections receive the machine +# credential once in the web flow and persist it in the configured SecretStore. OIDC_INTROSPECTION_CLIENT_ID= OIDC_INTROSPECTION_CLIENT_SECRET= # SSF/CAEP is activated by the durable connection created in System Settings; diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 39690d4..9d3b02f 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -25,19 +25,20 @@ const maxOIDCResponseBytes = 1 << 20 const defaultOIDCHTTPTimeout = 10 * time.Second type OIDCConfig struct { - Issuer string - Audience string - TenantID string - RolePrefix string - RequiredScopes []string - JWKSCacheTTL time.Duration - IntrospectionEnabled bool - IntrospectionClientID string - IntrospectionClientSecret string - HTTPClient *http.Client - SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) - IntrospectionObserver func(string) - JWKSRefreshFailureObserver func() + Issuer string + Audience string + TenantID string + RolePrefix string + RequiredScopes []string + JWKSCacheTTL time.Duration + IntrospectionEnabled bool + IntrospectionClientID string + IntrospectionClientSecret string + IntrospectionCredentialProvider func(context.Context) (string, []byte, error) + HTTPClient *http.Client + SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) + IntrospectionObserver func(string) + JWKSRefreshFailureObserver func() } type OIDCSecurityEventIdentity struct { @@ -92,7 +93,8 @@ 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 && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { + if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil && + (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { return nil, errors.New("introspection client credentials are required when introspection is enabled") } if config.JWKSCacheTTL <= 0 { @@ -295,7 +297,19 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Accept", "application/json") - request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret) + clientID := v.config.IntrospectionClientID + secret := []byte(v.config.IntrospectionClientSecret) + if v.config.IntrospectionCredentialProvider != nil { + clientID, secret, err = v.config.IntrospectionCredentialProvider(ctx) + if err != nil { + return false, err + } + } + defer clear(secret) + if strings.TrimSpace(clientID) == "" || len(secret) < 16 { + return false, errors.New("OIDC introspection credential is unavailable") + } + request.SetBasicAuth(clientID, string(secret)) response, err := v.client.Do(request) if err != nil { return false, err diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index 36ed7fd..da6952d 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -138,8 +138,10 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing 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-api", - IntrospectionClientSecret: "introspection-secret", + IntrospectionEnabled: true, + IntrospectionCredentialProvider: func(context.Context) (string, []byte, error) { + return "gateway-api", []byte("introspection-secret"), nil + }, }) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go index ce0614c..4e75115 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -16,7 +16,9 @@ import ( ) type securityEventConnectionRequest struct { - TransmitterIssuer string `json:"transmitter_issuer"` + TransmitterIssuer string `json:"transmitter_issuer"` + ManagementClientID string `json:"management_client_id"` + ManagementClientSecret string `json:"management_client_secret"` } type securityEventConnectionResponse struct { @@ -65,14 +67,22 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque return } request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/") - requestHash := securityEventOperationHash("connect", request.TransmitterIssuer) + request.ManagementClientID = strings.TrimSpace(request.ManagementClientID) + if (request.ManagementClientID == "") != (request.ManagementClientSecret == "") || len(request.ManagementClientID) > 200 || len(request.ManagementClientSecret) > 512 { + writeError(w, http.StatusBadRequest, "machine Client ID and Secret must be provided together", "invalid_machine_credential") + return + } + secret := []byte(request.ManagementClientSecret) + defer clear(secret) + secretDigest := sha256.Sum256(secret) + requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:])) 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) + connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey) if err != nil { s.writeSecurityEventConnectionError(w, r, "connect", traceID, err) return @@ -128,6 +138,7 @@ func (s *Server) securityEventPrerequisites() map[string]any { "introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "", "publicBaseUrlConfigured": s.cfg.PublicBaseURL != "", "managementClientId": s.cfg.OIDCIntrospectionClientID, + "credentialInputSupported": true, } } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 1b03891..26e37df 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -94,13 +94,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes, - JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, - IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, - IntrospectionClientID: cfg.OIDCIntrospectionClientID, - IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, - SecurityEventEvaluator: evaluator, - IntrospectionObserver: securityEventMetrics.ObserveIntrospection, - JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") }, + JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, + IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, + IntrospectionClientID: cfg.OIDCIntrospectionClientID, + IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, + IntrospectionCredentialProvider: server.securityEventManager.IntrospectionCredential, + SecurityEventEvaluator: evaluator, + IntrospectionObserver: securityEventMetrics.ObserveIntrospection, + JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") }, }) if err != nil { panic("invalid OIDC configuration: " + err.Error()) diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go index 451a73c..6d572d2 100644 --- a/apps/api/internal/securityevents/connection_manager.go +++ b/apps/api/internal/securityevents/connection_manager.go @@ -34,6 +34,7 @@ type ConnectionRepository interface { 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) + SetSecurityEventManagementCredential(context.Context, string, string, string) (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) @@ -181,51 +182,114 @@ func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) { return m.view(connection), nil } -func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey string) (ConnectionView, error) { +func (m *ConnectionManager) IntrospectionCredential(ctx context.Context) (string, []byte, error) { + connection, err := m.repository.SecurityEventConnection(ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" { + return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil + } + return "", nil, errors.New("managed machine credential is unavailable") + } + if err != nil { + return "", nil, err + } + return m.managementCredential(ctx, connection) +} + +func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClientID string, managementSecret []byte, idempotencyKey string) (ConnectionView, error) { m.operation.Lock() defer m.operation.Unlock() issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + managementClientID = strings.TrimSpace(managementClientID) if idempotencyKey == "" { return ConnectionView{}, errors.New("idempotency key is required") } - if err := m.validatePrerequisites(issuer); err != nil { + existing, err := m.repository.SecurityEventConnection(ctx) + if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return ConnectionView{}, err + } + credentialsProvided := managementClientID != "" || len(managementSecret) > 0 + if (managementClientID == "") != (len(managementSecret) == 0) { + return ConnectionView{}, errors.New("machine client id and secret must be provided together") + } + if !credentialsProvided { + if err == nil { + managementClientID, managementSecret, _ = m.managementCredential(ctx, existing) + } else { + managementClientID = strings.TrimSpace(m.config.ManagementClientID) + managementSecret = []byte(m.config.ManagementClientSecret) + } + } + defer clear(managementSecret) + if err := m.validatePrerequisites(issuer, managementClientID, managementSecret, errors.Is(err, store.ErrSecurityEventConnectionNotFound)); err != nil { return ConnectionView{}, err } - existing, err := m.repository.SecurityEventConnection(ctx) if err == nil { if existing.TransmitterIssuer != issuer { return ConnectionView{}, store.ErrSecurityEventConnectionConflict } + if credentialsProvided { + existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret) + if err != nil { + return ConnectionView{}, err + } + } if existing.StreamID != nil && !resumableConnectionLifecycle(existing.LifecycleStatus) { + if existing.Audience != nil { + if err := m.activate(ctx, existing); err != nil { + return ConnectionView{}, err + } + } 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 + managementReference := "ssf-management-" + connectionID + if err := m.secrets.Put(ctx, managementReference, managementSecret); err != nil { + return ConnectionView{}, err + } secret, err := randomPushBearer() if err != nil { + _ = m.secrets.Delete(ctx, managementReference) return ConnectionView{}, err } defer clear(secret) if err := m.secrets.Put(ctx, reference, secret); err != nil { + _ = m.secrets.Delete(ctx, managementReference) 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, + CredentialRef: reference, ManagementClientID: managementClientID, + ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey, }) if err != nil { _ = m.secrets.Delete(ctx, reference) + _ = m.secrets.Delete(ctx, managementReference) return ConnectionView{}, err } return m.resumeConnecting(ctx, connection) } +func (m *ConnectionManager) persistManagementCredential(ctx context.Context, connection store.SecurityEventConnection, clientID string, secret []byte) (store.SecurityEventConnection, error) { + reference := "ssf-management-" + connection.ConnectionID + "-" + uuid.NewString() + if err := m.secrets.Put(ctx, reference, secret); err != nil { + return store.SecurityEventConnection{}, err + } + updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference) + if err != nil { + _ = m.secrets.Delete(ctx, reference) + return store.SecurityEventConnection{}, err + } + if connection.ManagementCredentialRef != nil { + _ = m.secrets.Delete(ctx, *connection.ManagementCredentialRef) + } + return updated, nil +} + func resumableConnectionLifecycle(status string) bool { switch status { case "connecting", "verifying", "degraded", "error": @@ -484,6 +548,11 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur return err } defer clear(current) + managementClientID, managementSecret, err := m.managementCredential(ctx, connection) + if err != nil { + return err + } + defer clear(managementSecret) var next []byte if connection.NextCredentialRef != nil { next, err = m.secrets.Get(ctx, *connection.NextCredentialRef) @@ -517,7 +586,7 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur 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, + ManagementClientID: managementClientID, ManagementSecret: string(managementSecret), HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter, HTTPClient: safeClient, }) @@ -656,13 +725,16 @@ func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConne } m.stopRuntime() _ = m.secrets.Delete(m.ctx, connection.CredentialRef) + if connection.ManagementCredentialRef != nil { + _ = m.secrets.Delete(m.ctx, *connection.ManagementCredentialRef) + } 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 == "" { +func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error { + if !m.config.OIDCEnabled || strings.TrimSpace(managementClientID) == "" || len(managementSecret) < 16 { return errors.New("OIDC and RFC 7662 machine client must be configured") } if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil { @@ -671,6 +743,9 @@ func (m *ConnectionManager) validatePrerequisites(issuer string) error { if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil { return err } + if !requirePublicBaseURL { + return nil + } 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") @@ -710,12 +785,21 @@ func (m *ConnectionManager) discover(ctx context.Context, issuer string) (transm } func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Client, scopes string) (string, error) { + connection, err := m.repository.SecurityEventConnection(ctx) + if err != nil { + return "", err + } + managementClientID, managementSecret, err := m.managementCredential(ctx, connection) + if err != nil { + return "", err + } + defer clear(managementSecret) 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.SetBasicAuth(managementClientID, string(managementSecret)) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response, err := client.Do(request) if err != nil { @@ -736,6 +820,24 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl return payload.AccessToken, nil } +func (m *ConnectionManager) managementCredential(ctx context.Context, connection store.SecurityEventConnection) (string, []byte, error) { + if connection.ManagementCredentialRef != nil && connection.ManagementClientID != "" { + secret, err := m.secrets.Get(ctx, *connection.ManagementCredentialRef) + if err != nil { + return "", nil, err + } + if len(secret) < 16 { + clear(secret) + return "", nil, errors.New("managed machine credential is invalid") + } + return connection.ManagementClientID, secret, nil + } + if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" { + return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil + } + return "", nil, errors.New("managed machine credential is unavailable") +} + 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 { @@ -789,11 +891,18 @@ func (m *ConnectionManager) view(connection store.SecurityEventConnection) Conne 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, + ManagementClientID: managementClientIDForView(connection.ManagementClientID, m.config.ManagementClientID), LastVerificationAt: connection.LastVerificationAt, LastErrorCategory: connection.LastErrorCategory, Version: connection.Version, } } +func managementClientIDForView(managed, fallback string) string { + if strings.TrimSpace(managed) != "" { + return managed + } + return fallback +} + 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 || diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go index 2647b80..6b1fbef 100644 --- a/apps/api/internal/securityevents/connection_manager_test.go +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -66,11 +66,21 @@ func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Con } now := time.Now().UTC() value := store.SecurityEventConnection{ConnectionID: input.ConnectionID, TransmitterIssuer: input.TransmitterIssuer, - EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, LifecycleStatus: "connecting", Version: 1, + EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, ManagementClientID: input.ManagementClientID, + ManagementCredentialRef: &input.ManagementCredentialRef, LifecycleStatus: "connecting", Version: 1, IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"} r.connection = &value return value, nil } +func (r *memoryConnectionRepository) SetSecurityEventManagementCredential(_ context.Context, id, clientID, reference 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.ManagementClientID, r.connection.ManagementCredentialRef = clientID, &reference + return *r.connection, nil +} func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) { r.mutex.Lock() defer r.mutex.Unlock() @@ -183,6 +193,20 @@ func TestConnectionLifecycleCanResumeOnlyIncompleteOrFailedConnections(t *testin } } +func TestExistingConnectionCredentialRepairDoesNotRequirePublicBaseURL(t *testing.T) { + manager := &ConnectionManager{config: ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCTenantID: uuid.NewString(), + }} + secret := []byte("machine-secret-for-existing-connection") + defer clear(secret) + if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, false); err != nil { + t.Fatalf("existing connection credential repair unexpectedly required a public base URL: %v", err) + } + if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, true); err == nil { + t.Fatal("a new connection must still require AI_GATEWAY_PUBLIC_BASE_URL") + } +} + 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", @@ -296,7 +320,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi }) case "/oidc/token": clientID, secret, ok := r.BasicAuth() - if !ok || clientID != "gateway-machine" || secret != "machine-secret" { + if !ok || clientID != "gateway-machine" || secret != "machine-secret-for-test" { t.Fatal("RFC 7662 machine client was not reused") } _ = r.ParseForm() @@ -339,7 +363,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi 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", + ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret-for-test", PublicBaseURL: transmitter.URL, HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: transmitter.Client(), }, &Metrics{}) if err != nil { @@ -349,7 +373,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi 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") + view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "", nil, "connect-once") if err != nil { t.Fatal(err) } @@ -357,12 +381,18 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi 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-") { + if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret-for-test") || strings.Contains(string(encoded), "ssf-push-") { t.Fatalf("secret metadata escaped in response: %s", encoded) } - if len(secrets.values) != 1 { + if len(secrets.values) != 2 { t.Fatalf("secret count=%d", len(secrets.values)) } + restarted := &ConnectionManager{repository: repository, secrets: secrets, config: ConnectionManagerConfig{}} + restartedClientID, restartedSecret, err := restarted.IntrospectionCredential(ctx) + if err != nil || restartedClientID != "gateway-machine" || string(restartedSecret) != "machine-secret-for-test" { + t.Fatalf("persisted management credential was not recoverable after restart: client=%q secret_present=%v err=%v", restartedClientID, len(restartedSecret) > 0, err) + } + clear(restartedSecret) if !managementScopeRequested.Load() { t.Fatal("management scopes were not requested automatically") } diff --git a/apps/api/internal/store/security_event_connections.go b/apps/api/internal/store/security_event_connections.go index fb0e198..e53851e 100644 --- a/apps/api/internal/store/security_event_connections.go +++ b/apps/api/internal/store/security_event_connections.go @@ -16,25 +16,27 @@ var ( ) 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"` + 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:"-"` + ManagementClientID string `json:"managementClientId"` + ManagementCredentialRef *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 + ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, ManagementClientID, ManagementCredentialRef, IdempotencyKey string } type SecurityEventConnectionIdempotency struct { @@ -65,13 +67,14 @@ func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateS 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) + connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key +) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7) 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, + next_credential_ref,management_client_id,management_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`, + input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, + input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey, ).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID, - &value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version, + &value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, &value.LifecycleStatus, &value.Version, &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt) if err != nil { if isUniqueViolation(err) { @@ -90,7 +93,8 @@ func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConne 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.stream_id::text,connection.credential_ref,connection.next_credential_ref, + COALESCE(connection.management_client_id,''),connection.management_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 @@ -98,7 +102,8 @@ 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.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, + &value.LifecycleStatus, &value.Version, &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt, &value.LastVerificationAt, &value.HealthMode, ) @@ -121,6 +126,19 @@ WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID, return s.SecurityEventConnection(ctx) } +func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string) (SecurityEventConnection, error) { + tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections +SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,updated_at=now() +WHERE connection_id=$1::uuid`, connectionID, clientID, reference) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound + } + 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) diff --git a/apps/api/migrations/0066_security_event_management_credential.sql b/apps/api/migrations/0066_security_event_management_credential.sql new file mode 100644 index 0000000..d6df346 --- /dev/null +++ b/apps/api/migrations/0066_security_event_management_credential.sql @@ -0,0 +1,9 @@ +ALTER TABLE gateway_security_event_connections + ADD COLUMN IF NOT EXISTS management_client_id text, + ADD COLUMN IF NOT EXISTS management_credential_ref text; + +ALTER TABLE gateway_security_event_connections + ADD CONSTRAINT gateway_security_event_management_credential_pair CHECK ( + (management_client_id IS NULL AND management_credential_ref IS NULL) OR + (management_client_id IS NOT NULL AND management_credential_ref IS NOT NULL) + ); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c74a7b6..7a4ecff 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1029,12 +1029,12 @@ export function App() { } } - async function connectSecurityEvents(transmitterIssuer: string) { + async function connectSecurityEvents(transmitterIssuer: string, managementClientId = '', managementClientSecret = '') { setCoreState('loading'); setCoreMessage(''); try { const version = securityEventConnection?.connection?.version; - const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version); + const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, managementClientId, managementClientSecret, version); setSecurityEventConnection(connection); setCoreState('ready'); setCoreMessage('认证中心安全事件连接正在验证。'); diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index a9c1ea6..d0cbc8e 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -40,7 +40,7 @@ describe('security event connection transport', () => { vi.unstubAllGlobals(); }); - it('sends only the public transmitter issuer and concurrency headers', async () => { + it('sends the one-time machine credential only in the connection request', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), { status: 202, headers: { 'Content-Type': 'application/json' }, @@ -48,12 +48,15 @@ describe('security event connection transport', () => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); - await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf'); + await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret'); 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(JSON.parse(String(init.body))).toEqual({ + transmitter_issuer: 'https://auth.example/ssf', + management_client_id: 'gateway-machine', + management_client_secret: 'one-time-machine-secret', + }); expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-'); expect(new Headers(init.headers).has('If-Match')).toBe(false); }); @@ -66,7 +69,7 @@ describe('security event connection transport', () => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); - await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 7); + await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret', 7); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index d71a66f..9232fe4 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1007,11 +1007,21 @@ export async function getSecurityEventConnection(token: string): Promise(securityEventConnectionPath, { token }); } -export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise { +export async function connectSecurityEventTransmitter( + token: string, + transmitterIssuer: string, + managementClientId: string, + managementClientSecret: string, + version?: number, +): Promise { const headers: Record = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` }; if (version !== undefined) headers['If-Match'] = `W/"${version}"`; return request(securityEventConnectionPath, { - body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token, + body: { + transmitter_issuer: transmitterIssuer, + management_client_id: managementClientId, + management_client_secret: managementClientSecret, + }, method: 'PUT', token, headers, }); } diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index dbc947f..94dccb6 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -64,7 +64,7 @@ export function SystemSettingsPanel(props: { onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise; onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise; onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise; - onConnectSecurityEvents: (transmitterIssuer: string) => Promise; + onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise; onDisconnectSecurityEvents: () => Promise; onRefreshSecurityEvents: () => Promise; onRotateSecurityEventsCredential: () => Promise; @@ -411,7 +411,7 @@ function SecurityEventConnectionPanel(props: { issuer: string; loading: boolean; onIssuerChange(value: string): void; - onConnect(issuer: string): Promise; + onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise; onDisconnect(): void; onRefresh(): Promise; onRotate(): Promise; @@ -419,9 +419,13 @@ function SecurityEventConnectionPanel(props: { }) { const connection = props.connection?.connection; const prerequisites = props.connection?.prerequisites; + const [managementClientId, setManagementClientId] = useState(''); + const [managementClientSecret, setManagementClientSecret] = useState(''); + useEffect(() => { + if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId); + }, [managementClientId, prerequisites?.managementClientId]); 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 ( @@ -446,15 +450,22 @@ function SecurityEventConnectionPanel(props: {

请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。

{missing.length > 0 &&
{missing.join(';')}
} -
- 复用机器 Client: {prerequisites?.managementClientId || '未配置'} -
- @@ -475,6 +486,21 @@ function SecurityEventConnectionPanel(props: { {props.connection?.auditId && 最近操作 Audit ID: {props.connection.auditId}} {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}} + {(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') &&
+
认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。
+ + + +
}
{connection.lifecycleStatus === 'error' && ( diff --git a/docs/security/ssf-session-revocation.md b/docs/security/ssf-session-revocation.md index aee2578..7133aa3 100644 --- a/docs/security/ssf-session-revocation.md +++ b/docs/security/ssf-session-revocation.md @@ -7,17 +7,18 @@ Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Securit 用户只执行两项操作: 1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client,点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope;不会创建第二个 Client。 -2. 在 Gateway“系统设置 → 认证中心安全事件”中填写认证中心公开 SSF Issuer,点击“连接认证中心”。 +2. Receiver 就绪后点击“生成一次性连接凭据”,立即复制 machine Client ID/Secret。 +3. 在 Gateway“系统设置 → 认证中心安全事件”中填写 SSF Issuer 和这组一次性凭据,点击“连接认证中心”。 -Gateway 后端随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器看不到 Push Bearer、OAuth Secret 或 Secret 引用,管理员不编辑 Secret 文件,也不需要重启 Gateway。 +Gateway 后端先把 machine Secret 写入 SecretStore,随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器只在两端连接表单的短暂操作期间接触 machine Secret;Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。 -前置配置只有既有 OIDC/RFC 7662 配置和 Gateway 公共地址: +环境前置配置只保留 OIDC 公共参数和 Gateway 公共地址: - `OIDC_ISSUER`、`OIDC_TENANT_ID`; -- `OIDC_INTROSPECTION_ENABLED=true`; -- `OIDC_INTROSPECTION_CLIENT_ID/SECRET`,与认证中心准备 Receiver 时选择的机器 Client 相同; - `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。 +`OIDC_INTROSPECTION_CLIENT_ID/SECRET` 仅作为旧部署兼容回退,不再是新连接必填项。新连接把 machine 凭据动态托管到 SecretStore,OIDC fallback、SSF 管理 Token 和 Verification 共用这一份凭据,重启后继续生效。 + 第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。 ## SecretStore diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8b81266..00faee8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -976,6 +976,7 @@ export interface SecurityEventConnectionPrerequisites { introspectionClientConfigured: boolean; publicBaseUrlConfigured: boolean; managementClientId: string; + credentialInputSupported: boolean; } export interface SecurityEventConnectionResponse {