package store import ( "context" "database/sql" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) var ( ErrSecurityEventConnectionNotFound = errors.New("security event connection not found") ErrSecurityEventConnectionConflict = errors.New("security event connection conflicts with current state") ErrIdentitySecretCleanupConflict = errors.New("identity secret cleanup conflicts with current state") ) type SecurityEventConnection struct { ConnectionID string `json:"connectionId"` TransmitterIssuer string `json:"transmitterIssuer"` EndpointURL string `json:"endpointUrl"` Audience *string `json:"audience,omitempty"` StreamID *string `json:"streamId,omitempty"` CredentialRef string `json:"-"` NextCredentialRef *string `json:"-"` ManagementClientID string `json:"managementClientId"` ManagementCredentialRef *string `json:"-"` LifecycleStatus string `json:"lifecycleStatus"` Version int64 `json:"version"` LastErrorCategory *string `json:"lastErrorCategory,omitempty"` IdempotencyKey string `json:"-"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"` HealthMode string `json:"healthMode"` } type CreateSecurityEventConnectionInput struct { ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, ManagementClientID, ManagementCredentialRef, IdempotencyKey string } type SecurityEventConnectionIdempotency struct { RequestHash string Response json.RawMessage } type IdentitySecretCleanupClaim struct { Reference string ClaimToken string LeaseExpiresAt time.Time } func (s *Store) QueueIdentitySecretCleanup(ctx context.Context, reference string, notBefore time.Time) error { tag, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before) VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now() WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore) if err != nil { return err } if tag.RowsAffected() != 1 { return ErrIdentitySecretCleanupConflict } return nil } func (s *Store) RemovePendingIdentitySecretCleanup(ctx context.Context, reference string) (bool, error) { tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1 AND status='pending'`, reference) if err != nil { return false, err } return tag.RowsAffected() == 1, nil } func (s *Store) ClaimIdentitySecretCleanups(ctx context.Context, limit int, lease time.Duration) ([]IdentitySecretCleanupClaim, error) { if limit <= 0 || limit > 1000 { limit = 100 } if lease <= 0 || lease > time.Hour { lease = 2 * time.Minute } leaseSeconds := int64((lease + time.Second - 1) / time.Second) rows, err := s.pool.Query(ctx, `WITH candidates AS MATERIALIZED ( SELECT secret_ref FROM gateway_identity_secret_cleanup_queue WHERE (status='pending' AND not_before <= now()) OR (status='claimed' AND lease_expires_at <= now()) ORDER BY COALESCE(lease_expires_at,not_before),created_at FOR UPDATE SKIP LOCKED LIMIT $1 ) UPDATE gateway_identity_secret_cleanup_queue queued SET status='claimed',claim_token=gen_random_uuid(), lease_expires_at=now()+make_interval(secs => $2::int),updated_at=now() FROM candidates WHERE queued.secret_ref=candidates.secret_ref RETURNING queued.secret_ref,queued.claim_token::text,queued.lease_expires_at`, limit, leaseSeconds) if err != nil { return nil, err } defer rows.Close() claims := make([]IdentitySecretCleanupClaim, 0) for rows.Next() { var claim IdentitySecretCleanupClaim if err := rows.Scan(&claim.Reference, &claim.ClaimToken, &claim.LeaseExpiresAt); err != nil { return nil, err } claims = append(claims, claim) } return claims, rows.Err() } func (s *Store) CompleteIdentitySecretCleanup(ctx context.Context, reference, claimToken string) (bool, error) { if _, err := uuid.Parse(claimToken); err != nil { return false, ErrIdentitySecretCleanupConflict } tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1 AND status='claimed' AND claim_token=$2::uuid`, reference, claimToken) if err != nil { return false, err } return tag.RowsAffected() == 1, nil } func adoptPendingIdentitySecrets(ctx context.Context, tx pgx.Tx, references ...string) error { unique := make(map[string]struct{}, len(references)) for _, reference := range references { if reference == "" { return ErrIdentitySecretCleanupConflict } unique[reference] = struct{}{} } values := make([]string, 0, len(unique)) for reference := range unique { values = append(values, reference) } tag, err := tx.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1) AND status='pending'`, values) if err != nil { return err } if tag.RowsAffected() != int64(len(values)) { return ErrIdentitySecretCleanupConflict } return nil } func queueIdentitySecretCleanupTx(ctx context.Context, tx pgx.Tx, reference string, notBefore time.Time) error { tag, err := tx.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before) VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now() WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore) if err != nil { return err } if tag.RowsAffected() != 1 { return ErrIdentitySecretCleanupConflict } return nil } func (s *Store) SecurityEventConnectionIdempotency(ctx context.Context, operation, key string) (SecurityEventConnectionIdempotency, error) { var value SecurityEventConnectionIdempotency err := s.pool.QueryRow(ctx, `SELECT request_hash,response FROM gateway_security_event_connection_idempotency WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan(&value.RequestHash, &value.Response) if errors.Is(err, pgx.ErrNoRows) { return SecurityEventConnectionIdempotency{}, ErrSecurityEventConnectionNotFound } return value, err } func (s *Store) RecordSecurityEventConnectionIdempotency(ctx context.Context, operation, key, requestHash string, response []byte) error { if !json.Valid(response) { return errors.New("security event idempotency response is invalid") } _, err := s.pool.Exec(ctx, `INSERT INTO gateway_security_event_connection_idempotency(operation,idempotency_key,request_hash,response) VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, operation, key, requestHash, response) return err } func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateSecurityEventConnectionInput) (SecurityEventConnection, error) { tx, err := s.pool.Begin(ctx) if err != nil { return SecurityEventConnection{}, err } defer tx.Rollback(ctx) _, err = tx.Exec(ctx, ` INSERT INTO gateway_security_event_connections( connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key ) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7) `, input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey, ) if err != nil { if isUniqueViolation(err) { _ = tx.Rollback(ctx) existing, getErr := s.SecurityEventConnection(ctx) if getErr == nil && existing.IdempotencyKey == input.IdempotencyKey && existing.TransmitterIssuer == input.TransmitterIssuer { return existing, nil } return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } return SecurityEventConnection{}, err } if err := adoptPendingIdentitySecrets(ctx, tx, input.CredentialRef, input.ManagementCredentialRef); err != nil { return SecurityEventConnection{}, err } if err := tx.Commit(ctx); err != nil { return SecurityEventConnection{}, err } return s.SecurityEventConnection(ctx) } func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConnection, error) { var value SecurityEventConnection err := s.pool.QueryRow(ctx, ` SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience, connection.stream_id::text,connection.credential_ref,connection.next_credential_ref, COALESCE(connection.management_client_id,''),connection.management_credential_ref,connection.lifecycle_status, connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at, state.last_verification_at,COALESCE(state.mode,'disabled') FROM gateway_security_event_connections connection LEFT JOIN gateway_security_event_stream_state state ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience WHERE connection.singleton=true`).Scan( &value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID, &value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, &value.LifecycleStatus, &value.Version, &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt, &value.LastVerificationAt, &value.HealthMode, ) if errors.Is(err, pgx.ErrNoRows) { return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound } return value, err } func (s *Store) BindSecurityEventConnection(ctx context.Context, connectionID, audience, streamID, lifecycle string, expectedVersion int64) (SecurityEventConnection, error) { tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections SET audience=$2,stream_id=$3::uuid,lifecycle_status=$4,last_error_category=NULL,version=version+1,updated_at=now() WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID, lifecycle, expectedVersion) if err != nil { return SecurityEventConnection{}, err } if tag.RowsAffected() == 0 { return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } return s.SecurityEventConnection(ctx) } func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string, expectedVersion int64) (SecurityEventConnection, error) { tx, err := s.pool.Begin(ctx) if err != nil { return SecurityEventConnection{}, err } defer tx.Rollback(ctx) var oldReference sql.NullString var lifecycle string var version int64 if err := tx.QueryRow(ctx, `SELECT management_credential_ref,lifecycle_status,version FROM gateway_security_event_connections WHERE connection_id=$1::uuid FOR UPDATE`, connectionID).Scan(&oldReference, &lifecycle, &version); errors.Is(err, pgx.ErrNoRows) { return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound } else if err != nil { return SecurityEventConnection{}, err } if version != expectedVersion || lifecycle == "retiring" || lifecycle == "disconnect_pending" { return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL, version=version+1,updated_at=now() WHERE connection_id=$1::uuid AND version=$4 AND lifecycle_status NOT IN ('retiring','disconnect_pending')`, connectionID, clientID, reference, expectedVersion) if err != nil { return SecurityEventConnection{}, err } if tag.RowsAffected() == 0 { return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil { return SecurityEventConnection{}, err } if oldReference.Valid && oldReference.String != reference { if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference.String, time.Now().UTC()); err != nil { return SecurityEventConnection{}, err } } if err := tx.Commit(ctx); err != nil { return SecurityEventConnection{}, err } return s.SecurityEventConnection(ctx) } func (s *Store) TransitionSecurityEventConnectionLifecycle(ctx context.Context, connectionID, expectedLifecycle string, expectedVersion int64, lifecycle string, errorCategory *string) (SecurityEventConnection, error) { tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections SET lifecycle_status=$4,last_error_category=$5,version=version+1,updated_at=now() WHERE connection_id=$1::uuid AND lifecycle_status=$2 AND version=$3 AND (lifecycle_status NOT IN ('retiring','disconnect_pending') OR $4 IN ('retiring','disconnect_pending'))`, connectionID, expectedLifecycle, expectedVersion, lifecycle, errorCategory) if err != nil { return SecurityEventConnection{}, err } if tag.RowsAffected() == 0 { return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } return s.SecurityEventConnection(ctx) } func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string, expectedVersion int64) (SecurityEventConnection, error) { tx, err := s.pool.Begin(ctx) if err != nil { return SecurityEventConnection{}, err } defer tx.Rollback(ctx) tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now() WHERE connection_id=$1::uuid AND version=$3 AND next_credential_ref IS NULL AND lifecycle_status IN ('connecting','verifying','bootstrap','enabled','degraded','error')`, connectionID, reference, expectedVersion) if err != nil { return SecurityEventConnection{}, err } if tag.RowsAffected() == 0 { return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil { return SecurityEventConnection{}, err } if err := tx.Commit(ctx); err != nil { return SecurityEventConnection{}, err } return s.SecurityEventConnection(ctx) } func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) { tx, err := s.pool.Begin(ctx) if err != nil { return SecurityEventConnection{}, err } defer tx.Rollback(ctx) var oldReference string if err := tx.QueryRow(ctx, `SELECT credential_ref FROM gateway_security_event_connections WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating' FOR UPDATE`, connectionID, nextReference).Scan(&oldReference); errors.Is(err, pgx.ErrNoRows) { return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } else if err != nil { return SecurityEventConnection{}, err } tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL, version=version+1,updated_at=now() WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating'`, connectionID, nextReference) if err != nil { return SecurityEventConnection{}, err } if tag.RowsAffected() == 0 { return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } if oldReference != nextReference { if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference, time.Now().UTC()); err != nil { return SecurityEventConnection{}, err } } if err := tx.Commit(ctx); err != nil { return SecurityEventConnection{}, err } return s.SecurityEventConnection(ctx) } // DiscardPreparedSecurityEventConnection atomically retires the local Secrets // and removes only the exact, still-unbound connection generation observed by // the caller. A concurrent Bind or credential update changes the version and // therefore preserves both the connection and its Secrets. func (s *Store) DiscardPreparedSecurityEventConnection(ctx context.Context, connectionID, ownerKey string, expectedVersion int64) error { if _, err := uuid.Parse(connectionID); err != nil || ownerKey == "" || expectedVersion <= 0 { return ErrSecurityEventConnectionConflict } tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var credentialReference string var nextReference, managementReference sql.NullString err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref FROM gateway_security_event_connections WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL FOR UPDATE`, connectionID, ownerKey, expectedVersion).Scan( &credentialReference, &nextReference, &managementReference, ) if errors.Is(err, pgx.ErrNoRows) { return ErrSecurityEventConnectionConflict } if err != nil { return err } references := map[string]struct{}{credentialReference: {}} if nextReference.Valid { references[nextReference.String] = struct{}{} } if managementReference.Valid { references[managementReference.String] = struct{}{} } now := time.Now().UTC() for reference := range references { if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil { return err } } tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL`, connectionID, ownerKey, expectedVersion) if err != nil { return err } if tag.RowsAffected() != 1 { return ErrSecurityEventConnectionConflict } return tx.Commit(ctx) } // FinalizeRetiringSecurityEventConnection atomically transfers every Secret // reference owned by the exact retiring generation to the durable cleanup // queue before removing that generation. The SecretStore worker can therefore // resume after a crash without the database losing its last cleanup intent. func (s *Store) FinalizeRetiringSecurityEventConnection(ctx context.Context, connectionID string, expectedVersion int64) error { if _, err := uuid.Parse(connectionID); err != nil || expectedVersion <= 0 { return ErrSecurityEventConnectionConflict } tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var credentialReference string var nextReference, managementReference sql.NullString err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref FROM gateway_security_event_connections WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2 FOR UPDATE`, connectionID, expectedVersion).Scan( &credentialReference, &nextReference, &managementReference, ) if errors.Is(err, pgx.ErrNoRows) { return ErrSecurityEventConnectionConflict } if err != nil { return err } references := map[string]struct{}{credentialReference: {}} if nextReference.Valid { references[nextReference.String] = struct{}{} } if managementReference.Valid { references[managementReference.String] = struct{}{} } now := time.Now().UTC() for reference := range references { if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil { return err } } tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2`, connectionID, expectedVersion) if err != nil { return err } if tag.RowsAffected() != 1 { return ErrSecurityEventConnectionConflict } return tx.Commit(ctx) }