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:
2026-07-17 18:31:12 +08:00
parent cdfca61304
commit a312ad880d
55 changed files with 9225 additions and 419 deletions
+63 -16
View File
@@ -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)
}
}
}
+16 -12
View File
@@ -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)
}
}
+370 -48
View File
@@ -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)
}
}