package store import ( "context" "encoding/json" "errors" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/jackc/pgx/v5" ) var ErrIdentityManagementRequestNotFound = errors.New("identity management request not found") type IdentityManagementRequest struct { Operation string Key string RequestHash string Response []byte CreatedAt time.Time } const identityRevisionColumns = ` id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''), COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix, local_tenant_key,public_base_url,web_base_url,jit_enabled,legacy_jwt_enabled,token_introspection,session_revocation, COALESCE(security_event_issuer,''),COALESCE(security_event_configuration_url,''),COALESCE(security_event_audience,''), COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),session_idle_seconds,session_absolute_seconds, 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) return scanIdentityRevision(s.pool.QueryRow(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 ) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15) RETURNING `+identityRevisionColumns, 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, )) } func (s *Store) IdentityConfigurationRevision(ctx context.Context, id string) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` FROM gateway_identity_configuration_revisions WHERE id=$1::uuid`, id)) return revision, normalizeIdentityRevisionError(err) } func (s *Store) ActiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` FROM gateway_identity_configuration_revisions WHERE state='active'`)) return revision, normalizeIdentityRevisionError(err) } 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 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) } func (s *Store) LatestSupersededIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` FROM gateway_identity_configuration_revisions WHERE state='superseded' ORDER BY superseded_at DESC NULLS LAST LIMIT 1`)) return revision, normalizeIdentityRevisionError(err) } func (s *Store) UpdateIdentityRevisionPolicy(ctx context.Context, id string, expectedVersion int64, policy identity.RevisionPolicy) (identity.Revision, error) { if err := policy.Validate(); err != nil { return identity.Revision{}, err } revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` UPDATE gateway_identity_configuration_revisions SET local_tenant_key=$3,role_prefix=$4,jit_enabled=$5, legacy_jwt_enabled=$6,session_idle_seconds=$7,session_absolute_seconds=$8,session_refresh_seconds=$9, version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='draft' RETURNING `+identityRevisionColumns, id, expectedVersion, policy.LocalTenantKey, policy.RolePrefix, policy.JITEnabled, policy.LegacyJWTEnabled, policy.SessionIdleSeconds, policy.SessionAbsoluteSeconds, policy.SessionRefreshSeconds)) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } return revision, err } func (s *Store) IdentityManagementRequest(ctx context.Context, operation, key string) (IdentityManagementRequest, error) { var request IdentityManagementRequest err := s.pool.QueryRow(ctx, `SELECT operation,idempotency_key,request_hash,response,created_at FROM gateway_identity_management_requests WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan( &request.Operation, &request.Key, &request.RequestHash, &request.Response, &request.CreatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return IdentityManagementRequest{}, ErrIdentityManagementRequestNotFound } return request, err } func (s *Store) RecordIdentityManagementRequest(ctx context.Context, request IdentityManagementRequest) error { if !json.Valid(request.Response) { return errors.New("identity management response is invalid") } _, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_management_requests(operation,idempotency_key,request_hash,response) VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, request.Operation, request.Key, request.RequestHash, request.Response) return err } func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) { 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 } updated, err := identity.ApplyManifest(current, applied) 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(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, security_event_issuer=NULLIF($13,''),security_event_configuration_url=NULLIF($14,''),security_event_audience=NULLIF($15,''), machine_credential_ref=NULLIF($16,''),session_encryption_key_ref=NULLIF($17,''),last_trace_id=NULLIF($18,''), last_audit_id=NULLIF($19,''),last_error_category=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='draft' RETURNING `+identityRevisionColumns, id, expectedVersion, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience, updated.BrowserClientID, updated.MachineClientID, string(scopes), string(capabilities), updated.TokenIntrospection, updated.SessionRevocation, updated.SecurityEventIssuer, updated.SecurityEventConfigURL, updated.SecurityEventAudience, updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID, )) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } 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='draft' RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } return revision, err } func (s *Store) RevalidateActiveIdentityRevision(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 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='active' RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } return revision, err } func (s *Store) MarkIdentityRevisionFailed(ctx context.Context, id string, expectedVersion int64, category, traceID, auditID string) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` UPDATE gateway_identity_configuration_revisions SET state='failed',last_error_category=NULLIF($3,''), last_trace_id=NULLIF($4,''),last_audit_id=NULLIF($5,''),version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state IN ('draft','validated') RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, auditID)) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } return revision, err } func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, bool, error) { 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 { return identity.Revision{}, false, identity.ErrBreakGlassRequired } var tenantExists bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=( SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$1::uuid) AND status='active')`, id).Scan(&tenantExists); err != nil { return identity.Revision{}, false, err } if !tenantExists { return identity.Revision{}, false, identity.ErrLocalTenantInvalid } var previousID, previousIssuer, previousTenant, previousAudience, previousClient, previousSessionRef string 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, ); 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 WHERE id=$1::uuid AND version=$2 AND state='validated' FOR UPDATE`, id, expectedVersion).Scan( &nextIssuer, &nextTenant, &nextAudience, &nextClient, &nextSessionRef, ); errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, false, identity.ErrRevisionConflict } else if err != nil { return identity.Revision{}, false, err } if previousID != "" { if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',superseded_at=now(), version=version+1,updated_at=now() WHERE id=$1::uuid AND state='active'`, previousID); err != nil { return identity.Revision{}, false, err } } revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active', activated_at=now(),superseded_at=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='validated' RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) if err != nil { return identity.Revision{}, false, err } identityChanged := previousID != "" && (previousIssuer != nextIssuer || previousTenant != nextTenant || previousAudience != nextAudience || previousClient != nextClient || previousSessionRef != nextSessionRef) if identityChanged { if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil { 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 } return revision, identityChanged, nil } func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { tx, err := s.beginIdentityConfigurationLifecycleTx(ctx) if err != nil { return identity.Revision{}, err } defer tx.Rollback(ctx) if ok, err := hasBreakGlassManager(ctx, tx); err != nil { return identity.Revision{}, err } 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(),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 } if err != nil { return identity.Revision{}, err } if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil { return identity.Revision{}, err } if err := tx.Commit(ctx); err != nil { return identity.Revision{}, err } return revision, nil } func (s *Store) HasBreakGlassManager(ctx context.Context) (bool, error) { return hasBreakGlassManager(ctx, s.pool) } func (s *Store) HasActiveTenantKey(ctx context.Context, tenantKey string) (bool, error) { var exists bool err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=$1 AND status='active')`, tenantKey).Scan(&exists) return exists, err } func hasBreakGlassManager(ctx context.Context, query interface { QueryRow(context.Context, string, ...any) pgx.Row }) (bool, error) { var exists bool err := query.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_users WHERE source='gateway' AND status='active' AND deleted_at IS NULL AND password_hash IS NOT NULL AND password_hash <> '' AND (roles ? 'manager' OR roles ? 'admin'))`).Scan(&exists) return exists, err } func scanIdentityRevision(row scanner) (identity.Revision, error) { var revision identity.Revision var state string var scopes, capabilities []byte if err := row.Scan( &revision.ID, &state, &revision.SchemaVersion, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID, &revision.ApplicationID, &revision.Audience, &revision.BrowserClientID, &revision.MachineClientID, &scopes, &capabilities, &revision.RolePrefix, &revision.LocalTenantKey, &revision.PublicBaseURL, &revision.WebBaseURL, &revision.JITEnabled, &revision.LegacyJWTEnabled, &revision.TokenIntrospection, &revision.SessionRevocation, &revision.SecurityEventIssuer, &revision.SecurityEventConfigURL, &revision.SecurityEventAudience, &revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds, &revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory, &revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt, &revision.CreatedAt, &revision.UpdatedAt, ); err != nil { return identity.Revision{}, err } revision.State = identity.RevisionState(state) _ = json.Unmarshal(scopes, &revision.Scopes) _ = json.Unmarshal(capabilities, &revision.Capabilities) if revision.Scopes == nil { revision.Scopes = []string{} } if revision.Capabilities == nil { revision.Capabilities = []string{} } return revision, nil } func normalizeIdentityRevisionError(err error) error { if errors.Is(err, pgx.ErrNoRows) { return identity.ErrRevisionNotFound } return err }