feat(identity): 实现接入码配对后台流程
Gateway 使用一次性接入码创建 Exchange,将 Exchange Token、机器凭据与 Session Key 仅写入 SecretStore,并持久化脱敏配对进度。\n\n资源就绪后先保存凭据和 Manifest,再自动准备 SSF,最后确认远端 Exchange;SecretStore 失败时保持 ready,重试会触发新 Secret 轮换,不回放旧值。\n\n验证:go test ./...;go vet ./...
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user