修复 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。
408 lines
17 KiB
Go
408 lines
17 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const identityPairingColumns = `
|
|
id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,cleanup_status,remote_version,expires_at,version,
|
|
COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),cancelled_at,cleanup_completed_at,
|
|
created_at,updated_at`
|
|
|
|
func (s *Store) ReserveIdentityPairingStart(ctx context.Context, attemptID string, expiresAt time.Time) error {
|
|
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation
|
|
WHERE state='starting' AND expires_at <= now()`); err != nil {
|
|
return err
|
|
}
|
|
var activeConfigurationExists bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(
|
|
SELECT 1 FROM gateway_identity_configuration_revisions
|
|
WHERE state='active'
|
|
)`).Scan(&activeConfigurationExists); err != nil {
|
|
return err
|
|
}
|
|
if activeConfigurationExists {
|
|
return identity.ErrActiveConfigurationHandoffRequired
|
|
}
|
|
var blocked bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(
|
|
SELECT 1 FROM gateway_identity_configuration_revisions revision
|
|
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
|
|
WHERE revision.state IN ('draft','validated','failed')
|
|
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
|
|
)`).Scan(&blocked); err != nil {
|
|
return err
|
|
}
|
|
if blocked {
|
|
return identity.ErrPairingInProgress
|
|
}
|
|
if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,expires_at)
|
|
VALUES(true,$1::uuid,'starting',$2)`, attemptID, expiresAt); err != nil {
|
|
if isUniqueViolation(err) {
|
|
return identity.ErrPairingInProgress
|
|
}
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (s *Store) ReleaseIdentityPairingStart(ctx context.Context, attemptID string) error {
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation
|
|
WHERE attempt_id=$1::uuid AND state='starting'`, attemptID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) CommitIdentityPairingStart(ctx context.Context, revision identity.Revision, exchange identity.PairingExchange) (identity.PairingExchange, error) {
|
|
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
var reserved bool
|
|
if err := tx.QueryRow(ctx, `SELECT true FROM gateway_identity_pairing_start_reservation
|
|
WHERE singleton=true AND attempt_id=$1::uuid AND state='starting' FOR UPDATE`, exchange.ID).Scan(&reserved); errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrPairingInProgress
|
|
} else if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if !reserved {
|
|
return identity.PairingExchange{}, identity.ErrPairingInProgress
|
|
}
|
|
var activeConfigurationExists bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(
|
|
SELECT 1 FROM gateway_identity_configuration_revisions WHERE state='active'
|
|
)`).Scan(&activeConfigurationExists); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if activeConfigurationExists {
|
|
return identity.PairingExchange{}, identity.ErrActiveConfigurationHandoffRequired
|
|
}
|
|
if err := adoptPendingIdentitySecrets(ctx, tx, exchange.ExchangeTokenRef); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
scopes, _ := json.Marshal(revision.Scopes)
|
|
capabilities, _ := json.Marshal(revision.Capabilities)
|
|
if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
|
|
id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url,
|
|
jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds,last_trace_id
|
|
) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15,NULLIF($16,''))`,
|
|
revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix,
|
|
revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled,
|
|
string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds,
|
|
revision.SessionRefreshSeconds, revision.LastTraceID,
|
|
); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
created, err := scanIdentityPairing(tx.QueryRow(ctx, `INSERT INTO gateway_identity_onboarding_exchanges (
|
|
id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id
|
|
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,''))
|
|
RETURNING `+identityPairingColumns,
|
|
exchange.ID, revision.ID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef,
|
|
exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID,
|
|
))
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
tag, err := tx.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation
|
|
SET state='paired',revision_id=$2::uuid,expires_at=$3,updated_at=now()
|
|
WHERE attempt_id=$1::uuid AND state='starting'`, exchange.ID, revision.ID, exchange.ExpiresAt)
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if tag.RowsAffected() != 1 {
|
|
return identity.PairingExchange{}, identity.ErrPairingInProgress
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (s *Store) IdentityPairingStartBlocked(ctx context.Context) (bool, error) {
|
|
var blocked bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM gateway_identity_configuration_revisions revision
|
|
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
|
|
WHERE revision.state IN ('draft','validated','failed')
|
|
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
|
|
) OR EXISTS (
|
|
SELECT 1 FROM gateway_identity_pairing_start_reservation
|
|
WHERE state='paired' OR expires_at > now()
|
|
)`).Scan(&blocked)
|
|
return blocked, err
|
|
}
|
|
|
|
func (s *Store) CreateIdentityPairingExchange(ctx context.Context, exchange identity.PairingExchange) (identity.PairingExchange, error) {
|
|
return scanIdentityPairing(s.pool.QueryRow(ctx, `
|
|
INSERT INTO gateway_identity_onboarding_exchanges (
|
|
id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id
|
|
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,''))
|
|
RETURNING `+identityPairingColumns,
|
|
exchange.ID, exchange.RevisionID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef,
|
|
exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID,
|
|
))
|
|
}
|
|
|
|
func (s *Store) IdentityPairingExchange(ctx context.Context, id string) (identity.PairingExchange, error) {
|
|
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+`
|
|
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionNotFound
|
|
}
|
|
return exchange, err
|
|
}
|
|
|
|
func (s *Store) IdentityPairingExchangeForRevision(ctx context.Context, revisionID string) (identity.PairingExchange, error) {
|
|
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+`
|
|
FROM gateway_identity_onboarding_exchanges WHERE revision_id=$1::uuid`, revisionID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionNotFound
|
|
}
|
|
return exchange, err
|
|
}
|
|
|
|
func (s *Store) PendingIdentityPairingExchanges(ctx context.Context) ([]identity.PairingExchange, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
|
|
FROM gateway_identity_onboarding_exchanges exchange
|
|
WHERE exchange.id=(
|
|
SELECT candidate_exchange.id
|
|
FROM gateway_identity_onboarding_exchanges candidate_exchange
|
|
JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id
|
|
WHERE revision.state IN ('draft','validated','failed')
|
|
ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC,
|
|
revision.created_at DESC,candidate_exchange.created_at DESC
|
|
LIMIT 1
|
|
)
|
|
AND exchange.status IN ('metadata_pending','preparing','ready','credentials_saved')`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
exchanges := make([]identity.PairingExchange, 0)
|
|
for rows.Next() {
|
|
exchange, scanErr := scanIdentityPairing(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
exchanges = append(exchanges, exchange)
|
|
}
|
|
return exchanges, rows.Err()
|
|
}
|
|
|
|
func (s *Store) PendingIdentityPairingCleanups(ctx context.Context) ([]identity.PairingExchange, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
|
|
FROM gateway_identity_onboarding_exchanges
|
|
WHERE status='cancelled' AND cleanup_status='pending' ORDER BY updated_at`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
exchanges := make([]identity.PairingExchange, 0)
|
|
for rows.Next() {
|
|
exchange, scanErr := scanIdentityPairing(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
exchanges = append(exchanges, exchange)
|
|
}
|
|
return exchanges, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CompletedIdentityPairingsAwaitingActivation(ctx context.Context) ([]identity.PairingExchange, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
|
|
FROM gateway_identity_onboarding_exchanges exchange
|
|
WHERE exchange.id=(
|
|
SELECT candidate_exchange.id
|
|
FROM gateway_identity_onboarding_exchanges candidate_exchange
|
|
JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id
|
|
WHERE revision.state IN ('draft','validated','failed')
|
|
ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC,
|
|
revision.created_at DESC,candidate_exchange.created_at DESC
|
|
LIMIT 1
|
|
)
|
|
AND exchange.status='completed' AND EXISTS (
|
|
SELECT 1 FROM gateway_identity_configuration_revisions revision
|
|
WHERE revision.id=exchange.revision_id AND revision.state IN ('draft','validated')
|
|
)`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
exchanges := make([]identity.PairingExchange, 0)
|
|
for rows.Next() {
|
|
exchange, scanErr := scanIdentityPairing(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
exchanges = append(exchanges, exchange)
|
|
}
|
|
return exchanges, rows.Err()
|
|
}
|
|
|
|
func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `
|
|
UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''),
|
|
auth_center_audit_id=COALESCE(NULLIF($6,''),auth_center_audit_id),version=version+1,updated_at=now()
|
|
WHERE id=$1::uuid AND version=$2
|
|
RETURNING `+identityPairingColumns,
|
|
id, expectedVersion, update.Status, update.RemoteVersion, update.LastErrorCategory, update.AuthCenterAuditID,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionConflict
|
|
}
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if update.Status == identity.PairingCompleted || update.Status == identity.PairingFailed || update.Status == identity.PairingExpired {
|
|
if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
return exchange, nil
|
|
}
|
|
|
|
func (s *Store) RecordIdentityPairingRetryFailure(ctx context.Context, id string, status identity.PairingStatus, category string) (identity.PairingExchange, error) {
|
|
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `
|
|
UPDATE gateway_identity_onboarding_exchanges SET last_error_category=$3,updated_at=now()
|
|
WHERE id=$1::uuid AND status=$2 AND cleanup_status='none'
|
|
RETURNING `+identityPairingColumns, id, status, category))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionConflict
|
|
}
|
|
return exchange, err
|
|
}
|
|
|
|
func (s *Store) CancelIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.PairingExchange, error) {
|
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `SELECT `+identityPairingColumns+`
|
|
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid FOR UPDATE`, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionNotFound
|
|
}
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if exchange.Status == identity.PairingCancelled {
|
|
return exchange, tx.Commit(ctx)
|
|
}
|
|
if exchange.Version != expectedVersion {
|
|
return identity.PairingExchange{}, identity.ErrRevisionConflict
|
|
}
|
|
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
|
|
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, exchange.RevisionID))
|
|
if err != nil {
|
|
return identity.PairingExchange{}, normalizeIdentityRevisionError(err)
|
|
}
|
|
if revision.State != identity.RevisionDraft && revision.State != identity.RevisionValidated && revision.State != identity.RevisionFailed {
|
|
return identity.PairingExchange{}, identity.ErrPairingNotCancellable
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='failed',
|
|
last_error_category='pairing_cancelled',last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),
|
|
version=version+1,updated_at=now() WHERE id=$1::uuid`, exchange.RevisionID, traceID, auditID); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
exchange, err = scanIdentityPairing(tx.QueryRow(ctx, `
|
|
UPDATE gateway_identity_onboarding_exchanges SET status='cancelled',cleanup_status='pending',cancelled_at=now(),
|
|
cleanup_completed_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2
|
|
RETURNING `+identityPairingColumns, id, expectedVersion))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionConflict
|
|
}
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
return exchange, nil
|
|
}
|
|
|
|
func (s *Store) CompleteIdentityPairingCleanup(ctx context.Context, id string, expectedVersion int64) (identity.PairingExchange, error) {
|
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
var revisionID string
|
|
if err := tx.QueryRow(ctx, `SELECT revision_id::text FROM gateway_identity_onboarding_exchanges
|
|
WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending' FOR UPDATE`, id, expectedVersion).Scan(&revisionID); errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionConflict
|
|
} else if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
tag, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET machine_credential_ref=NULL,
|
|
session_encryption_key_ref=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND state='failed'`, revisionID)
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if tag.RowsAffected() != 1 {
|
|
return identity.PairingExchange{}, identity.ErrRevisionConflict
|
|
}
|
|
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
|
SET cleanup_status='completed',cleanup_completed_at=now(),version=version+1,updated_at=now()
|
|
WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending'
|
|
RETURNING `+identityPairingColumns, id, expectedVersion))
|
|
if err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, revisionID); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
return exchange, nil
|
|
}
|
|
|
|
func (s *Store) RecordIdentityPairingCleanupFailure(ctx context.Context, id, category string) (identity.PairingExchange, error) {
|
|
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
|
SET last_error_category=$2,updated_at=now() WHERE id=$1::uuid AND status='cancelled' AND cleanup_status='pending'
|
|
RETURNING `+identityPairingColumns, id, category))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return identity.PairingExchange{}, identity.ErrRevisionConflict
|
|
}
|
|
return exchange, err
|
|
}
|
|
|
|
func scanIdentityPairing(row scanner) (identity.PairingExchange, error) {
|
|
var exchange identity.PairingExchange
|
|
var status string
|
|
if err := row.Scan(
|
|
&exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status, &exchange.CleanupStatus,
|
|
&exchange.RemoteVersion, &exchange.ExpiresAt, &exchange.Version, &exchange.LastErrorCategory,
|
|
&exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CancelledAt, &exchange.CleanupCompletedAt,
|
|
&exchange.CreatedAt, &exchange.UpdatedAt,
|
|
); err != nil {
|
|
return identity.PairingExchange{}, err
|
|
}
|
|
exchange.Status = identity.PairingStatus(status)
|
|
return exchange, nil
|
|
}
|