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