Gateway 使用一次性接入码创建 Exchange,将 Exchange Token、机器凭据与 Session Key 仅写入 SecretStore,并持久化脱敏配对进度。\n\n资源就绪后先保存凭据和 Manifest,再自动准备 SSF,最后确认远端 Exchange;SecretStore 失败时保持 ready,重试会触发新 Secret 轮换,不回放旧值。\n\n验证:go test ./...;go vet ./...
157 lines
6.6 KiB
Go
157 lines
6.6 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type pairingRepositoryFake struct {
|
|
revision Revision
|
|
exchange PairingExchange
|
|
}
|
|
|
|
func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) {
|
|
f.revision = revision
|
|
return revision, nil
|
|
}
|
|
func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, string) (Revision, error) {
|
|
return f.revision, nil
|
|
}
|
|
func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) {
|
|
revision, err := ApplyManifest(f.revision, applied)
|
|
if err != nil {
|
|
return Revision{}, err
|
|
}
|
|
revision.Version++
|
|
f.revision = revision
|
|
return revision, nil
|
|
}
|
|
func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) {
|
|
f.exchange = exchange
|
|
return exchange, nil
|
|
}
|
|
func (f *pairingRepositoryFake) IdentityPairingExchange(context.Context, string) (PairingExchange, error) {
|
|
return f.exchange, nil
|
|
}
|
|
func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context, id string, expected int64, update PairingExchangeUpdate) (PairingExchange, error) {
|
|
if f.exchange.ID != id || f.exchange.Version != expected {
|
|
return PairingExchange{}, ErrRevisionConflict
|
|
}
|
|
f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion
|
|
f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID
|
|
f.exchange.Version++
|
|
return f.exchange, nil
|
|
}
|
|
|
|
type secretStoreFake struct {
|
|
values map[string][]byte
|
|
failMachine bool
|
|
}
|
|
|
|
func (f *secretStoreFake) Put(_ context.Context, reference string, value []byte) error {
|
|
if f.failMachine && len(reference) > len("identity-machine-") && reference[:len("identity-machine-")] == "identity-machine-" {
|
|
return errors.New("secret store unavailable")
|
|
}
|
|
if f.values == nil {
|
|
f.values = map[string][]byte{}
|
|
}
|
|
f.values[reference] = append([]byte(nil), value...)
|
|
return nil
|
|
}
|
|
func (f *secretStoreFake) Get(_ context.Context, reference string) ([]byte, error) {
|
|
value, ok := f.values[reference]
|
|
if !ok {
|
|
return nil, errors.New("secret not found")
|
|
}
|
|
return append([]byte(nil), value...), nil
|
|
}
|
|
func (f *secretStoreFake) Delete(_ context.Context, reference string) error {
|
|
delete(f.values, reference)
|
|
return nil
|
|
}
|
|
|
|
type onboardingRemoteFake struct {
|
|
claimed ClaimedExchange
|
|
view Exchange
|
|
delivery CredentialDelivery
|
|
deliveryCalls int
|
|
completed bool
|
|
}
|
|
|
|
func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) {
|
|
return f.claimed, nil
|
|
}
|
|
func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) {
|
|
f.view.Status, f.view.Version = ExchangePreparing, 2
|
|
return f.view, nil
|
|
}
|
|
func (f *onboardingRemoteFake) Get(context.Context, string, string) (Exchange, error) {
|
|
f.view.Status, f.view.Version = ExchangeReady, 3
|
|
return f.view, nil
|
|
}
|
|
func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error) {
|
|
f.deliveryCalls++
|
|
f.delivery.MachineCredential.ClientSecret = "rotated-machine-secret-value-000000000000-" + string(rune('0'+f.deliveryCalls))
|
|
f.delivery.Version = int64(3 + f.deliveryCalls)
|
|
return f.delivery, nil
|
|
}
|
|
func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error {
|
|
f.completed = true
|
|
return nil
|
|
}
|
|
|
|
type securityEventPreparerFake struct{ called bool }
|
|
|
|
func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error {
|
|
f.called = true
|
|
return nil
|
|
}
|
|
|
|
func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) {
|
|
repository := &pairingRepositoryFake{}
|
|
secrets := &secretStoreFake{values: map[string][]byte{}, failMachine: true}
|
|
remote := &onboardingRemoteFake{
|
|
claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
|
|
view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
|
|
delivery: CredentialDelivery{Manifest: ManifestV1{
|
|
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333",
|
|
ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222",
|
|
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"},
|
|
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
|
|
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"},
|
|
}, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}},
|
|
}
|
|
preparer := &securityEventPreparerFake{}
|
|
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
|
|
pairing, err := service.Start(context.Background(), PairingInput{
|
|
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
|
|
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
|
|
}, "trace-pairing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := secrets.values[pairing.ExchangeTokenRef]; !ok {
|
|
t.Fatal("exchange token was not saved in SecretStore")
|
|
}
|
|
|
|
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
|
|
t.Fatal("machine SecretStore failure was ignored")
|
|
}
|
|
if remote.completed || repository.revision.ApplicationID != "" {
|
|
t.Fatal("failed secret save advanced the exchange or revision")
|
|
}
|
|
secrets.failMachine = false
|
|
completed, err := service.Continue(context.Background(), pairing.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !remote.completed || completed.Status != PairingCompleted || remote.deliveryCalls != 2 || !preparer.called {
|
|
t.Fatalf("pairing did not recover safely: pairing=%#v calls=%d completed=%v", completed, remote.deliveryCalls, remote.completed)
|
|
}
|
|
if repository.revision.MachineCredentialRef == "" || repository.revision.SessionEncryptionKeyRef == "" {
|
|
t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision)
|
|
}
|
|
}
|