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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user