From 96bbd3a2f6957fe78a76ba99f945ede160b47d01 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 11:55:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(identity):=20=E5=AE=9E=E7=8E=B0=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=A0=81=E9=85=8D=E5=AF=B9=E5=90=8E=E5=8F=B0=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway 使用一次性接入码创建 Exchange,将 Exchange Token、机器凭据与 Session Key 仅写入 SecretStore,并持久化脱敏配对进度。\n\n资源就绪后先保存凭据和 Manifest,再自动准备 SSF,最后确认远端 Exchange;SecretStore 失败时保持 ready,重试会触发新 Secret 轮换,不回放旧值。\n\n验证:go test ./...;go vet ./... --- apps/api/cmd/migrate/main_test.go | 22 ++ apps/api/internal/identity/configuration.go | 8 + apps/api/internal/identity/pairing_service.go | 301 ++++++++++++++++++ .../internal/identity/pairing_service_test.go | 156 +++++++++ .../internal/store/identity_configurations.go | 10 +- apps/api/internal/store/identity_pairing.go | 61 ++++ .../0068_identity_onboarding_exchanges.sql | 23 ++ 7 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 apps/api/internal/identity/pairing_service.go create mode 100644 apps/api/internal/identity/pairing_service_test.go create mode 100644 apps/api/internal/store/identity_pairing.go create mode 100644 apps/api/migrations/0068_identity_onboarding_exchanges.sql diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go index 4c17357..9ccf709 100644 --- a/apps/api/cmd/migrate/main_test.go +++ b/apps/api/cmd/migrate/main_test.go @@ -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) + } + } +} diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go index 2faf81c..a43f78e 100644 --- a/apps/api/internal/identity/configuration.go +++ b/apps/api/internal/identity/configuration.go @@ -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) diff --git a/apps/api/internal/identity/pairing_service.go b/apps/api/internal/identity/pairing_service.go new file mode 100644 index 0000000..7a63c73 --- /dev/null +++ b/apps/api/internal/identity/pairing_service.go @@ -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, + }) +} diff --git a/apps/api/internal/identity/pairing_service_test.go b/apps/api/internal/identity/pairing_service_test.go new file mode 100644 index 0000000..f8823c8 --- /dev/null +++ b/apps/api/internal/identity/pairing_service_test.go @@ -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) + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go index 2c3aa73..1dcf285 100644 --- a/apps/api/internal/store/identity_configurations.go +++ b/apps/api/internal/store/identity_configurations.go @@ -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, diff --git a/apps/api/internal/store/identity_pairing.go b/apps/api/internal/store/identity_pairing.go new file mode 100644 index 0000000..e37a215 --- /dev/null +++ b/apps/api/internal/store/identity_pairing.go @@ -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 +} diff --git a/apps/api/migrations/0068_identity_onboarding_exchanges.sql b/apps/api/migrations/0068_identity_onboarding_exchanges.sql new file mode 100644 index 0000000..c3168ac --- /dev/null +++ b/apps/api/migrations/0068_identity_onboarding_exchanges.sql @@ -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);