fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
This commit is contained in:
@@ -11,10 +11,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRevisionNotFound = errors.New("identity configuration revision not found")
|
||||
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
|
||||
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
|
||||
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
|
||||
ErrRevisionNotFound = errors.New("identity configuration revision not found")
|
||||
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
|
||||
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
|
||||
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
|
||||
ErrActiveConfigurationHandoffRequired = errors.New("active identity configuration requires an explicit remote resource handoff before re-pairing")
|
||||
ErrRollbackConfigurationHandoffRequired = errors.New("rollback requires a fresh remote resource handoff")
|
||||
ErrSecurityEventRetirementPending = errors.New("active security event connection must retire before identity can be disabled")
|
||||
)
|
||||
|
||||
type RevisionPolicy struct {
|
||||
@@ -111,15 +114,16 @@ type ManifestApplication struct {
|
||||
SessionEncryptionKeyRef string
|
||||
TraceID string
|
||||
AuditID string
|
||||
AppEnv string
|
||||
}
|
||||
|
||||
func NewDraft(input PairingInput) (Revision, error) {
|
||||
if _, err := input.ConsumerMetadata(false); err != nil {
|
||||
func NewDraft(input PairingInput, appEnv string) (Revision, error) {
|
||||
if _, err := input.ConsumerMetadata(false, appEnv); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
authCenter, _ := exactBaseURL(input.AuthCenterURL)
|
||||
publicBase, _ := exactBaseURL(input.PublicBaseURL)
|
||||
webBase, _ := exactBaseURL(input.WebBaseURL)
|
||||
authCenter, _ := exactBaseURL(input.AuthCenterURL, appEnv)
|
||||
publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv)
|
||||
webBase, _ := exactBaseURL(input.WebBaseURL, appEnv)
|
||||
return Revision{
|
||||
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
|
||||
AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey),
|
||||
@@ -133,7 +137,7 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro
|
||||
if revision.State != RevisionDraft {
|
||||
return Revision{}, ErrRevisionConflict
|
||||
}
|
||||
if err := input.Manifest.Validate(); err != nil {
|
||||
if err := input.Manifest.Validate(input.AppEnv); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
capabilities := make(map[string]bool, len(input.Manifest.Capabilities))
|
||||
@@ -187,17 +191,17 @@ func CanTransition(from, to RevisionState) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMetadata, error) {
|
||||
authCenter, err := exactBaseURL(input.AuthCenterURL)
|
||||
func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string) (ConsumerMetadata, error) {
|
||||
authCenter, err := exactBaseURL(input.AuthCenterURL, appEnv)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("auth center URL is invalid")
|
||||
}
|
||||
_ = authCenter
|
||||
publicBase, err := exactBaseURL(input.PublicBaseURL)
|
||||
publicBase, err := exactBaseURL(input.PublicBaseURL, appEnv)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("public base URL is invalid")
|
||||
}
|
||||
webBase, err := exactBaseURL(input.WebBaseURL)
|
||||
webBase, err := exactBaseURL(input.WebBaseURL, appEnv)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("web base URL is invalid")
|
||||
}
|
||||
@@ -215,7 +219,7 @@ func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMeta
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func exactBaseURL(raw string) (string, error) {
|
||||
func exactBaseURL(raw, appEnv string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("invalid public URL")
|
||||
@@ -225,7 +229,7 @@ func exactBaseURL(raw string) (string, error) {
|
||||
return "", errors.New("base URL must not contain a path")
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "https" && !(scheme == "http" && isLoopbackHost(hostname)) {
|
||||
if scheme != "https" && !(scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(hostname)) {
|
||||
return "", errors.New("public URL must use HTTPS")
|
||||
}
|
||||
port := parsed.Port()
|
||||
@@ -242,6 +246,49 @@ func exactBaseURL(raw string) (string, error) {
|
||||
return scheme + "://" + host, nil
|
||||
}
|
||||
|
||||
func isLocalIdentityEnvironment(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "development", "dev", "local", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRevisionURLs re-applies the deployment environment URL policy when
|
||||
// constructing a Runtime. This protects against legacy or directly persisted
|
||||
// revisions bypassing the pairing boundary.
|
||||
func ValidateRevisionURLs(revision Revision, appEnv string) error {
|
||||
urls := []struct {
|
||||
label string
|
||||
raw string
|
||||
base bool
|
||||
}{
|
||||
{label: "auth center", raw: revision.AuthCenterURL, base: true},
|
||||
{label: "issuer", raw: revision.Issuer},
|
||||
{label: "public base", raw: revision.PublicBaseURL, base: true},
|
||||
{label: "web base", raw: revision.WebBaseURL, base: true},
|
||||
}
|
||||
for _, candidate := range urls {
|
||||
var err error
|
||||
if candidate.base {
|
||||
_, err = exactBaseURL(candidate.raw, appEnv)
|
||||
} else {
|
||||
err = validatePublicIdentityURL(candidate.raw, appEnv)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment")
|
||||
}
|
||||
}
|
||||
if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" {
|
||||
if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil ||
|
||||
validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil {
|
||||
return errors.New("identity security event URLs must use HTTPS in this environment")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) {
|
||||
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
|
||||
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
|
||||
}
|
||||
metadata, err := input.ConsumerMetadata(true)
|
||||
metadata, err := input.ConsumerMetadata(true, "production")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) {
|
||||
{AuthCenterURL: "http://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
|
||||
{AuthCenterURL: "https://user:password@auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
|
||||
} {
|
||||
if _, err := input.ConsumerMetadata(false); err == nil {
|
||||
if _, err := input.ConsumerMetadata(false, "production"); err == nil {
|
||||
t.Fatalf("unsafe pairing input accepted: %#v", input)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin
|
||||
AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted",
|
||||
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com",
|
||||
LocalTenantKey: "default", LegacyJWTEnabled: true,
|
||||
})
|
||||
}, "production")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -87,3 +87,41 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin
|
||||
t.Fatalf("draft exposed onboarding code: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
||||
localInput := PairingInput{
|
||||
AuthCenterURL: "http://localhost:18000", PublicBaseURL: "http://127.0.0.1:18089",
|
||||
WebBaseURL: "http://localhost:5178", LocalTenantKey: "default",
|
||||
}
|
||||
secureInput := PairingInput{
|
||||
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
|
||||
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*PairingInput)
|
||||
}{
|
||||
{name: "auth center", mutate: func(input *PairingInput) { input.AuthCenterURL = localInput.AuthCenterURL }},
|
||||
{name: "public base", mutate: func(input *PairingInput) { input.PublicBaseURL = localInput.PublicBaseURL }},
|
||||
{name: "web base", mutate: func(input *PairingInput) { input.WebBaseURL = localInput.WebBaseURL }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
input := secureInput
|
||||
test.mutate(&input)
|
||||
for _, appEnv := range []string{"", "production", "staging"} {
|
||||
if _, err := NewDraft(input, appEnv); err == nil {
|
||||
t.Fatalf("%s accepted loopback HTTP %s URL", appEnv, test.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, appEnv := range []string{"local", "development", "dev", "test"} {
|
||||
draft, err := NewDraft(localInput, appEnv)
|
||||
if err != nil {
|
||||
t.Fatalf("%s rejected loopback HTTP pairing URLs: %v", appEnv, err)
|
||||
}
|
||||
if draft.AuthCenterURL != localInput.AuthCenterURL || draft.PublicBaseURL != localInput.PublicBaseURL || draft.WebBaseURL != localInput.WebBaseURL {
|
||||
t.Fatalf("%s changed normalized loopback URLs: %#v", appEnv, draft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ type CredentialDelivery struct {
|
||||
Version int64 `json:"-"`
|
||||
}
|
||||
|
||||
func (manifest ManifestV1) Validate() error {
|
||||
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer) != nil {
|
||||
func (manifest ManifestV1) Validate(appEnv string) error {
|
||||
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer, appEnv) != nil {
|
||||
return errors.New("application manifest identity metadata is invalid")
|
||||
}
|
||||
if _, err := uuid.Parse(manifest.TenantID); err != nil {
|
||||
@@ -116,9 +116,12 @@ func (manifest ManifestV1) Validate() error {
|
||||
if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" {
|
||||
return errors.New("application manifest audience is missing")
|
||||
}
|
||||
if capabilities["session_revocation"] {
|
||||
if manifest.SecurityEvents == nil || validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer) != nil ||
|
||||
validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" {
|
||||
if capabilities["session_revocation"] && manifest.SecurityEvents == nil {
|
||||
return errors.New("application manifest security event metadata is invalid")
|
||||
}
|
||||
if manifest.SecurityEvents != nil {
|
||||
if validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer, appEnv) != nil ||
|
||||
validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint, appEnv) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" {
|
||||
return errors.New("application manifest security event metadata is invalid")
|
||||
}
|
||||
}
|
||||
@@ -133,8 +136,8 @@ func (manifest ManifestV1) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePublicIdentityURL(raw string) error {
|
||||
_, err := exactBaseURL(raw)
|
||||
func validatePublicIdentityURL(raw, appEnv string) error {
|
||||
_, err := exactBaseURL(raw, appEnv)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -145,7 +148,7 @@ func validatePublicIdentityURL(raw string) error {
|
||||
return errors.New("identity URL is invalid")
|
||||
}
|
||||
scheme := strings.ToLower(parsed.URL.Scheme)
|
||||
if scheme == "https" || scheme == "http" && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
|
||||
if scheme == "https" || scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("identity URL must use HTTPS")
|
||||
@@ -154,10 +157,11 @@ func validatePublicIdentityURL(raw string) error {
|
||||
type OnboardingClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
appEnv string
|
||||
}
|
||||
|
||||
func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, error) {
|
||||
normalized, err := exactBaseURL(baseURL)
|
||||
func NewOnboardingClient(baseURL string, base *http.Client, appEnv string) (*OnboardingClient, error) {
|
||||
normalized, err := exactBaseURL(baseURL, appEnv)
|
||||
if err != nil {
|
||||
return nil, errors.New("auth center URL is invalid")
|
||||
}
|
||||
@@ -169,7 +173,7 @@ func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient,
|
||||
client.Timeout = 10 * time.Second
|
||||
}
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &OnboardingClient{baseURL: normalized, client: &client}, nil
|
||||
return &OnboardingClient{baseURL: normalized, client: &client, appEnv: appEnv}, nil
|
||||
}
|
||||
|
||||
func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) {
|
||||
@@ -208,7 +212,7 @@ func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange
|
||||
if err != nil || status != http.StatusOK {
|
||||
return CredentialDelivery{}, onboardingProtocolError(err)
|
||||
}
|
||||
if err := output.Manifest.Validate(); err != nil {
|
||||
if err := output.Manifest.Validate(client.appEnv); err != nil {
|
||||
return CredentialDelivery{}, err
|
||||
}
|
||||
output.Version, err = parseWeakETag(etag)
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewOnboardingClient(server.URL, server.Client())
|
||||
client, err := NewOnboardingClient(server.URL, server.Client(), "production")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -63,17 +63,17 @@ func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *te
|
||||
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:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
if err := valid.Validate("production"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := valid
|
||||
invalid.Capabilities = []string{"session_revocation"}
|
||||
if err := invalid.Validate(); err == nil {
|
||||
if err := invalid.Validate("production"); err == nil {
|
||||
t.Fatal("manifest with missing capability dependencies was accepted")
|
||||
}
|
||||
invalid = valid
|
||||
invalid.SchemaVersion = 2
|
||||
if err := invalid.Validate(); err == nil {
|
||||
if err := invalid.Validate("production"); err == nil {
|
||||
t.Fatal("unsupported manifest schema was accepted")
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestOnboardingClientRejectsRedirects(t *testing.T) {
|
||||
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
client, err := NewOnboardingClient(redirect.URL, redirect.Client())
|
||||
client, err := NewOnboardingClient(redirect.URL, redirect.Client(), "production")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -95,3 +95,61 @@ func TestOnboardingClientRejectsRedirects(t *testing.T) {
|
||||
t.Fatal("redirecting onboarding endpoint was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
||||
manifest := ManifestV1{
|
||||
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/easyai",
|
||||
TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
Capabilities: []string{"machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"gateway.access"},
|
||||
Clients: ManifestClients{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:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
},
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*ManifestV1)
|
||||
}{
|
||||
{name: "OIDC issuer", mutate: func(value *ManifestV1) { value.Issuer = "http://localhost:18003/issuer/easyai" }},
|
||||
{name: "SSF issuer", mutate: func(value *ManifestV1) { value.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf" }},
|
||||
{name: "SSF configuration", mutate: func(value *ManifestV1) {
|
||||
value.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf"
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := manifest
|
||||
securityEvents := *manifest.SecurityEvents
|
||||
candidate.SecurityEvents = &securityEvents
|
||||
test.mutate(&candidate)
|
||||
if err := candidate.Validate("production"); err == nil {
|
||||
t.Fatalf("production accepted loopback HTTP %s URL", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
localManifest := manifest
|
||||
localSecurityEvents := *manifest.SecurityEvents
|
||||
localManifest.SecurityEvents = &localSecurityEvents
|
||||
localManifest.Issuer = "http://localhost:18003/issuer/easyai"
|
||||
localManifest.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf"
|
||||
localManifest.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf"
|
||||
if err := localManifest.Validate("test"); err != nil {
|
||||
t.Fatalf("test environment rejected loopback HTTP manifest URLs: %v", err)
|
||||
}
|
||||
localManifest.Capabilities = []string{"machine_to_machine"}
|
||||
localManifest.Issuer = manifest.Issuer
|
||||
if err := localManifest.Validate("production"); err == nil {
|
||||
t.Fatal("production accepted optional loopback HTTP security event metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingClientAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
if _, err := NewOnboardingClient(server.URL, server.Client(), "production"); err == nil {
|
||||
t.Fatal("production accepted loopback HTTP Auth Center URL")
|
||||
}
|
||||
if _, err := NewOnboardingClient(server.URL, server.Client(), "development"); err != nil {
|
||||
t.Fatalf("development rejected loopback HTTP Auth Center URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -20,22 +21,45 @@ const (
|
||||
PairingCompleted PairingStatus = "completed"
|
||||
PairingFailed PairingStatus = "failed"
|
||||
PairingExpired PairingStatus = "expired"
|
||||
PairingCancelled PairingStatus = "cancelled"
|
||||
)
|
||||
|
||||
type PairingCleanupStatus string
|
||||
|
||||
const (
|
||||
PairingCleanupNone PairingCleanupStatus = "none"
|
||||
PairingCleanupPending PairingCleanupStatus = "pending"
|
||||
PairingCleanupCompleted PairingCleanupStatus = "completed"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPairingInProgress = errors.New("an identity pairing is already in progress")
|
||||
ErrPairingNotCancellable = errors.New("identity pairing cannot be cancelled")
|
||||
ErrPairingConflictNotResolvable = errors.New("identity pairing has no resolvable security event conflict")
|
||||
)
|
||||
|
||||
const (
|
||||
identityPairingStartReservationTTL = 2 * time.Minute
|
||||
identityPairingStartReleaseTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type PairingExchange struct {
|
||||
ID string `json:"id"`
|
||||
RevisionID string `json:"revisionId"`
|
||||
RemoteExchangeID string `json:"remoteExchangeId"`
|
||||
ExchangeTokenRef string `json:"-"`
|
||||
Status PairingStatus `json:"status"`
|
||||
RemoteVersion int64 `json:"remoteVersion"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Version int64 `json:"version"`
|
||||
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
||||
AuthCenterAuditID string `json:"authCenterAuditId,omitempty"`
|
||||
LastTraceID string `json:"lastTraceId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
RevisionID string `json:"revisionId"`
|
||||
RemoteExchangeID string `json:"remoteExchangeId"`
|
||||
ExchangeTokenRef string `json:"-"`
|
||||
Status PairingStatus `json:"status"`
|
||||
CleanupStatus PairingCleanupStatus `json:"cleanupStatus"`
|
||||
RemoteVersion int64 `json:"remoteVersion"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Version int64 `json:"version"`
|
||||
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
||||
AuthCenterAuditID string `json:"authCenterAuditId,omitempty"`
|
||||
LastTraceID string `json:"lastTraceId,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
CleanupCompletedAt *time.Time `json:"cleanupCompletedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PairingExchangeUpdate struct {
|
||||
@@ -46,12 +70,20 @@ type PairingExchangeUpdate struct {
|
||||
}
|
||||
|
||||
type PairingRepository interface {
|
||||
CreateIdentityConfigurationRevision(context.Context, Revision) (Revision, error)
|
||||
ReserveIdentityPairingStart(context.Context, string, time.Time) error
|
||||
ReleaseIdentityPairingStart(context.Context, string) error
|
||||
CommitIdentityPairingStart(context.Context, Revision, PairingExchange) (PairingExchange, error)
|
||||
IdentityPairingStartBlocked(context.Context) (bool, error)
|
||||
IdentityConfigurationRevision(context.Context, string) (Revision, error)
|
||||
ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error)
|
||||
CreateIdentityPairingExchange(context.Context, PairingExchange) (PairingExchange, error)
|
||||
IdentityPairingExchange(context.Context, string) (PairingExchange, error)
|
||||
UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error)
|
||||
RecordIdentityPairingRetryFailure(context.Context, string, PairingStatus, string) (PairingExchange, error)
|
||||
CancelIdentityPairingExchange(context.Context, string, int64, string, string) (PairingExchange, error)
|
||||
CompleteIdentityPairingCleanup(context.Context, string, int64) (PairingExchange, error)
|
||||
RecordIdentityPairingCleanupFailure(context.Context, string, string) (PairingExchange, error)
|
||||
QueueIdentitySecretCleanup(context.Context, string, time.Time) error
|
||||
RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error)
|
||||
}
|
||||
|
||||
type PairingSecretStore interface {
|
||||
@@ -70,6 +102,11 @@ type OnboardingRemote interface {
|
||||
|
||||
type SecurityEventPreparer interface {
|
||||
PrepareSecurityEvents(context.Context, Revision, []byte) error
|
||||
CleanupPreparedSecurityEvents(context.Context, Revision) error
|
||||
}
|
||||
|
||||
type SecurityEventConflictResolver interface {
|
||||
RetireConflictingSecurityEvents(context.Context, Revision) error
|
||||
}
|
||||
|
||||
type PairingService struct {
|
||||
@@ -77,22 +114,37 @@ type PairingService struct {
|
||||
secrets PairingSecretStore
|
||||
remote func(string) (OnboardingRemote, error)
|
||||
security SecurityEventPreparer
|
||||
appEnv string
|
||||
operations sync.Map
|
||||
}
|
||||
|
||||
func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer) *PairingService {
|
||||
return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security}
|
||||
func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer, appEnvs ...string) *PairingService {
|
||||
appEnv := "production"
|
||||
if len(appEnvs) > 0 {
|
||||
appEnv = appEnvs[0]
|
||||
}
|
||||
return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security, appEnv: appEnv}
|
||||
}
|
||||
|
||||
func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) {
|
||||
draft, err := NewDraft(input)
|
||||
draft, err := NewDraft(input, service.appEnv)
|
||||
if err != nil {
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
draft.LastTraceID = strings.TrimSpace(traceID)
|
||||
draft, err = service.repository.CreateIdentityConfigurationRevision(ctx, draft)
|
||||
if err != nil {
|
||||
pairingID := uuid.NewString()
|
||||
tokenReference := "identity-exchange-" + pairingID
|
||||
if err := service.repository.ReserveIdentityPairingStart(ctx, pairingID, time.Now().UTC().Add(identityPairingStartReservationTTL)); err != nil {
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
releaseContext, cancel := context.WithTimeout(context.Background(), identityPairingStartReleaseTimeout)
|
||||
defer cancel()
|
||||
_ = service.repository.ReleaseIdentityPairingStart(releaseContext, pairingID)
|
||||
}
|
||||
}()
|
||||
remote, err := service.remote(draft.AuthCenterURL)
|
||||
if err != nil {
|
||||
return PairingExchange{}, err
|
||||
@@ -101,32 +153,211 @@ func (service *PairingService) Start(ctx context.Context, input PairingInput, tr
|
||||
if err != nil {
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
pairingID := uuid.NewString()
|
||||
tokenReference := "identity-exchange-" + pairingID
|
||||
if err := service.secrets.Put(ctx, tokenReference, []byte(claimed.ExchangeToken)); err != nil {
|
||||
token := []byte(claimed.ExchangeToken)
|
||||
claimed.ExchangeToken = ""
|
||||
if err := service.stageIdentitySecret(ctx, tokenReference, token); err != nil {
|
||||
clear(token)
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
claimed.ExchangeToken = ""
|
||||
clear(token)
|
||||
pairing := PairingExchange{
|
||||
ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference,
|
||||
Status: PairingMetadataPending, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
|
||||
Status: PairingMetadataPending, CleanupStatus: PairingCleanupNone, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
|
||||
Version: 1, LastTraceID: strings.TrimSpace(traceID),
|
||||
}
|
||||
pairing, err = service.repository.CreateIdentityPairingExchange(ctx, pairing)
|
||||
pairing, err = service.repository.CommitIdentityPairingStart(ctx, draft, pairing)
|
||||
if err != nil {
|
||||
_ = service.secrets.Delete(ctx, tokenReference)
|
||||
// Commit may have succeeded even when its response was lost. The
|
||||
// staging row is the source of truth: rollback leaves it queued, while
|
||||
// a successful commit atomically adopts it. Re-queueing here could
|
||||
// delete a Secret that the new Pairing already references.
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
committed = true
|
||||
return pairing, nil
|
||||
}
|
||||
|
||||
func (service *PairingService) Cancel(ctx context.Context, pairingID string, expectedVersion int64, traceID, auditID string) (PairingExchange, error) {
|
||||
return service.repository.CancelIdentityPairingExchange(ctx, pairingID, expectedVersion, strings.TrimSpace(traceID), strings.TrimSpace(auditID))
|
||||
}
|
||||
|
||||
func (service *PairingService) RetireConflictingSecurityEvents(ctx context.Context, pairingID string, expectedVersion int64) (PairingExchange, error) {
|
||||
unlock := service.lockPairingOperation(pairingID)
|
||||
defer unlock()
|
||||
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
|
||||
if err != nil {
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
if pairing.Version != expectedVersion {
|
||||
return pairing, ErrRevisionConflict
|
||||
}
|
||||
if pairing.Status != PairingCredentialsSaved || pairing.CleanupStatus != PairingCleanupNone ||
|
||||
pairing.LastErrorCategory != "security_event_connection_conflict" {
|
||||
return pairing, ErrPairingConflictNotResolvable
|
||||
}
|
||||
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
|
||||
if err != nil {
|
||||
return pairing, err
|
||||
}
|
||||
if revision.State != RevisionDraft || !revision.SessionRevocation {
|
||||
return pairing, ErrPairingConflictNotResolvable
|
||||
}
|
||||
resolver, ok := service.security.(SecurityEventConflictResolver)
|
||||
if !ok {
|
||||
return pairing, errors.New("security event conflict recovery is unavailable")
|
||||
}
|
||||
if err := resolver.RetireConflictingSecurityEvents(ctx, revision); err != nil {
|
||||
return pairing, err
|
||||
}
|
||||
return service.repository.RecordIdentityPairingRetryFailure(ctx, pairing.ID, pairing.Status, "security_event_retirement_pending")
|
||||
}
|
||||
|
||||
// RestoreCompletedSecurityEvents reconstructs the prepared Receiver after a
|
||||
// process restart while a completed Revision is still awaiting validation or
|
||||
// activation. The machine Secret is read only from SecretStore and cleared
|
||||
// immediately after use.
|
||||
func (service *PairingService) RestoreCompletedSecurityEvents(ctx context.Context, pairingID string) error {
|
||||
unlock := service.lockPairingOperation(pairingID)
|
||||
defer unlock()
|
||||
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pairing.Status != PairingCompleted {
|
||||
return ErrRevisionConflict
|
||||
}
|
||||
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision.State == RevisionActive {
|
||||
return nil
|
||||
}
|
||||
if revision.State != RevisionDraft && revision.State != RevisionValidated {
|
||||
return ErrRevisionConflict
|
||||
}
|
||||
if !revision.SessionRevocation {
|
||||
return nil
|
||||
}
|
||||
if service.security == nil || revision.MachineCredentialRef == "" {
|
||||
return errors.New("completed security event configuration is unavailable")
|
||||
}
|
||||
secret, err := service.secrets.Get(ctx, revision.MachineCredentialRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(secret)
|
||||
if err := service.security.PrepareSecurityEvents(ctx, revision, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
currentRevision, revisionErr := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
|
||||
if revisionErr != nil {
|
||||
return revisionErr
|
||||
}
|
||||
if currentRevision.State == RevisionActive {
|
||||
return nil
|
||||
}
|
||||
currentPairing, pairingErr := service.repository.IdentityPairingExchange(ctx, pairingID)
|
||||
if pairingErr != nil {
|
||||
return pairingErr
|
||||
}
|
||||
if currentPairing.Status == PairingCompleted &&
|
||||
(currentRevision.State == RevisionDraft || currentRevision.State == RevisionValidated) {
|
||||
return nil
|
||||
}
|
||||
if cleanupErr := service.security.CleanupPreparedSecurityEvents(ctx, currentRevision); cleanupErr != nil {
|
||||
return cleanupErr
|
||||
}
|
||||
return ErrRevisionConflict
|
||||
}
|
||||
|
||||
func (service *PairingService) Cleanup(ctx context.Context, pairingID string) (PairingExchange, error) {
|
||||
unlock := service.lockPairingOperation(pairingID)
|
||||
defer unlock()
|
||||
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
|
||||
if err != nil {
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
if pairing.Status != PairingCancelled {
|
||||
return pairing, ErrPairingNotCancellable
|
||||
}
|
||||
if pairing.CleanupStatus == PairingCleanupCompleted {
|
||||
return pairing, nil
|
||||
}
|
||||
if pairing.CleanupStatus != PairingCleanupPending {
|
||||
return pairing, ErrRevisionConflict
|
||||
}
|
||||
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
|
||||
if err != nil {
|
||||
return service.recordCleanupFailure(ctx, pairing, "cleanup_revision_unavailable", err)
|
||||
}
|
||||
if revision.SessionRevocation {
|
||||
if service.security == nil {
|
||||
return service.recordCleanupFailure(ctx, pairing, "cleanup_security_event_unavailable", errors.New("security event cleanup is unavailable"))
|
||||
}
|
||||
if err := service.security.CleanupPreparedSecurityEvents(ctx, revision); err != nil {
|
||||
return service.recordCleanupFailure(ctx, pairing, pairingCleanupFailureCategory(err), err)
|
||||
}
|
||||
}
|
||||
for _, reference := range []string{pairing.ExchangeTokenRef, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef} {
|
||||
if reference == "" {
|
||||
continue
|
||||
}
|
||||
if err := service.secrets.Delete(ctx, reference); err != nil {
|
||||
return service.recordCleanupFailure(ctx, pairing, "cleanup_secret_store_failed", err)
|
||||
}
|
||||
}
|
||||
completed, err := service.repository.CompleteIdentityPairingCleanup(ctx, pairing.ID, pairing.Version)
|
||||
if err != nil {
|
||||
return service.recordCleanupFailure(ctx, pairing, "cleanup_finalize_failed", err)
|
||||
}
|
||||
return completed, nil
|
||||
}
|
||||
|
||||
func (service *PairingService) recordCleanupFailure(ctx context.Context, pairing PairingExchange, category string, cause error) (PairingExchange, error) {
|
||||
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
|
||||
return pairing, cause
|
||||
}
|
||||
recorded, err := service.repository.RecordIdentityPairingCleanupFailure(ctx, pairing.ID, category)
|
||||
if err != nil {
|
||||
return pairing, cause
|
||||
}
|
||||
return recorded, cause
|
||||
}
|
||||
|
||||
func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) {
|
||||
unlock := service.lockPairingOperation(pairingID)
|
||||
defer unlock()
|
||||
pairing, err := service.continuePairing(ctx, pairingID)
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return pairing, err
|
||||
}
|
||||
current, currentErr := service.repository.IdentityPairingExchange(ctx, pairingID)
|
||||
if currentErr != nil || !isPairingInProgress(current.Status) {
|
||||
return pairing, err
|
||||
}
|
||||
category := pairingRetryFailureCategory(current.Status, err)
|
||||
recorded, recordErr := service.repository.RecordIdentityPairingRetryFailure(ctx, current.ID, current.Status, category)
|
||||
if recordErr != nil {
|
||||
return current, err
|
||||
}
|
||||
return recorded, err
|
||||
}
|
||||
|
||||
func (service *PairingService) lockPairingOperation(pairingID string) func() {
|
||||
value, _ := service.operations.LoadOrStore(pairingID, &sync.Mutex{})
|
||||
mutex := value.(*sync.Mutex)
|
||||
mutex.Lock()
|
||||
return mutex.Unlock
|
||||
}
|
||||
|
||||
func (service *PairingService) continuePairing(ctx context.Context, pairingID string) (PairingExchange, error) {
|
||||
for step := 0; step < 6; step++ {
|
||||
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
|
||||
if err != nil {
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired {
|
||||
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired || pairing.Status == PairingCancelled {
|
||||
return pairing, nil
|
||||
}
|
||||
if !pairing.ExpiresAt.After(time.Now()) {
|
||||
@@ -134,7 +365,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
|
||||
Status: PairingExpired, RemoteVersion: pairing.RemoteVersion, LastErrorCategory: "exchange_expired",
|
||||
})
|
||||
if updateErr == nil {
|
||||
_ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef)
|
||||
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
|
||||
}
|
||||
return expired, updateErr
|
||||
}
|
||||
@@ -156,7 +387,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
|
||||
metadata, metadataErr := PairingInput{
|
||||
AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL,
|
||||
WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey,
|
||||
}.ConsumerMetadata(true)
|
||||
}.ConsumerMetadata(true, service.appEnv)
|
||||
if metadataErr != nil {
|
||||
clear(token)
|
||||
return PairingExchange{}, metadataErr
|
||||
@@ -224,7 +455,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
|
||||
completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion)
|
||||
clear(token)
|
||||
if completeErr != nil {
|
||||
return PairingExchange{}, completeErr
|
||||
return PairingExchange{}, pairingStepError{category: "exchange_completion_failed", cause: completeErr}
|
||||
}
|
||||
completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
|
||||
Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion,
|
||||
@@ -232,7 +463,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
|
||||
if err != nil {
|
||||
return PairingExchange{}, err
|
||||
}
|
||||
_ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef)
|
||||
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
|
||||
return completed, nil
|
||||
default:
|
||||
clear(token)
|
||||
@@ -242,8 +473,71 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
|
||||
return service.repository.IdentityPairingExchange(ctx, pairingID)
|
||||
}
|
||||
|
||||
func isPairingInProgress(status PairingStatus) bool {
|
||||
switch status {
|
||||
case PairingMetadataPending, PairingPreparing, PairingReady, PairingCredentialsSaved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type safeErrorCategory interface {
|
||||
SafeErrorCategory() string
|
||||
}
|
||||
|
||||
type pairingStepError struct {
|
||||
category string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err pairingStepError) Error() string { return "identity pairing step failed: " + err.category }
|
||||
func (err pairingStepError) Unwrap() error { return err.cause }
|
||||
func (err pairingStepError) SafeErrorCategory() string { return err.category }
|
||||
|
||||
func pairingRetryFailureCategory(status PairingStatus, err error) string {
|
||||
var categorized safeErrorCategory
|
||||
if errors.As(err, &categorized) && categorized.SafeErrorCategory() == "exchange_completion_failed" {
|
||||
return "exchange_completion_failed"
|
||||
}
|
||||
switch status {
|
||||
case PairingMetadataPending:
|
||||
return "metadata_submission_failed"
|
||||
case PairingPreparing:
|
||||
return "exchange_status_unavailable"
|
||||
case PairingReady:
|
||||
return "credential_delivery_failed"
|
||||
case PairingCredentialsSaved:
|
||||
if errors.As(err, &categorized) {
|
||||
switch categorized.SafeErrorCategory() {
|
||||
case "configuration_invalid", "connection_conflict", "discovery_failed", "management_token_failed",
|
||||
"stream_create_failed", "stream_response_invalid", "receiver_activation_failed", "preparation_failed", "retirement_pending",
|
||||
"credential_handoff_unsafe", "connection_binding_missing", "connection_binding_unavailable",
|
||||
"connection_binding_invalid", "connection_binding_mismatch":
|
||||
return "security_event_" + categorized.SafeErrorCategory()
|
||||
}
|
||||
}
|
||||
return "security_event_preparation_failed"
|
||||
default:
|
||||
return "pairing_step_failed"
|
||||
}
|
||||
}
|
||||
|
||||
func pairingCleanupFailureCategory(err error) string {
|
||||
var categorized safeErrorCategory
|
||||
if errors.As(err, &categorized) {
|
||||
switch categorized.SafeErrorCategory() {
|
||||
case "configuration_invalid", "retirement_pending", "connection_conflict", "connection_cleanup_failed", "secret_cleanup_failed",
|
||||
"discovery_failed", "management_token_failed", "stream_create_failed", "stream_response_invalid",
|
||||
"receiver_activation_failed", "preparation_failed", "credential_handoff_unsafe":
|
||||
return "cleanup_security_event_" + categorized.SafeErrorCategory()
|
||||
}
|
||||
}
|
||||
return "cleanup_security_event_failed"
|
||||
}
|
||||
|
||||
func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) {
|
||||
if err := delivery.Manifest.Validate(); err != nil {
|
||||
if err := delivery.Manifest.Validate(service.appEnv); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
machineReference := ""
|
||||
@@ -251,10 +545,10 @@ func (service *PairingService) saveDelivery(ctx context.Context, revision Revisi
|
||||
if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID {
|
||||
return Revision{}, errors.New("onboarding machine credential is invalid")
|
||||
}
|
||||
machineReference = "identity-machine-" + revision.ID
|
||||
machineReference = "identity-machine-" + uuid.NewString()
|
||||
secret := []byte(delivery.MachineCredential.ClientSecret)
|
||||
delivery.MachineCredential.ClientSecret = ""
|
||||
if err := service.secrets.Put(ctx, machineReference, secret); err != nil {
|
||||
if err := service.stageIdentitySecret(ctx, machineReference, secret); err != nil {
|
||||
clear(secret)
|
||||
return Revision{}, err
|
||||
}
|
||||
@@ -262,49 +556,77 @@ func (service *PairingService) saveDelivery(ctx context.Context, revision Revisi
|
||||
}
|
||||
sessionReference := ""
|
||||
if delivery.Manifest.Clients.BrowserLogin != nil {
|
||||
sessionReference = "identity-session-" + revision.ID
|
||||
sessionReference = "identity-session-" + uuid.NewString()
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
_ = service.secrets.Delete(ctx, machineReference)
|
||||
_ = service.retireIdentitySecret(ctx, machineReference)
|
||||
return Revision{}, err
|
||||
}
|
||||
if err := service.secrets.Put(ctx, sessionReference, key); err != nil {
|
||||
if err := service.stageIdentitySecret(ctx, sessionReference, key); err != nil {
|
||||
clear(key)
|
||||
_ = service.secrets.Delete(ctx, machineReference)
|
||||
_ = service.retireIdentitySecret(ctx, machineReference)
|
||||
return Revision{}, err
|
||||
}
|
||||
clear(key)
|
||||
}
|
||||
updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{
|
||||
Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference,
|
||||
TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID,
|
||||
TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, AppEnv: service.appEnv,
|
||||
})
|
||||
if err != nil {
|
||||
if machineReference != "" {
|
||||
_ = service.secrets.Delete(ctx, machineReference)
|
||||
}
|
||||
if sessionReference != "" {
|
||||
_ = service.secrets.Delete(ctx, sessionReference)
|
||||
}
|
||||
// ApplyIdentityManifest adopts the staged references in the same DB
|
||||
// transaction as the Revision update. On rollback the staging rows
|
||||
// remain; on an ambiguous successful commit they are gone. Do not
|
||||
// re-queue here or a valid active credential could be destroyed.
|
||||
return Revision{}, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (service *PairingService) stageIdentitySecret(ctx context.Context, reference string, value []byte) error {
|
||||
if err := service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(10*time.Minute)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.secrets.Put(ctx, reference, value); err != nil {
|
||||
deleteErr := service.secrets.Delete(ctx, reference)
|
||||
if deleteErr == nil {
|
||||
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *PairingService) retireIdentitySecret(ctx context.Context, reference string) error {
|
||||
if reference == "" {
|
||||
return nil
|
||||
}
|
||||
return service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (service *PairingService) deleteRetiredIdentitySecret(ctx context.Context, reference string) {
|
||||
if err := service.secrets.Delete(ctx, reference); err == nil {
|
||||
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) {
|
||||
status := PairingPreparing
|
||||
category := ""
|
||||
switch remote.Status {
|
||||
case ExchangeReady, ExchangeCredentialDelivered:
|
||||
status = PairingReady
|
||||
case ExchangeCompleted:
|
||||
status = PairingFailed
|
||||
remote.LastErrorCategory = "remote_state_invalid"
|
||||
category = "remote_state_invalid"
|
||||
case ExchangeFailed:
|
||||
status = PairingFailed
|
||||
category = "remote_exchange_failed"
|
||||
case ExchangeExpired:
|
||||
status = PairingExpired
|
||||
category = "exchange_expired"
|
||||
}
|
||||
return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
|
||||
Status: status, RemoteVersion: remote.Version, LastErrorCategory: remote.LastErrorCategory, AuthCenterAuditID: remote.AuditID,
|
||||
Status: status, RemoteVersion: remote.Version, LastErrorCategory: category, AuthCenterAuditID: remote.AuditID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,14 +2,68 @@ package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pairingRepositoryFake struct {
|
||||
revision Revision
|
||||
exchange PairingExchange
|
||||
revision Revision
|
||||
exchange PairingExchange
|
||||
blocked bool
|
||||
startReservationErr error
|
||||
startReservationID string
|
||||
startReservationState string
|
||||
startReservationCalls int
|
||||
startReservationReleases int
|
||||
pendingSecretCleanups map[string]time.Time
|
||||
commitStartErr error
|
||||
commitStartAmbiguous bool
|
||||
applyManifestAmbiguousOnce bool
|
||||
failCredentialsSavedOnce bool
|
||||
}
|
||||
|
||||
func (f *pairingRepositoryFake) ReserveIdentityPairingStart(_ context.Context, attemptID string, _ time.Time) error {
|
||||
f.startReservationCalls++
|
||||
if f.startReservationErr != nil {
|
||||
return f.startReservationErr
|
||||
}
|
||||
if f.blocked || f.startReservationID != "" {
|
||||
return ErrPairingInProgress
|
||||
}
|
||||
f.startReservationID = attemptID
|
||||
f.startReservationState = "starting"
|
||||
return nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) ReleaseIdentityPairingStart(_ context.Context, attemptID string) error {
|
||||
if f.startReservationID == attemptID && f.startReservationState == "starting" {
|
||||
f.startReservationID = ""
|
||||
f.startReservationState = ""
|
||||
f.startReservationReleases++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) CommitIdentityPairingStart(_ context.Context, revision Revision, exchange PairingExchange) (PairingExchange, error) {
|
||||
if f.commitStartErr != nil && !f.commitStartAmbiguous {
|
||||
return PairingExchange{}, f.commitStartErr
|
||||
}
|
||||
if f.startReservationID != exchange.ID || f.startReservationState != "starting" {
|
||||
return PairingExchange{}, ErrPairingInProgress
|
||||
}
|
||||
if _, staged := f.pendingSecretCleanups[exchange.ExchangeTokenRef]; !staged {
|
||||
return PairingExchange{}, errors.New("exchange token was not staged")
|
||||
}
|
||||
delete(f.pendingSecretCleanups, exchange.ExchangeTokenRef)
|
||||
f.revision = revision
|
||||
f.exchange = exchange
|
||||
f.startReservationState = "paired"
|
||||
if f.commitStartAmbiguous {
|
||||
return PairingExchange{}, f.commitStartErr
|
||||
}
|
||||
return exchange, nil
|
||||
}
|
||||
|
||||
func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) {
|
||||
@@ -20,12 +74,33 @@ func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, s
|
||||
return f.revision, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) {
|
||||
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
|
||||
if reference == "" {
|
||||
continue
|
||||
}
|
||||
if _, staged := f.pendingSecretCleanups[reference]; !staged {
|
||||
return Revision{}, errors.New("identity secret was not staged")
|
||||
}
|
||||
}
|
||||
oldMachineReference, oldSessionReference := f.revision.MachineCredentialRef, f.revision.SessionEncryptionKeyRef
|
||||
revision, err := ApplyManifest(f.revision, applied)
|
||||
if err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
revision.Version++
|
||||
f.revision = revision
|
||||
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
|
||||
delete(f.pendingSecretCleanups, reference)
|
||||
}
|
||||
for _, reference := range []string{oldMachineReference, oldSessionReference} {
|
||||
if reference != "" && reference != applied.MachineCredentialRef && reference != applied.SessionEncryptionKeyRef {
|
||||
f.pendingSecretCleanups[reference] = time.Now()
|
||||
}
|
||||
}
|
||||
if f.applyManifestAmbiguousOnce {
|
||||
f.applyManifestAmbiguousOnce = false
|
||||
return Revision{}, errors.New("manifest commit response lost")
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) {
|
||||
@@ -39,9 +114,97 @@ func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context,
|
||||
if f.exchange.ID != id || f.exchange.Version != expected {
|
||||
return PairingExchange{}, ErrRevisionConflict
|
||||
}
|
||||
if update.Status == PairingCredentialsSaved && f.failCredentialsSavedOnce {
|
||||
f.failCredentialsSavedOnce = false
|
||||
return PairingExchange{}, errors.New("pairing status temporarily unavailable")
|
||||
}
|
||||
f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion
|
||||
f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID
|
||||
f.exchange.Version++
|
||||
if update.Status == PairingCompleted || update.Status == PairingFailed || update.Status == PairingExpired {
|
||||
if f.pendingSecretCleanups == nil {
|
||||
f.pendingSecretCleanups = map[string]time.Time{}
|
||||
}
|
||||
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
|
||||
}
|
||||
return f.exchange, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) RecordIdentityPairingRetryFailure(_ context.Context, id string, status PairingStatus, category string) (PairingExchange, error) {
|
||||
if f.exchange.ID != id || f.exchange.Status != status {
|
||||
return PairingExchange{}, ErrRevisionConflict
|
||||
}
|
||||
f.exchange.LastErrorCategory = category
|
||||
return f.exchange, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) IdentityPairingStartBlocked(context.Context) (bool, error) {
|
||||
return f.blocked, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) QueueIdentitySecretCleanup(_ context.Context, reference string, notBefore time.Time) error {
|
||||
if f.pendingSecretCleanups == nil {
|
||||
f.pendingSecretCleanups = map[string]time.Time{}
|
||||
}
|
||||
current, exists := f.pendingSecretCleanups[reference]
|
||||
if !exists || notBefore.Before(current) {
|
||||
f.pendingSecretCleanups[reference] = notBefore
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) RemovePendingIdentitySecretCleanup(_ context.Context, reference string) (bool, error) {
|
||||
if _, exists := f.pendingSecretCleanups[reference]; !exists {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.pendingSecretCleanups, reference)
|
||||
return true, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) CancelIdentityPairingExchange(_ context.Context, id string, expected int64, traceID, auditID string) (PairingExchange, error) {
|
||||
if f.exchange.ID != id || f.revision.State == RevisionActive || f.revision.State == RevisionSuperseded {
|
||||
return PairingExchange{}, ErrRevisionConflict
|
||||
}
|
||||
if f.exchange.Status == PairingCancelled {
|
||||
return f.exchange, nil
|
||||
}
|
||||
if f.exchange.Version != expected {
|
||||
return PairingExchange{}, ErrRevisionConflict
|
||||
}
|
||||
f.exchange.Status = PairingCancelled
|
||||
f.exchange.CleanupStatus = PairingCleanupPending
|
||||
f.exchange.Version++
|
||||
now := time.Now().UTC()
|
||||
f.exchange.CancelledAt = &now
|
||||
f.revision.State = RevisionFailed
|
||||
f.revision.LastErrorCategory = "pairing_cancelled"
|
||||
f.revision.LastTraceID = traceID
|
||||
f.revision.LastAuditID = auditID
|
||||
f.revision.Version++
|
||||
if f.exchange.ExchangeTokenRef != "" {
|
||||
if f.pendingSecretCleanups == nil {
|
||||
f.pendingSecretCleanups = map[string]time.Time{}
|
||||
}
|
||||
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
|
||||
}
|
||||
return f.exchange, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) CompleteIdentityPairingCleanup(_ context.Context, id string, expected int64) (PairingExchange, error) {
|
||||
if f.exchange.ID != id || f.exchange.Version != expected || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
|
||||
return PairingExchange{}, ErrRevisionConflict
|
||||
}
|
||||
f.exchange.CleanupStatus = PairingCleanupCompleted
|
||||
f.exchange.Version++
|
||||
now := time.Now().UTC()
|
||||
f.exchange.CleanupCompletedAt = &now
|
||||
f.revision.MachineCredentialRef = ""
|
||||
f.revision.SessionEncryptionKeyRef = ""
|
||||
if f.startReservationState == "paired" && f.startReservationID == f.exchange.ID {
|
||||
f.startReservationID = ""
|
||||
f.startReservationState = ""
|
||||
}
|
||||
return f.exchange, nil
|
||||
}
|
||||
func (f *pairingRepositoryFake) RecordIdentityPairingCleanupFailure(_ context.Context, id string, category string) (PairingExchange, error) {
|
||||
if f.exchange.ID != id || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
|
||||
return PairingExchange{}, ErrRevisionConflict
|
||||
}
|
||||
f.exchange.LastErrorCategory = category
|
||||
return f.exchange, nil
|
||||
}
|
||||
|
||||
@@ -74,14 +237,18 @@ func (f *secretStoreFake) Delete(_ context.Context, reference string) error {
|
||||
|
||||
type onboardingRemoteFake struct {
|
||||
claimed ClaimedExchange
|
||||
claimErr error
|
||||
claimCalls int
|
||||
view Exchange
|
||||
delivery CredentialDelivery
|
||||
deliveryCalls int
|
||||
completed bool
|
||||
completeErr error
|
||||
}
|
||||
|
||||
func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) {
|
||||
return f.claimed, nil
|
||||
f.claimCalls++
|
||||
return f.claimed, f.claimErr
|
||||
}
|
||||
func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) {
|
||||
f.view.Status, f.view.Version = ExchangePreparing, 2
|
||||
@@ -99,14 +266,159 @@ func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, stri
|
||||
}
|
||||
func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error {
|
||||
f.completed = true
|
||||
return f.completeErr
|
||||
}
|
||||
|
||||
type securityEventPreparerFake struct {
|
||||
called bool
|
||||
err error
|
||||
cleanupCalled bool
|
||||
cleanupErr error
|
||||
conflictRetireCalled bool
|
||||
conflictRetireErr error
|
||||
conflictRevisionID string
|
||||
}
|
||||
|
||||
type blockingSecurityEventPreparer struct {
|
||||
prepareStarted chan struct{}
|
||||
releasePrepare chan struct{}
|
||||
cleanupStarted chan struct{}
|
||||
cleanupOnce sync.Once
|
||||
}
|
||||
|
||||
func (preparer *blockingSecurityEventPreparer) PrepareSecurityEvents(context.Context, Revision, []byte) error {
|
||||
close(preparer.prepareStarted)
|
||||
<-preparer.releasePrepare
|
||||
return nil
|
||||
}
|
||||
|
||||
type securityEventPreparerFake struct{ called bool }
|
||||
func (preparer *blockingSecurityEventPreparer) CleanupPreparedSecurityEvents(context.Context, Revision) error {
|
||||
preparer.cleanupOnce.Do(func() { close(preparer.cleanupStarted) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error {
|
||||
f.called = true
|
||||
return nil
|
||||
return f.err
|
||||
}
|
||||
func (f *securityEventPreparerFake) CleanupPreparedSecurityEvents(context.Context, Revision) error {
|
||||
f.cleanupCalled = true
|
||||
return f.cleanupErr
|
||||
}
|
||||
func (f *securityEventPreparerFake) RetireConflictingSecurityEvents(_ context.Context, revision Revision) error {
|
||||
f.conflictRetireCalled = true
|
||||
f.conflictRevisionID = revision.ID
|
||||
return f.conflictRetireErr
|
||||
}
|
||||
|
||||
type safeCategoryTestError struct {
|
||||
category string
|
||||
message string
|
||||
}
|
||||
|
||||
func (err safeCategoryTestError) Error() string { return err.message }
|
||||
func (err safeCategoryTestError) SafeErrorCategory() string { return err.category }
|
||||
|
||||
func TestPairingStartReservationSerializesAndRecoversFailedClaims(t *testing.T) {
|
||||
input := PairingInput{
|
||||
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
|
||||
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
|
||||
}
|
||||
|
||||
t.Run("existing pairing blocks before remote claim", func(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{blocked: true}
|
||||
remote := &onboardingRemoteFake{}
|
||||
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
|
||||
|
||||
if _, err := service.Start(context.Background(), input, "trace-blocked"); !errors.Is(err, ErrPairingInProgress) {
|
||||
t.Fatalf("blocked Start error=%v", err)
|
||||
}
|
||||
if repository.startReservationCalls != 1 || repository.startReservationReleases != 0 || repository.startReservationID != "" || remote.claimCalls != 0 {
|
||||
t.Fatalf("reservation/claim lifecycle calls=%d releases=%d id=%q claims=%d",
|
||||
repository.startReservationCalls, repository.startReservationReleases, repository.startReservationID, remote.claimCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("active machine credential blocks destructive rotation before remote claim", func(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{startReservationErr: ErrActiveConfigurationHandoffRequired}
|
||||
remote := &onboardingRemoteFake{}
|
||||
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
|
||||
|
||||
if _, err := service.Start(context.Background(), input, "trace-active"); !errors.Is(err, ErrActiveConfigurationHandoffRequired) {
|
||||
t.Fatalf("active credential guard error=%v", err)
|
||||
}
|
||||
if remote.claimCalls != 0 {
|
||||
t.Fatal("active credential guard ran after the one-time onboarding code was claimed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote failure releases database guard", func(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{}
|
||||
remote := &onboardingRemoteFake{claimErr: errors.New("remote unavailable")}
|
||||
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
|
||||
|
||||
if _, err := service.Start(context.Background(), input, "trace-failed"); err == nil {
|
||||
t.Fatal("remote claim failure was ignored")
|
||||
}
|
||||
if repository.startReservationCalls != 1 || repository.startReservationReleases != 1 || repository.startReservationID != "" || remote.claimCalls != 1 {
|
||||
t.Fatalf("reservation was not released after remote failure: %#v remote=%#v", repository, remote)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPairingStartLeavesUncommittedExchangeTokenInDurableCleanupQueue(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{commitStartErr: errors.New("database commit unavailable")}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{}}
|
||||
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
|
||||
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
|
||||
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
|
||||
}}
|
||||
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
|
||||
|
||||
if _, 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-uncommitted"); err == nil {
|
||||
t.Fatal("failed pairing commit was ignored")
|
||||
}
|
||||
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 1 || repository.startReservationID != "" || repository.startReservationReleases != 1 {
|
||||
t.Fatalf("uncommitted token was not left recoverable: secrets=%d cleanup=%d reservation=%q releases=%d",
|
||||
len(secrets.values), len(repository.pendingSecretCleanups), repository.startReservationID, repository.startReservationReleases)
|
||||
}
|
||||
for reference := range secrets.values {
|
||||
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
|
||||
t.Fatalf("uncommitted Secret reference %q is not queued for cleanup", reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingStartAmbiguousCommitNeverRequeuesAdoptedExchangeToken(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
commitStartErr: errors.New("commit response lost"), commitStartAmbiguous: true,
|
||||
}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{}}
|
||||
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
|
||||
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
|
||||
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
|
||||
}}
|
||||
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
|
||||
|
||||
if _, 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-ambiguous"); err == nil {
|
||||
t.Fatal("ambiguous pairing commit was not reported")
|
||||
}
|
||||
if repository.exchange.ID == "" || repository.revision.ID == "" {
|
||||
t.Fatal("test did not simulate a committed Pairing")
|
||||
}
|
||||
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 0 {
|
||||
t.Fatalf("adopted exchange token was re-queued: secrets=%d cleanup=%d", len(secrets.values), len(repository.pendingSecretCleanups))
|
||||
}
|
||||
if repository.startReservationID != repository.exchange.ID || repository.startReservationState != "paired" || repository.startReservationReleases != 0 {
|
||||
t.Fatalf("committed reservation was not retained: id=%q state=%q releases=%d",
|
||||
repository.startReservationID, repository.startReservationState, repository.startReservationReleases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) {
|
||||
@@ -155,6 +467,91 @@ func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingRetryNeverOverwritesOrDeletesAdoptedIdentitySecrets(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{applyManifestAmbiguousOnce: true, failCredentialsSavedOnce: true}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{}}
|
||||
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-retry")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
|
||||
t.Fatal("simulated pairing status persistence failure was ignored")
|
||||
}
|
||||
firstMachine := repository.revision.MachineCredentialRef
|
||||
firstSession := repository.revision.SessionEncryptionKeyRef
|
||||
if firstMachine == "" || firstSession == "" {
|
||||
t.Fatalf("first delivery was not adopted: %#v", repository.revision)
|
||||
}
|
||||
if _, ok := secrets.values[firstMachine]; !ok {
|
||||
t.Fatal("first adopted machine Secret was deleted")
|
||||
}
|
||||
if _, ok := secrets.values[firstSession]; !ok {
|
||||
t.Fatal("first adopted session Secret was deleted")
|
||||
}
|
||||
for _, reference := range []string{firstMachine, firstSession} {
|
||||
if _, queued := repository.pendingSecretCleanups[reference]; queued {
|
||||
t.Fatalf("ambiguously adopted identity Secret %q was re-queued", reference)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
|
||||
t.Fatal("simulated pairing status persistence failure was ignored")
|
||||
}
|
||||
secondMachine := repository.revision.MachineCredentialRef
|
||||
secondSession := repository.revision.SessionEncryptionKeyRef
|
||||
if secondMachine == firstMachine || secondSession == firstSession {
|
||||
t.Fatalf("identity Secret references were overwritten in place: first=(%s,%s) second=(%s,%s)", firstMachine, firstSession, secondMachine, secondSession)
|
||||
}
|
||||
for _, reference := range []string{firstMachine, firstSession} {
|
||||
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
|
||||
t.Fatalf("first superseded identity Secret %q was not queued for cleanup", reference)
|
||||
}
|
||||
}
|
||||
|
||||
completed, err := service.Continue(context.Background(), pairing.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed.Status != PairingCompleted {
|
||||
t.Fatalf("retry did not complete: %#v", completed)
|
||||
}
|
||||
thirdMachine := repository.revision.MachineCredentialRef
|
||||
thirdSession := repository.revision.SessionEncryptionKeyRef
|
||||
if thirdMachine == secondMachine || thirdSession == secondSession {
|
||||
t.Fatalf("second retry overwrote identity Secret references in place: second=(%s,%s) third=(%s,%s)", secondMachine, secondSession, thirdMachine, thirdSession)
|
||||
}
|
||||
for _, reference := range []string{thirdMachine, thirdSession} {
|
||||
if _, ok := secrets.values[reference]; !ok {
|
||||
t.Fatalf("active identity Secret %q was deleted", reference)
|
||||
}
|
||||
if _, queued := repository.pendingSecretCleanups[reference]; queued {
|
||||
t.Fatalf("active identity Secret %q remained in cleanup queue", reference)
|
||||
}
|
||||
}
|
||||
for _, reference := range []string{firstMachine, firstSession, secondMachine, secondSession} {
|
||||
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
|
||||
t.Fatalf("superseded identity Secret %q was not queued for cleanup", reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{exchange: PairingExchange{
|
||||
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
|
||||
@@ -177,3 +574,307 @@ func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) {
|
||||
t.Fatal("expired exchange token remained in SecretStore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingPersistsOnlySafeSecurityEventFailureCategory(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{
|
||||
ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com",
|
||||
SessionRevocation: true, MachineCredentialRef: "identity-machine-revision",
|
||||
},
|
||||
exchange: PairingExchange{
|
||||
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
|
||||
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
|
||||
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
|
||||
},
|
||||
}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{
|
||||
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
|
||||
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
|
||||
}}
|
||||
remote := &onboardingRemoteFake{}
|
||||
preparer := &securityEventPreparerFake{err: safeCategoryTestError{
|
||||
category: "discovery_failed",
|
||||
message: "sensitive-marker https://internal.example/ssf token-value",
|
||||
}}
|
||||
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
|
||||
|
||||
failed, err := service.Continue(context.Background(), "pairing")
|
||||
if err == nil {
|
||||
t.Fatal("security event discovery failure was ignored")
|
||||
}
|
||||
if failed.Status != PairingCredentialsSaved || failed.LastErrorCategory != "security_event_discovery_failed" {
|
||||
t.Fatalf("unsafe or missing pairing failure state: %#v", failed)
|
||||
}
|
||||
encoded, marshalErr := json.Marshal(failed)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
if strings.Contains(string(encoded), "sensitive-marker") || strings.Contains(string(encoded), "internal.example") || strings.Contains(string(encoded), "token-value") {
|
||||
t.Fatalf("raw security event error escaped through pairing JSON: %s", encoded)
|
||||
}
|
||||
|
||||
preparer.err = nil
|
||||
completed, err := service.Continue(context.Background(), "pairing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed.Status != PairingCompleted || completed.LastErrorCategory != "" {
|
||||
t.Fatalf("successful retry did not clear the diagnostic category: %#v", completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingCancellationPersistsIntentBeforeCleaningTemporaryResources(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{
|
||||
ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true,
|
||||
MachineCredentialRef: "identity-machine-revision", SessionEncryptionKeyRef: "identity-session-revision",
|
||||
},
|
||||
exchange: PairingExchange{
|
||||
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
|
||||
Status: PairingCredentialsSaved, CleanupStatus: PairingCleanupNone, RemoteVersion: 4,
|
||||
ExpiresAt: time.Now().Add(time.Hour), Version: 7,
|
||||
},
|
||||
}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{
|
||||
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
|
||||
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
|
||||
"identity-session-revision": []byte("temporary-session-secret-value-0000000"),
|
||||
}}
|
||||
preparer := &securityEventPreparerFake{}
|
||||
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) {
|
||||
t.Fatal("cancellation must not call the onboarding remote")
|
||||
return nil, nil
|
||||
}, preparer)
|
||||
|
||||
cancelled, err := service.Cancel(context.Background(), "pairing", 7, "trace-cancel", "audit-cancel")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cancelled.Status != PairingCancelled || cancelled.CleanupStatus != PairingCleanupPending || cancelled.CancelledAt == nil {
|
||||
t.Fatalf("cancellation intent was not persisted: %#v", cancelled)
|
||||
}
|
||||
if repository.revision.State != RevisionFailed || repository.revision.LastErrorCategory != "pairing_cancelled" {
|
||||
t.Fatalf("draft was not atomically retired: %#v", repository.revision)
|
||||
}
|
||||
if len(secrets.values) != 3 {
|
||||
t.Fatal("synchronous cancellation removed resources before the cleanup worker could resume them")
|
||||
}
|
||||
replayed, err := service.Cancel(context.Background(), "pairing", 7, "trace-replay", "audit-replay")
|
||||
if err != nil || replayed.Version != cancelled.Version || replayed.Status != PairingCancelled {
|
||||
t.Fatalf("lost cancellation response could not be replayed safely: %#v err=%v", replayed, err)
|
||||
}
|
||||
|
||||
cleaned, err := service.Cleanup(context.Background(), "pairing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleaned.CleanupStatus != PairingCleanupCompleted || cleaned.CleanupCompletedAt == nil || !preparer.cleanupCalled {
|
||||
t.Fatalf("pairing cleanup was not completed: %#v", cleaned)
|
||||
}
|
||||
if len(secrets.values) != 0 || repository.revision.MachineCredentialRef != "" || repository.revision.SessionEncryptionKeyRef != "" {
|
||||
t.Fatalf("temporary secret references survived cleanup: secrets=%d revision=%#v", len(secrets.values), repository.revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingCleanupRetriesWithSafeCategory(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{ID: "revision", State: RevisionFailed, Version: 4, SessionRevocation: true},
|
||||
exchange: PairingExchange{
|
||||
ID: "pairing", RevisionID: "revision", Status: PairingCancelled, CleanupStatus: PairingCleanupPending,
|
||||
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 8,
|
||||
},
|
||||
}
|
||||
preparer := &securityEventPreparerFake{cleanupErr: safeCategoryTestError{
|
||||
category: "retirement_pending", message: "sensitive cleanup marker token-value",
|
||||
}}
|
||||
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{}}, nil, preparer)
|
||||
|
||||
pending, err := service.Cleanup(context.Background(), "pairing")
|
||||
if err == nil {
|
||||
t.Fatal("pending security event retirement was treated as completed")
|
||||
}
|
||||
if pending.CleanupStatus != PairingCleanupPending || pending.LastErrorCategory != "cleanup_security_event_retirement_pending" || strings.Contains(pending.LastErrorCategory, "sensitive") {
|
||||
t.Fatalf("cleanup failure was not safely persisted: %#v", pending)
|
||||
}
|
||||
|
||||
preparer.cleanupErr = nil
|
||||
completed, err := service.Cleanup(context.Background(), "pairing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed.CleanupStatus != PairingCleanupCompleted {
|
||||
t.Fatalf("cleanup retry did not complete: %#v", completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingMapsUntrustedRemoteErrorCategoryToStableValue(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{exchange: PairingExchange{ID: "pairing", Version: 2}}
|
||||
service := NewPairingService(repository, &secretStoreFake{}, nil, nil)
|
||||
|
||||
updated, err := service.updateFromRemote(context.Background(), repository.exchange, Exchange{
|
||||
Status: ExchangeFailed, Version: 3, LastErrorCategory: "secret_token_abcdef",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Status != PairingFailed || updated.LastErrorCategory != "remote_exchange_failed" {
|
||||
t.Fatalf("untrusted remote category was persisted: %#v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingConflictRetirementIsScopedToCurrentBlockedPairing(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true},
|
||||
exchange: PairingExchange{
|
||||
ID: "pairing", RevisionID: "revision", Status: PairingCredentialsSaved,
|
||||
CleanupStatus: PairingCleanupNone, Version: 7, LastErrorCategory: "security_event_connection_conflict",
|
||||
},
|
||||
}
|
||||
preparer := &securityEventPreparerFake{}
|
||||
service := NewPairingService(repository, &secretStoreFake{}, nil, preparer)
|
||||
|
||||
resolved, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.ID != "pairing" || resolved.LastErrorCategory != "security_event_retirement_pending" || !preparer.conflictRetireCalled || preparer.conflictRevisionID != "revision" {
|
||||
t.Fatalf("conflict retirement escaped pairing scope: pairing=%#v preparer=%#v", resolved, preparer)
|
||||
}
|
||||
|
||||
preparer.conflictRetireCalled = false
|
||||
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 6); !errors.Is(err, ErrRevisionConflict) {
|
||||
t.Fatalf("stale pairing version error=%v", err)
|
||||
}
|
||||
repository.exchange.LastErrorCategory = ""
|
||||
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7); !errors.Is(err, ErrPairingConflictNotResolvable) {
|
||||
t.Fatalf("resolved pairing could retire another connection: %v", err)
|
||||
}
|
||||
if preparer.conflictRetireCalled {
|
||||
t.Fatal("stale conflict action reached the security event manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedPairingRestoresPreparedReceiverAfterRestart(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{
|
||||
ID: "revision", State: RevisionValidated, SessionRevocation: true,
|
||||
MachineCredentialRef: "identity-machine-revision",
|
||||
},
|
||||
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
|
||||
}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{
|
||||
"identity-machine-revision": []byte("machine-secret-value-restored-from-store"),
|
||||
}}
|
||||
preparer := &securityEventPreparerFake{}
|
||||
service := NewPairingService(repository, secrets, nil, preparer)
|
||||
|
||||
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !preparer.called {
|
||||
t.Fatal("completed pairing did not reconstruct its prepared security event receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedPairingDoesNotRestoreReceiverForSupersededRevision(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{
|
||||
ID: "revision", State: RevisionSuperseded, Version: 5, SessionRevocation: true,
|
||||
MachineCredentialRef: "identity-machine-revision",
|
||||
},
|
||||
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
|
||||
}
|
||||
preparer := &securityEventPreparerFake{}
|
||||
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{
|
||||
"identity-machine-revision": []byte("must-not-be-read"),
|
||||
}}, nil, preparer)
|
||||
|
||||
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); !errors.Is(err, ErrRevisionConflict) {
|
||||
t.Fatalf("restore error=%v, want revision conflict", err)
|
||||
}
|
||||
if preparer.called {
|
||||
t.Fatal("superseded Revision reconstructed an orphan prepared Receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledPairingCleanupWaitsForCompletedReceiverRestore(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{
|
||||
ID: "revision", State: RevisionValidated, Version: 3, SessionRevocation: true,
|
||||
MachineCredentialRef: "identity-machine-revision",
|
||||
},
|
||||
exchange: PairingExchange{
|
||||
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
|
||||
Status: PairingCompleted, CleanupStatus: PairingCleanupNone, Version: 8,
|
||||
},
|
||||
}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{
|
||||
"identity-exchange-pairing": []byte("exchange-token"),
|
||||
"identity-machine-revision": []byte("machine-secret"),
|
||||
}}
|
||||
preparer := &blockingSecurityEventPreparer{
|
||||
prepareStarted: make(chan struct{}),
|
||||
releasePrepare: make(chan struct{}),
|
||||
cleanupStarted: make(chan struct{}),
|
||||
}
|
||||
service := NewPairingService(repository, secrets, nil, preparer)
|
||||
restoreResult := make(chan error, 1)
|
||||
go func() { restoreResult <- service.RestoreCompletedSecurityEvents(context.Background(), "pairing") }()
|
||||
<-preparer.prepareStarted
|
||||
|
||||
cancelled, err := service.Cancel(context.Background(), "pairing", 8, "trace", "audit")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupResult := make(chan PairingExchange, 1)
|
||||
cleanupError := make(chan error, 1)
|
||||
go func() {
|
||||
cleaned, cleanupErr := service.Cleanup(context.Background(), "pairing")
|
||||
cleanupResult <- cleaned
|
||||
cleanupError <- cleanupErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-preparer.cleanupStarted:
|
||||
t.Fatal("cleanup overtook an in-flight completed receiver restore")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(preparer.releasePrepare)
|
||||
if err := <-restoreResult; !errors.Is(err, ErrRevisionConflict) {
|
||||
t.Fatalf("restore error = %v, want revision conflict after cancellation", err)
|
||||
}
|
||||
if err := <-cleanupError; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleaned := <-cleanupResult
|
||||
if cancelled.Status != PairingCancelled || cleaned.CleanupStatus != PairingCleanupCompleted {
|
||||
t.Fatalf("cancel/cleanup did not converge: cancelled=%#v cleaned=%#v", cancelled, cleaned)
|
||||
}
|
||||
if len(secrets.values) != 0 {
|
||||
t.Fatalf("cancelled restore left temporary secrets: %d", len(secrets.values))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingClassifiesExchangeCompletionFailureSeparatelyFromSSF(t *testing.T) {
|
||||
repository := &pairingRepositoryFake{
|
||||
revision: Revision{ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com"},
|
||||
exchange: PairingExchange{
|
||||
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
|
||||
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
|
||||
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
|
||||
},
|
||||
}
|
||||
secrets := &secretStoreFake{values: map[string][]byte{
|
||||
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
|
||||
}}
|
||||
remote := &onboardingRemoteFake{completeErr: errors.New("upstream body contains sensitive marker")}
|
||||
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
|
||||
|
||||
failed, err := service.Continue(context.Background(), "pairing")
|
||||
if err == nil {
|
||||
t.Fatal("exchange completion failure was ignored")
|
||||
}
|
||||
if failed.LastErrorCategory != "exchange_completion_failed" {
|
||||
t.Fatalf("completion failure was misclassified: %#v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user