feat(ssf): 托管机器凭据并支持动态恢复
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 秒失效验收。
This commit is contained in:
@@ -33,6 +33,8 @@ OIDC_REQUIRED_SCOPES=gateway.access
|
|||||||
OIDC_JWKS_CACHE_TTL_SECONDS=300
|
OIDC_JWKS_CACHE_TTL_SECONDS=300
|
||||||
OIDC_ACCEPT_LEGACY_HS256=true
|
OIDC_ACCEPT_LEGACY_HS256=true
|
||||||
OIDC_INTROSPECTION_ENABLED=false
|
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_ID=
|
||||||
OIDC_INTROSPECTION_CLIENT_SECRET=
|
OIDC_INTROSPECTION_CLIENT_SECRET=
|
||||||
# SSF/CAEP is activated by the durable connection created in System Settings;
|
# SSF/CAEP is activated by the durable connection created in System Settings;
|
||||||
|
|||||||
@@ -25,19 +25,20 @@ const maxOIDCResponseBytes = 1 << 20
|
|||||||
const defaultOIDCHTTPTimeout = 10 * time.Second
|
const defaultOIDCHTTPTimeout = 10 * time.Second
|
||||||
|
|
||||||
type OIDCConfig struct {
|
type OIDCConfig struct {
|
||||||
Issuer string
|
Issuer string
|
||||||
Audience string
|
Audience string
|
||||||
TenantID string
|
TenantID string
|
||||||
RolePrefix string
|
RolePrefix string
|
||||||
RequiredScopes []string
|
RequiredScopes []string
|
||||||
JWKSCacheTTL time.Duration
|
JWKSCacheTTL time.Duration
|
||||||
IntrospectionEnabled bool
|
IntrospectionEnabled bool
|
||||||
IntrospectionClientID string
|
IntrospectionClientID string
|
||||||
IntrospectionClientSecret string
|
IntrospectionClientSecret string
|
||||||
HTTPClient *http.Client
|
IntrospectionCredentialProvider func(context.Context) (string, []byte, error)
|
||||||
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
|
HTTPClient *http.Client
|
||||||
IntrospectionObserver func(string)
|
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
|
||||||
JWKSRefreshFailureObserver func()
|
IntrospectionObserver func(string)
|
||||||
|
JWKSRefreshFailureObserver func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDCSecurityEventIdentity struct {
|
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 == "" {
|
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")
|
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")
|
return nil, errors.New("introspection client credentials are required when introspection is enabled")
|
||||||
}
|
}
|
||||||
if config.JWKSCacheTTL <= 0 {
|
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("Content-Type", "application/x-www-form-urlencoded")
|
||||||
request.Header.Set("Accept", "application/json")
|
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)
|
response, err := v.client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -138,8 +138,10 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
|
|||||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||||
IntrospectionEnabled: true, IntrospectionClientID: "gateway-api",
|
IntrospectionEnabled: true,
|
||||||
IntrospectionClientSecret: "introspection-secret",
|
IntrospectionCredentialProvider: func(context.Context) (string, []byte, error) {
|
||||||
|
return "gateway-api", []byte("introspection-secret"), nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type securityEventConnectionRequest struct {
|
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 {
|
type securityEventConnectionResponse struct {
|
||||||
@@ -65,14 +67,22 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/")
|
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) {
|
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) {
|
if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.writeSecurityEventConnectionError(w, r, "connect", traceID, err)
|
s.writeSecurityEventConnectionError(w, r, "connect", traceID, err)
|
||||||
return
|
return
|
||||||
@@ -128,6 +138,7 @@ func (s *Server) securityEventPrerequisites() map[string]any {
|
|||||||
"introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "",
|
"introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "",
|
||||||
"publicBaseUrlConfigured": s.cfg.PublicBaseURL != "",
|
"publicBaseUrlConfigured": s.cfg.PublicBaseURL != "",
|
||||||
"managementClientId": s.cfg.OIDCIntrospectionClientID,
|
"managementClientId": s.cfg.OIDCIntrospectionClientID,
|
||||||
|
"credentialInputSupported": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,13 +94,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
||||||
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
|
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
|
||||||
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
|
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
|
||||||
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
|
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
|
||||||
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
|
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
|
||||||
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
|
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
|
||||||
IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret,
|
IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret,
|
||||||
SecurityEventEvaluator: evaluator,
|
IntrospectionCredentialProvider: server.securityEventManager.IntrospectionCredential,
|
||||||
IntrospectionObserver: securityEventMetrics.ObserveIntrospection,
|
SecurityEventEvaluator: evaluator,
|
||||||
JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") },
|
IntrospectionObserver: securityEventMetrics.ObserveIntrospection,
|
||||||
|
JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") },
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("invalid OIDC configuration: " + err.Error())
|
panic("invalid OIDC configuration: " + err.Error())
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type ConnectionRepository interface {
|
|||||||
CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error)
|
CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error)
|
||||||
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
|
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
|
||||||
BindSecurityEventConnection(context.Context, string, string, string, string, int64) (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)
|
UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error)
|
||||||
SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error)
|
SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error)
|
||||||
PromoteSecurityEventCredential(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
|
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()
|
m.operation.Lock()
|
||||||
defer m.operation.Unlock()
|
defer m.operation.Unlock()
|
||||||
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
|
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
|
||||||
|
managementClientID = strings.TrimSpace(managementClientID)
|
||||||
if idempotencyKey == "" {
|
if idempotencyKey == "" {
|
||||||
return ConnectionView{}, errors.New("idempotency key is required")
|
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
|
return ConnectionView{}, err
|
||||||
}
|
}
|
||||||
existing, err := m.repository.SecurityEventConnection(ctx)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if existing.TransmitterIssuer != issuer {
|
if existing.TransmitterIssuer != issuer {
|
||||||
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
|
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.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.view(existing), nil
|
||||||
}
|
}
|
||||||
return m.resumeConnecting(ctx, existing)
|
return m.resumeConnecting(ctx, existing)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
||||||
return ConnectionView{}, err
|
|
||||||
}
|
|
||||||
connectionID := uuid.NewString()
|
connectionID := uuid.NewString()
|
||||||
reference := "ssf-push-" + connectionID
|
reference := "ssf-push-" + connectionID
|
||||||
|
managementReference := "ssf-management-" + connectionID
|
||||||
|
if err := m.secrets.Put(ctx, managementReference, managementSecret); err != nil {
|
||||||
|
return ConnectionView{}, err
|
||||||
|
}
|
||||||
secret, err := randomPushBearer()
|
secret, err := randomPushBearer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = m.secrets.Delete(ctx, managementReference)
|
||||||
return ConnectionView{}, err
|
return ConnectionView{}, err
|
||||||
}
|
}
|
||||||
defer clear(secret)
|
defer clear(secret)
|
||||||
if err := m.secrets.Put(ctx, reference, secret); err != nil {
|
if err := m.secrets.Put(ctx, reference, secret); err != nil {
|
||||||
|
_ = m.secrets.Delete(ctx, managementReference)
|
||||||
return ConnectionView{}, err
|
return ConnectionView{}, err
|
||||||
}
|
}
|
||||||
endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf"
|
endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf"
|
||||||
connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{
|
connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{
|
||||||
ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint,
|
ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint,
|
||||||
CredentialRef: reference, IdempotencyKey: idempotencyKey,
|
CredentialRef: reference, ManagementClientID: managementClientID,
|
||||||
|
ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = m.secrets.Delete(ctx, reference)
|
_ = m.secrets.Delete(ctx, reference)
|
||||||
|
_ = m.secrets.Delete(ctx, managementReference)
|
||||||
return ConnectionView{}, err
|
return ConnectionView{}, err
|
||||||
}
|
}
|
||||||
return m.resumeConnecting(ctx, connection)
|
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 {
|
func resumableConnectionLifecycle(status string) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case "connecting", "verifying", "degraded", "error":
|
case "connecting", "verifying", "degraded", "error":
|
||||||
@@ -484,6 +548,11 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer clear(current)
|
defer clear(current)
|
||||||
|
managementClientID, managementSecret, err := m.managementCredential(ctx, connection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer clear(managementSecret)
|
||||||
var next []byte
|
var next []byte
|
||||||
if connection.NextCredentialRef != nil {
|
if connection.NextCredentialRef != nil {
|
||||||
next, err = m.secrets.Get(ctx, *connection.NextCredentialRef)
|
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{
|
service, err := NewService(m.repository, ServiceConfig{
|
||||||
Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID,
|
Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID,
|
||||||
ManagementTokenURL: strings.TrimRight(m.config.OIDCIssuer, "/") + "/token",
|
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,
|
HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter,
|
||||||
HTTPClient: safeClient,
|
HTTPClient: safeClient,
|
||||||
})
|
})
|
||||||
@@ -656,13 +725,16 @@ func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConne
|
|||||||
}
|
}
|
||||||
m.stopRuntime()
|
m.stopRuntime()
|
||||||
_ = m.secrets.Delete(m.ctx, connection.CredentialRef)
|
_ = m.secrets.Delete(m.ctx, connection.CredentialRef)
|
||||||
|
if connection.ManagementCredentialRef != nil {
|
||||||
|
_ = m.secrets.Delete(m.ctx, *connection.ManagementCredentialRef)
|
||||||
|
}
|
||||||
if connection.NextCredentialRef != nil {
|
if connection.NextCredentialRef != nil {
|
||||||
_ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef)
|
_ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ConnectionManager) validatePrerequisites(issuer string) error {
|
func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error {
|
||||||
if !m.config.OIDCEnabled || m.config.ManagementClientID == "" || m.config.ManagementClientSecret == "" {
|
if !m.config.OIDCEnabled || strings.TrimSpace(managementClientID) == "" || len(managementSecret) < 16 {
|
||||||
return errors.New("OIDC and RFC 7662 machine client must be configured")
|
return errors.New("OIDC and RFC 7662 machine client must be configured")
|
||||||
}
|
}
|
||||||
if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil {
|
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 {
|
if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !requirePublicBaseURL {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
endpoint, err := url.Parse(strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf")
|
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 != "" {
|
if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
|
||||||
return errors.New("AI_GATEWAY_PUBLIC_BASE_URL is invalid")
|
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) {
|
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}}
|
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()))
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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")
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -736,6 +820,24 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl
|
|||||||
return payload.AccessToken, nil
|
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) {
|
func (m *ConnectionManager) findStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, description, endpoint string) (streamConfiguration, error) {
|
||||||
var streams []streamConfiguration
|
var streams []streamConfiguration
|
||||||
if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil {
|
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,
|
ConnectionID: connection.ConnectionID, TransmitterIssuer: connection.TransmitterIssuer,
|
||||||
ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID,
|
ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID,
|
||||||
LifecycleStatus: connection.LifecycleStatus, HealthMode: connection.HealthMode,
|
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,
|
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 {
|
func validateCreatedStream(stream streamConfiguration, issuer, endpoint string) error {
|
||||||
if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" ||
|
if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" ||
|
||||||
stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint ||
|
stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint ||
|
||||||
|
|||||||
@@ -66,11 +66,21 @@ func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Con
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
value := store.SecurityEventConnection{ConnectionID: input.ConnectionID, TransmitterIssuer: input.TransmitterIssuer,
|
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"}
|
IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"}
|
||||||
r.connection = &value
|
r.connection = &value
|
||||||
return value, nil
|
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) {
|
func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) {
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
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) {
|
func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) {
|
||||||
for _, address := range []string{
|
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",
|
"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":
|
case "/oidc/token":
|
||||||
clientID, secret, ok := r.BasicAuth()
|
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")
|
t.Fatal("RFC 7662 machine client was not reused")
|
||||||
}
|
}
|
||||||
_ = r.ParseForm()
|
_ = r.ParseForm()
|
||||||
@@ -339,7 +363,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
|
|||||||
defer transmitter.Close()
|
defer transmitter.Close()
|
||||||
manager, err := NewConnectionManager(ctx, repository, secrets, ConnectionManagerConfig{
|
manager, err := NewConnectionManager(ctx, repository, secrets, ConnectionManagerConfig{
|
||||||
AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/oidc", OIDCTenantID: uuid.NewString(),
|
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(),
|
PublicBaseURL: transmitter.URL, HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: transmitter.Client(),
|
||||||
}, &Metrics{})
|
}, &Metrics{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,7 +373,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
|
|||||||
if err != nil || evaluation.Enabled {
|
if err != nil || evaluation.Enabled {
|
||||||
t.Fatalf("unconfigured manager evaluation=%#v err=%v", evaluation, err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -357,12 +381,18 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
|
|||||||
t.Fatalf("connection view=%#v", view)
|
t.Fatalf("connection view=%#v", view)
|
||||||
}
|
}
|
||||||
encoded, _ := json.Marshal(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)
|
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))
|
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() {
|
if !managementScopeRequested.Load() {
|
||||||
t.Fatal("management scopes were not requested automatically")
|
t.Fatal("management scopes were not requested automatically")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,25 +16,27 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SecurityEventConnection struct {
|
type SecurityEventConnection struct {
|
||||||
ConnectionID string `json:"connectionId"`
|
ConnectionID string `json:"connectionId"`
|
||||||
TransmitterIssuer string `json:"transmitterIssuer"`
|
TransmitterIssuer string `json:"transmitterIssuer"`
|
||||||
EndpointURL string `json:"endpointUrl"`
|
EndpointURL string `json:"endpointUrl"`
|
||||||
Audience *string `json:"audience,omitempty"`
|
Audience *string `json:"audience,omitempty"`
|
||||||
StreamID *string `json:"streamId,omitempty"`
|
StreamID *string `json:"streamId,omitempty"`
|
||||||
CredentialRef string `json:"-"`
|
CredentialRef string `json:"-"`
|
||||||
NextCredentialRef *string `json:"-"`
|
NextCredentialRef *string `json:"-"`
|
||||||
LifecycleStatus string `json:"lifecycleStatus"`
|
ManagementClientID string `json:"managementClientId"`
|
||||||
Version int64 `json:"version"`
|
ManagementCredentialRef *string `json:"-"`
|
||||||
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
|
LifecycleStatus string `json:"lifecycleStatus"`
|
||||||
IdempotencyKey string `json:"-"`
|
Version int64 `json:"version"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
IdempotencyKey string `json:"-"`
|
||||||
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
HealthMode string `json:"healthMode"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
|
||||||
|
HealthMode string `json:"healthMode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateSecurityEventConnectionInput struct {
|
type CreateSecurityEventConnectionInput struct {
|
||||||
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, IdempotencyKey string
|
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, ManagementClientID, ManagementCredentialRef, IdempotencyKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecurityEventConnectionIdempotency struct {
|
type SecurityEventConnectionIdempotency struct {
|
||||||
@@ -65,13 +67,14 @@ func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateS
|
|||||||
var value SecurityEventConnection
|
var value SecurityEventConnection
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO gateway_security_event_connections(
|
INSERT INTO gateway_security_event_connections(
|
||||||
connection_id,transmitter_issuer,endpoint_url,credential_ref,lifecycle_status,idempotency_key
|
connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key
|
||||||
) VALUES($1::uuid,$2,$3,$4,'connecting',$5)
|
) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7)
|
||||||
RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref,
|
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`,
|
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.IdempotencyKey,
|
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,
|
).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.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
@@ -90,7 +93,8 @@ func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConne
|
|||||||
var value SecurityEventConnection
|
var value SecurityEventConnection
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience,
|
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,
|
connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at,
|
||||||
state.last_verification_at,COALESCE(state.mode,'disabled')
|
state.last_verification_at,COALESCE(state.mode,'disabled')
|
||||||
FROM gateway_security_event_connections connection
|
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
|
ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience
|
||||||
WHERE connection.singleton=true`).Scan(
|
WHERE connection.singleton=true`).Scan(
|
||||||
&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
|
&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.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt,
|
||||||
&value.LastVerificationAt, &value.HealthMode,
|
&value.LastVerificationAt, &value.HealthMode,
|
||||||
)
|
)
|
||||||
@@ -121,6 +126,19 @@ WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID,
|
|||||||
return s.SecurityEventConnection(ctx)
|
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) {
|
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
|
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)
|
SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -1029,12 +1029,12 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectSecurityEvents(transmitterIssuer: string) {
|
async function connectSecurityEvents(transmitterIssuer: string, managementClientId = '', managementClientSecret = '') {
|
||||||
setCoreState('loading');
|
setCoreState('loading');
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
const version = securityEventConnection?.connection?.version;
|
const version = securityEventConnection?.connection?.version;
|
||||||
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version);
|
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, managementClientId, managementClientSecret, version);
|
||||||
setSecurityEventConnection(connection);
|
setSecurityEventConnection(connection);
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('认证中心安全事件连接正在验证。');
|
setCoreMessage('认证中心安全事件连接正在验证。');
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('security event connection transport', () => {
|
|||||||
vi.unstubAllGlobals();
|
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 }), {
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
|
||||||
status: 202,
|
status: 202,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -48,12 +48,15 @@ describe('security event connection transport', () => {
|
|||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
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];
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
expect(init.method).toBe('PUT');
|
expect(init.method).toBe('PUT');
|
||||||
expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' });
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i);
|
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).get('Idempotency-Key')).toContain('ssf-connect-');
|
||||||
expect(new Headers(init.headers).has('If-Match')).toBe(false);
|
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('fetch', fetchMock);
|
||||||
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
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];
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
|
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
|
||||||
|
|||||||
+12
-2
@@ -1007,11 +1007,21 @@ export async function getSecurityEventConnection(token: string): Promise<Securit
|
|||||||
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
|
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise<SecurityEventConnectionResponse> {
|
export async function connectSecurityEventTransmitter(
|
||||||
|
token: string,
|
||||||
|
transmitterIssuer: string,
|
||||||
|
managementClientId: string,
|
||||||
|
managementClientSecret: string,
|
||||||
|
version?: number,
|
||||||
|
): Promise<SecurityEventConnectionResponse> {
|
||||||
const headers: Record<string, string> = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` };
|
const headers: Record<string, string> = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` };
|
||||||
if (version !== undefined) headers['If-Match'] = `W/"${version}"`;
|
if (version !== undefined) headers['If-Match'] = `W/"${version}"`;
|
||||||
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
|
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
|
||||||
body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token,
|
body: {
|
||||||
|
transmitter_issuer: transmitterIssuer,
|
||||||
|
management_client_id: managementClientId,
|
||||||
|
management_client_secret: managementClientSecret,
|
||||||
|
}, method: 'PUT', token,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function SystemSettingsPanel(props: {
|
|||||||
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
||||||
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||||
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
|
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
|
||||||
onDisconnectSecurityEvents: () => Promise<void>;
|
onDisconnectSecurityEvents: () => Promise<void>;
|
||||||
onRefreshSecurityEvents: () => Promise<void>;
|
onRefreshSecurityEvents: () => Promise<void>;
|
||||||
onRotateSecurityEventsCredential: () => Promise<void>;
|
onRotateSecurityEventsCredential: () => Promise<void>;
|
||||||
@@ -411,7 +411,7 @@ function SecurityEventConnectionPanel(props: {
|
|||||||
issuer: string;
|
issuer: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onIssuerChange(value: string): void;
|
onIssuerChange(value: string): void;
|
||||||
onConnect(issuer: string): Promise<void>;
|
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
|
||||||
onDisconnect(): void;
|
onDisconnect(): void;
|
||||||
onRefresh(): Promise<void>;
|
onRefresh(): Promise<void>;
|
||||||
onRotate(): Promise<void>;
|
onRotate(): Promise<void>;
|
||||||
@@ -419,9 +419,13 @@ function SecurityEventConnectionPanel(props: {
|
|||||||
}) {
|
}) {
|
||||||
const connection = props.connection?.connection;
|
const connection = props.connection?.connection;
|
||||||
const prerequisites = props.connection?.prerequisites;
|
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 = [
|
const missing = [
|
||||||
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
|
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
|
||||||
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
|
|
||||||
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
|
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
@@ -446,15 +450,22 @@ function SecurityEventConnectionPanel(props: {
|
|||||||
<p className="mutedText">请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。</p>
|
<p className="mutedText">请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。</p>
|
||||||
</div>
|
</div>
|
||||||
{missing.length > 0 && <div className="formMessage">{missing.join(';')}</div>}
|
{missing.length > 0 && <div className="formMessage">{missing.join(';')}</div>}
|
||||||
<div className="fileStorageMeta">
|
|
||||||
<span>复用机器 Client: {prerequisites?.managementClientId || '未配置'}</span>
|
|
||||||
</div>
|
|
||||||
<Label>
|
<Label>
|
||||||
SSF Transmitter Issuer
|
SSF Transmitter Issuer
|
||||||
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
|
<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>
|
<small>Endpoint、Scope、Push Bearer 和 Stream 由后端自动处理。</small>
|
||||||
</Label>
|
</Label>
|
||||||
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
|
<Label>
|
||||||
|
Machine Client ID
|
||||||
|
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Machine Client Secret
|
||||||
|
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||||
|
<small>浏览器只负责本次传递;Gateway 后端会立即写入 SecretStore,数据库、响应和日志均不保存明文。</small>
|
||||||
|
</Label>
|
||||||
|
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
|
||||||
|
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||||
<Link2 size={15} />连接认证中心
|
<Link2 size={15} />连接认证中心
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -475,6 +486,21 @@ function SecurityEventConnectionPanel(props: {
|
|||||||
{props.connection?.auditId && <span>最近操作 Audit ID: {props.connection.auditId}</span>}
|
{props.connection?.auditId && <span>最近操作 Audit ID: {props.connection.auditId}</span>}
|
||||||
{connection.lastErrorCategory && <span>最近错误: {connection.lastErrorCategory}</span>}
|
{connection.lastErrorCategory && <span>最近错误: {connection.lastErrorCategory}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
|
||||||
|
<div className="formMessage">认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。</div>
|
||||||
|
<Label>
|
||||||
|
Machine Client ID
|
||||||
|
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Machine Client Secret
|
||||||
|
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||||
|
</Label>
|
||||||
|
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
|
||||||
|
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||||
|
<Link2 size={15} />保存凭据并恢复连接
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
<div className="fileStorageToolbar">
|
<div className="fileStorageToolbar">
|
||||||
{connection.lifecycleStatus === 'error' && (
|
{connection.lifecycleStatus === 'error' && (
|
||||||
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} />重试连接</Button>
|
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} />重试连接</Button>
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Securit
|
|||||||
用户只执行两项操作:
|
用户只执行两项操作:
|
||||||
|
|
||||||
1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client,点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope;不会创建第二个 Client。
|
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_ISSUER`、`OIDC_TENANT_ID`;
|
||||||
- `OIDC_INTROSPECTION_ENABLED=true`;
|
|
||||||
- `OIDC_INTROSPECTION_CLIENT_ID/SECRET`,与认证中心准备 Receiver 时选择的机器 Client 相同;
|
|
||||||
- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。
|
- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。
|
||||||
|
|
||||||
|
`OIDC_INTROSPECTION_CLIENT_ID/SECRET` 仅作为旧部署兼容回退,不再是新连接必填项。新连接把 machine 凭据动态托管到 SecretStore,OIDC fallback、SSF 管理 Token 和 Verification 共用这一份凭据,重启后继续生效。
|
||||||
|
|
||||||
第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。
|
第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。
|
||||||
|
|
||||||
## SecretStore
|
## SecretStore
|
||||||
|
|||||||
@@ -976,6 +976,7 @@ export interface SecurityEventConnectionPrerequisites {
|
|||||||
introspectionClientConfigured: boolean;
|
introspectionClientConfigured: boolean;
|
||||||
publicBaseUrlConfigured: boolean;
|
publicBaseUrlConfigured: boolean;
|
||||||
managementClientId: string;
|
managementClientId: string;
|
||||||
|
credentialInputSupported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecurityEventConnectionResponse {
|
export interface SecurityEventConnectionResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user