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:
@@ -29,6 +29,55 @@ COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),sess
|
||||
session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''),
|
||||
validated_at,activated_at,superseded_at,created_at,updated_at`
|
||||
|
||||
// identityConfigurationLifecycleLockID serializes the database-side boundary
|
||||
// between onboarding reservations and Active Revision mutations. A transaction
|
||||
// must hold this lock before deciding that no Active configuration or foreign
|
||||
// pairing reservation exists.
|
||||
const identityConfigurationLifecycleLockID int64 = 0x4541494944454e54
|
||||
|
||||
// identityDisabledSecretCleanupDelay is intentionally longer than the
|
||||
// IdentityRuntimeManager's 30-second old-Runtime retirement window. Starting
|
||||
// cleanup one minute after the database handoff leaves an additional 30
|
||||
// seconds for commit, Runtime publication, timer scheduling, and worker jitter.
|
||||
const identityDisabledSecretCleanupDelay = time.Minute
|
||||
|
||||
func lockIdentityConfigurationLifecycle(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, identityConfigurationLifecycleLockID)
|
||||
return err
|
||||
}
|
||||
|
||||
// beginIdentityConfigurationLifecycleTx deliberately uses READ COMMITTED.
|
||||
// PostgreSQL fixes the transaction snapshot when a SERIALIZABLE transaction
|
||||
// executes the advisory-lock SELECT, before that statement finishes waiting.
|
||||
// A later state check can therefore miss the preceding lock holder's commit.
|
||||
// READ COMMITTED gives every statement after lock acquisition a fresh snapshot,
|
||||
// while the transaction-scoped advisory lock serializes all lifecycle decisions.
|
||||
func (s *Store) beginIdentityConfigurationLifecycleTx(ctx context.Context) (pgx.Tx, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := lockIdentityConfigurationLifecycle(ctx, tx); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return nil, err
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func queueDisabledIdentitySecretCleanupTx(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=GREATEST(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) CreateIdentityConfigurationRevision(ctx context.Context, revision identity.Revision) (identity.Revision, error) {
|
||||
scopes, _ := json.Marshal(revision.Scopes)
|
||||
capabilities, _ := json.Marshal(revision.Capabilities)
|
||||
@@ -58,7 +107,16 @@ FROM gateway_identity_configuration_revisions WHERE state='active'`))
|
||||
|
||||
func (s *Store) LatestInactiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
|
||||
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
|
||||
FROM gateway_identity_configuration_revisions WHERE state IN ('draft','validated','failed') ORDER BY created_at DESC LIMIT 1`))
|
||||
FROM gateway_identity_configuration_revisions
|
||||
WHERE id=(
|
||||
SELECT revision.id
|
||||
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')
|
||||
ORDER BY NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') DESC,
|
||||
revision.created_at DESC,exchange.created_at DESC
|
||||
LIMIT 1
|
||||
)`))
|
||||
return revision, normalizeIdentityRevisionError(err)
|
||||
}
|
||||
|
||||
@@ -108,10 +166,16 @@ VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`,
|
||||
}
|
||||
|
||||
func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) {
|
||||
current, err := s.IdentityConfigurationRevision(ctx, id)
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
current, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
|
||||
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, id))
|
||||
if err != nil {
|
||||
return identity.Revision{}, normalizeIdentityRevisionError(err)
|
||||
}
|
||||
if current.Version != expectedVersion {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
@@ -119,9 +183,20 @@ func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVe
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
newReferences := make([]string, 0, 2)
|
||||
for _, reference := range []string{updated.MachineCredentialRef, updated.SessionEncryptionKeyRef} {
|
||||
if reference != "" {
|
||||
newReferences = append(newReferences, reference)
|
||||
}
|
||||
}
|
||||
if len(newReferences) > 0 {
|
||||
if err := adoptPendingIdentitySecrets(ctx, tx, newReferences...); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
}
|
||||
scopes, _ := json.Marshal(updated.Scopes)
|
||||
capabilities, _ := json.Marshal(updated.Capabilities)
|
||||
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
|
||||
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `
|
||||
UPDATE gateway_identity_configuration_revisions SET
|
||||
issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''),
|
||||
scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12,
|
||||
@@ -138,14 +213,35 @@ RETURNING `+identityRevisionColumns,
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
return revision, err
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
activeReferences := map[string]struct{}{}
|
||||
for _, reference := range newReferences {
|
||||
activeReferences[reference] = struct{}{}
|
||||
}
|
||||
for _, reference := range []string{current.MachineCredentialRef, current.SessionEncryptionKeyRef} {
|
||||
if reference == "" {
|
||||
continue
|
||||
}
|
||||
if _, stillActive := activeReferences[reference]; stillActive {
|
||||
continue
|
||||
}
|
||||
if err := queueIdentitySecretCleanupTx(ctx, tx, reference, time.Now().UTC()); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkIdentityRevisionValidated(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
|
||||
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
|
||||
UPDATE gateway_identity_configuration_revisions SET state='validated',validated_at=now(),last_error_category=NULL,
|
||||
last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
|
||||
WHERE id=$1::uuid AND version=$2 AND state IN ('draft','superseded')
|
||||
WHERE id=$1::uuid AND version=$2 AND state='draft'
|
||||
RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
@@ -178,11 +274,22 @@ RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, aud
|
||||
}
|
||||
|
||||
func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, bool, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
|
||||
if err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var foreignPairingReservation bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM gateway_identity_pairing_start_reservation
|
||||
WHERE (state='starting' AND expires_at > now())
|
||||
OR (state='paired' AND revision_id IS DISTINCT FROM $1::uuid)
|
||||
)`, id).Scan(&foreignPairingReservation); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
if foreignPairingReservation {
|
||||
return identity.Revision{}, false, identity.ErrPairingInProgress
|
||||
}
|
||||
if ok, err := hasBreakGlassManager(ctx, tx); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
} else if !ok {
|
||||
@@ -197,9 +304,16 @@ SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$
|
||||
return identity.Revision{}, false, identity.ErrLocalTenantInvalid
|
||||
}
|
||||
var previousID, previousIssuer, previousTenant, previousAudience, previousClient, previousSessionRef string
|
||||
_ = tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
|
||||
COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions
|
||||
WHERE state='active' FOR UPDATE`).Scan(&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef)
|
||||
WHERE state='active' FOR UPDATE`).Scan(
|
||||
&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef,
|
||||
); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
if previousID != "" && previousID != id {
|
||||
return identity.Revision{}, false, identity.ErrActiveConfigurationHandoffRequired
|
||||
}
|
||||
var nextIssuer, nextTenant, nextAudience, nextClient, nextSessionRef string
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
|
||||
COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions
|
||||
@@ -228,6 +342,9 @@ WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevis
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, id); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
@@ -235,7 +352,7 @@ WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevis
|
||||
}
|
||||
|
||||
func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
@@ -245,9 +362,33 @@ func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersi
|
||||
} else if !ok {
|
||||
return identity.Revision{}, identity.ErrBreakGlassRequired
|
||||
}
|
||||
var activeID, machineReference, sessionReference string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,'')
|
||||
FROM gateway_identity_configuration_revisions
|
||||
WHERE state='active' AND version=$1 FOR UPDATE`, expectedVersion).Scan(
|
||||
&activeID, &machineReference, &sessionReference,
|
||||
); errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
} else if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
cleanupNotBefore := time.Now().UTC().Add(identityDisabledSecretCleanupDelay)
|
||||
references := make(map[string]struct{}, 2)
|
||||
for _, reference := range []string{machineReference, sessionReference} {
|
||||
if reference != "" {
|
||||
references[reference] = struct{}{}
|
||||
}
|
||||
}
|
||||
for reference := range references {
|
||||
if err := queueDisabledIdentitySecretCleanupTx(ctx, tx, reference, cleanupNotBefore); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
}
|
||||
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',
|
||||
superseded_at=now(),last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),version=version+1,updated_at=now()
|
||||
WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion, traceID, auditID))
|
||||
superseded_at=now(),machine_credential_ref=NULL,session_encryption_key_ref=NULL,
|
||||
last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
|
||||
WHERE id=$1::uuid AND state='active' AND version=$2
|
||||
RETURNING `+identityRevisionColumns, activeID, expectedVersion, traceID, auditID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user