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:
2026-07-17 09:13:57 +08:00
parent 8e33d1d33e
commit ffb85b73af
15 changed files with 327 additions and 90 deletions
@@ -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")
}