feat(identity): 合并标准统一认证接入与安全事件撤销 #7

Merged
chengcheng merged 21 commits from codex/merge-identity-main into main 2026-07-17 19:36:25 +08:00
7 changed files with 578 additions and 3 deletions
Showing only changes of commit 96bbd3a2f6 - Show all commits

View File

@ -63,3 +63,25 @@ func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleA
}
}
}
func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0068_identity_onboarding_exchanges.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"create table if not exists gateway_identity_onboarding_exchanges",
"exchange_token_ref text not null",
"security_event_configuration_url text",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity onboarding migration is missing %q", required)
}
}
for _, forbidden := range []string{"exchange_token text", "onboarding_code", "client_secret", "machine_secret"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity onboarding migration stores forbidden secret field %q", forbidden)
}
}
}

View File

@ -48,6 +48,9 @@ type Revision struct {
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
TokenIntrospection bool `json:"tokenIntrospection"`
SessionRevocation bool `json:"sessionRevocation"`
SecurityEventIssuer string `json:"securityEventIssuer,omitempty"`
SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"`
SecurityEventAudience string `json:"securityEventAudience,omitempty"`
MachineCredentialRef string `json:"-"`
SessionEncryptionKeyRef string `json:"-"`
SessionIdleSeconds int `json:"sessionIdleSeconds"`
@ -136,6 +139,11 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro
}
revision.TokenIntrospection = capabilities["token_introspection"]
revision.SessionRevocation = capabilities["session_revocation"]
if input.Manifest.SecurityEvents != nil {
revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer
revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint
revision.SecurityEventAudience = input.Manifest.SecurityEvents.Audience
}
revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef)
revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef)
revision.LastTraceID = strings.TrimSpace(input.TraceID)

View File

@ -0,0 +1,301 @@
package identity
import (
"context"
"crypto/rand"
"errors"
"strings"
"time"
"github.com/google/uuid"
)
type PairingStatus string
const (
PairingMetadataPending PairingStatus = "metadata_pending"
PairingPreparing PairingStatus = "preparing"
PairingReady PairingStatus = "ready"
PairingCredentialsSaved PairingStatus = "credentials_saved"
PairingCompleted PairingStatus = "completed"
PairingFailed PairingStatus = "failed"
PairingExpired PairingStatus = "expired"
)
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"`
}
type PairingExchangeUpdate struct {
Status PairingStatus
RemoteVersion int64
LastErrorCategory string
AuthCenterAuditID string
}
type PairingRepository interface {
CreateIdentityConfigurationRevision(context.Context, Revision) (Revision, 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)
}
type PairingSecretStore interface {
Put(context.Context, string, []byte) error
Get(context.Context, string) ([]byte, error)
Delete(context.Context, string) error
}
type OnboardingRemote interface {
Claim(context.Context, string) (ClaimedExchange, error)
SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error)
Get(context.Context, string, string) (Exchange, error)
DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error)
Complete(context.Context, string, string, string, int64) error
}
type SecurityEventPreparer interface {
PrepareSecurityEvents(context.Context, Revision, []byte) error
}
type PairingService struct {
repository PairingRepository
secrets PairingSecretStore
remote func(string) (OnboardingRemote, error)
security SecurityEventPreparer
}
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 (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) {
draft, err := NewDraft(input)
if err != nil {
return PairingExchange{}, err
}
draft.LastTraceID = strings.TrimSpace(traceID)
draft, err = service.repository.CreateIdentityConfigurationRevision(ctx, draft)
if err != nil {
return PairingExchange{}, err
}
remote, err := service.remote(draft.AuthCenterURL)
if err != nil {
return PairingExchange{}, err
}
claimed, err := remote.Claim(ctx, strings.TrimSpace(input.OnboardingCode))
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 {
return PairingExchange{}, err
}
claimed.ExchangeToken = ""
pairing := PairingExchange{
ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference,
Status: PairingMetadataPending, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
Version: 1, LastTraceID: strings.TrimSpace(traceID),
}
pairing, err = service.repository.CreateIdentityPairingExchange(ctx, pairing)
if err != nil {
_ = service.secrets.Delete(ctx, tokenReference)
return PairingExchange{}, err
}
return pairing, nil
}
func (service *PairingService) Continue(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 {
return pairing, nil
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return PairingExchange{}, err
}
token, err := service.secrets.Get(ctx, pairing.ExchangeTokenRef)
if err != nil {
return PairingExchange{}, err
}
remote, err := service.remote(revision.AuthCenterURL)
if err != nil {
clear(token)
return PairingExchange{}, err
}
switch pairing.Status {
case PairingMetadataPending:
metadata, metadataErr := PairingInput{
AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL,
WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey,
}.ConsumerMetadata(true)
if metadataErr != nil {
clear(token)
return PairingExchange{}, metadataErr
}
view, submitErr := remote.SubmitMetadata(ctx, ClaimedExchange{
ExchangeID: pairing.RemoteExchangeID, ExchangeToken: string(token), ExpiresAt: pairing.ExpiresAt, Version: pairing.RemoteVersion,
}, metadata, "gateway-pairing-metadata-"+pairing.ID)
clear(token)
if submitErr != nil {
return PairingExchange{}, submitErr
}
if _, err := service.updateFromRemote(ctx, pairing, view); err != nil {
return PairingExchange{}, err
}
case PairingPreparing:
view, getErr := remote.Get(ctx, pairing.RemoteExchangeID, string(token))
clear(token)
if getErr != nil {
return PairingExchange{}, getErr
}
updated, err := service.updateFromRemote(ctx, pairing, view)
if err != nil {
return PairingExchange{}, err
}
if updated.Status == PairingPreparing {
return updated, nil
}
case PairingReady:
delivery, deliveryErr := remote.DeliverCredential(ctx, Exchange{
ExchangeID: pairing.RemoteExchangeID, ApplicationID: revision.ApplicationID,
Status: ExchangeReady, Version: pairing.RemoteVersion, ExpiresAt: pairing.ExpiresAt,
}, string(token), "gateway-pairing-credential-"+uuid.NewString())
clear(token)
if deliveryErr != nil {
return PairingExchange{}, deliveryErr
}
updatedRevision, saveErr := service.saveDelivery(ctx, revision, pairing, delivery)
if saveErr != nil {
return PairingExchange{}, saveErr
}
_ = updatedRevision
if _, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCredentialsSaved, RemoteVersion: delivery.Version,
}); err != nil {
return PairingExchange{}, err
}
case PairingCredentialsSaved:
if revision.SessionRevocation {
if service.security == nil {
clear(token)
return PairingExchange{}, errors.New("security event preparation is unavailable")
}
secret, secretErr := service.secrets.Get(ctx, revision.MachineCredentialRef)
if secretErr != nil {
clear(token)
return PairingExchange{}, secretErr
}
prepareErr := service.security.PrepareSecurityEvents(ctx, revision, secret)
clear(secret)
if prepareErr != nil {
clear(token)
return PairingExchange{}, prepareErr
}
}
completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion)
clear(token)
if completeErr != nil {
return PairingExchange{}, completeErr
}
completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion,
})
if err != nil {
return PairingExchange{}, err
}
_ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef)
return completed, nil
default:
clear(token)
return PairingExchange{}, ErrRevisionConflict
}
}
return service.repository.IdentityPairingExchange(ctx, pairingID)
}
func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) {
if err := delivery.Manifest.Validate(); err != nil {
return Revision{}, err
}
machineReference := ""
if delivery.Manifest.Clients.MachineToMachine != nil {
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
secret := []byte(delivery.MachineCredential.ClientSecret)
delivery.MachineCredential.ClientSecret = ""
if err := service.secrets.Put(ctx, machineReference, secret); err != nil {
clear(secret)
return Revision{}, err
}
clear(secret)
}
sessionReference := ""
if delivery.Manifest.Clients.BrowserLogin != nil {
sessionReference = "identity-session-" + revision.ID
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
_ = service.secrets.Delete(ctx, machineReference)
return Revision{}, err
}
if err := service.secrets.Put(ctx, sessionReference, key); err != nil {
clear(key)
_ = service.secrets.Delete(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,
})
if err != nil {
if machineReference != "" {
_ = service.secrets.Delete(ctx, machineReference)
}
if sessionReference != "" {
_ = service.secrets.Delete(ctx, sessionReference)
}
return Revision{}, err
}
return updated, nil
}
func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) {
status := PairingPreparing
switch remote.Status {
case ExchangeReady, ExchangeCredentialDelivered:
status = PairingReady
case ExchangeCompleted:
status = PairingFailed
remote.LastErrorCategory = "remote_state_invalid"
case ExchangeFailed:
status = PairingFailed
case ExchangeExpired:
status = PairingExpired
}
return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: status, RemoteVersion: remote.Version, LastErrorCategory: remote.LastErrorCategory, AuthCenterAuditID: remote.AuditID,
})
}

View File

@ -0,0 +1,156 @@
package identity
import (
"context"
"errors"
"testing"
"time"
)
type pairingRepositoryFake struct {
revision Revision
exchange PairingExchange
}
func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) {
f.revision = revision
return revision, nil
}
func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, string) (Revision, error) {
return f.revision, nil
}
func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) {
revision, err := ApplyManifest(f.revision, applied)
if err != nil {
return Revision{}, err
}
revision.Version++
f.revision = revision
return revision, nil
}
func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) {
f.exchange = exchange
return exchange, nil
}
func (f *pairingRepositoryFake) IdentityPairingExchange(context.Context, string) (PairingExchange, error) {
return f.exchange, nil
}
func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context, id string, expected int64, update PairingExchangeUpdate) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Version != expected {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion
f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID
f.exchange.Version++
return f.exchange, nil
}
type secretStoreFake struct {
values map[string][]byte
failMachine bool
}
func (f *secretStoreFake) Put(_ context.Context, reference string, value []byte) error {
if f.failMachine && len(reference) > len("identity-machine-") && reference[:len("identity-machine-")] == "identity-machine-" {
return errors.New("secret store unavailable")
}
if f.values == nil {
f.values = map[string][]byte{}
}
f.values[reference] = append([]byte(nil), value...)
return nil
}
func (f *secretStoreFake) Get(_ context.Context, reference string) ([]byte, error) {
value, ok := f.values[reference]
if !ok {
return nil, errors.New("secret not found")
}
return append([]byte(nil), value...), nil
}
func (f *secretStoreFake) Delete(_ context.Context, reference string) error {
delete(f.values, reference)
return nil
}
type onboardingRemoteFake struct {
claimed ClaimedExchange
view Exchange
delivery CredentialDelivery
deliveryCalls int
completed bool
}
func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) {
return f.claimed, nil
}
func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) {
f.view.Status, f.view.Version = ExchangePreparing, 2
return f.view, nil
}
func (f *onboardingRemoteFake) Get(context.Context, string, string) (Exchange, error) {
f.view.Status, f.view.Version = ExchangeReady, 3
return f.view, nil
}
func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error) {
f.deliveryCalls++
f.delivery.MachineCredential.ClientSecret = "rotated-machine-secret-value-000000000000-" + string(rune('0'+f.deliveryCalls))
f.delivery.Version = int64(3 + f.deliveryCalls)
return f.delivery, nil
}
func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error {
f.completed = true
return nil
}
type securityEventPreparerFake struct{ called bool }
func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error {
f.called = true
return nil
}
func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) {
repository := &pairingRepositoryFake{}
secrets := &secretStoreFake{values: map[string][]byte{}, failMachine: true}
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-pairing")
if err != nil {
t.Fatal(err)
}
if _, ok := secrets.values[pairing.ExchangeTokenRef]; !ok {
t.Fatal("exchange token was not saved in SecretStore")
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("machine SecretStore failure was ignored")
}
if remote.completed || repository.revision.ApplicationID != "" {
t.Fatal("failed secret save advanced the exchange or revision")
}
secrets.failMachine = false
completed, err := service.Continue(context.Background(), pairing.ID)
if err != nil {
t.Fatal(err)
}
if !remote.completed || completed.Status != PairingCompleted || remote.deliveryCalls != 2 || !preparer.called {
t.Fatalf("pairing did not recover safely: pairing=%#v calls=%d completed=%v", completed, remote.deliveryCalls, remote.completed)
}
if repository.revision.MachineCredentialRef == "" || repository.revision.SessionEncryptionKeyRef == "" {
t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision)
}
}

View File

@ -13,6 +13,7 @@ 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`
@ -62,13 +63,15 @@ func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVe
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,
machine_credential_ref=NULLIF($13,''),session_encryption_key_ref=NULLIF($14,''),last_trace_id=NULLIF($15,''),
last_audit_id=NULLIF($16,''),last_error_category=NULL,version=version+1,updated_at=now()
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.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID,
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
@ -213,6 +216,7 @@ func scanIdentityRevision(row scanner) (identity.Revision, error) {
&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,

View File

@ -0,0 +1,61 @@
package store
import (
"context"
"errors"
"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,remote_version,expires_at,version,
COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),created_at,updated_at`
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,remote_version,expires_at,last_trace_id
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,NULLIF($8,''))
RETURNING `+identityPairingColumns,
exchange.ID, exchange.RevisionID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef,
exchange.Status, 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) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.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
}
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.RemoteVersion, &exchange.ExpiresAt, &exchange.Version, &exchange.LastErrorCategory,
&exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CreatedAt, &exchange.UpdatedAt,
); err != nil {
return identity.PairingExchange{}, err
}
exchange.Status = identity.PairingStatus(status)
return exchange, nil
}

View File

@ -0,0 +1,23 @@
ALTER TABLE gateway_identity_configuration_revisions
ADD COLUMN IF NOT EXISTS security_event_issuer text,
ADD COLUMN IF NOT EXISTS security_event_configuration_url text,
ADD COLUMN IF NOT EXISTS security_event_audience text;
CREATE TABLE IF NOT EXISTS gateway_identity_onboarding_exchanges (
id uuid PRIMARY KEY,
revision_id uuid NOT NULL UNIQUE REFERENCES gateway_identity_configuration_revisions(id) ON DELETE CASCADE,
remote_exchange_id uuid NOT NULL UNIQUE,
exchange_token_ref text NOT NULL CHECK (exchange_token_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'),
status text NOT NULL CHECK (status IN ('metadata_pending','preparing','ready','credentials_saved','completed','failed','expired')),
remote_version bigint NOT NULL CHECK (remote_version > 0),
expires_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
last_error_category text,
auth_center_audit_id text,
last_trace_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gateway_identity_onboarding_status
ON gateway_identity_onboarding_exchanges(status, updated_at DESC);