package store import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) { if input.Issuer == "" || uuid.Validate(input.ApplicationID) != nil || uuid.Validate(input.TenantID) != nil || input.Subject == "" || input.TenantMetadataStatus != "synced" && input.TenantMetadataStatus != "metadata_pending" { return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid multi-tenant OIDC user projection input") } tx, err := s.pool.Begin(ctx) if err != nil { return ResolveOrProvisionOIDCUserResult{}, err } defer rollbackTransaction(tx) bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err := loadOIDCTenantBinding(ctx, tx, input) if errors.Is(err, pgx.ErrNoRows) { if !input.ProvisioningEnabled { return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned } bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err = createOIDCTenantBinding(ctx, tx, input) accessStatus = "active" } if err != nil { return ResolveOrProvisionOIDCUserResult{}, err } if accessStatus != "active" { if input.TenantMetadataStatus != "synced" { return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable } tag, reactivateErr := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings SET access_status='active',metadata_status='synced',last_error_category=NULL,updated_at=now() WHERE id=$1::uuid AND access_status='disabled'`, bindingID) if reactivateErr != nil { return ResolveOrProvisionOIDCUserResult{}, reactivateErr } if tag.RowsAffected() != 1 { return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable } accessStatus = "active" } if input.TenantMetadataStatus == "synced" { if _, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings SET metadata_status='synced',display_name=$2,slug=$3,metadata_version=NULLIF($4,''), metadata_etag=NULLIF($5,''),metadata_updated_at=$6, last_sync_at=now(),next_sync_at=now()+interval '15 minutes',sync_failure_count=0,last_error_category=NULL,updated_at=now() WHERE id=$1::uuid AND access_status='active'`, bindingID, input.TenantName, input.TenantSlug, input.TenantMetadataVersion, input.TenantMetadataETag, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt), ); err != nil { return ResolveOrProvisionOIDCUserResult{}, err } if _, err := tx.Exec(ctx, `UPDATE gateway_tenants SET name=$2,synced_at=now(),source_updated_at=$3,updated_at=now() WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`, gatewayTenantID, input.TenantName, input.TenantMetadataUpdatedAt, ); err != nil { return ResolveOrProvisionOIDCUserResult{}, err } } user, userBindingID, err := loadOIDCMultiTenantUser(ctx, tx, bindingID, input.Subject) created := false if errors.Is(err, pgx.ErrNoRows) { if !input.ProvisioningEnabled { return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned } user, userBindingID, created, err = s.createOIDCMultiTenantUser( ctx, tx, bindingID, gatewayTenantID, tenantKey, groupID, input, ) } if err != nil { return ResolveOrProvisionOIDCUserResult{}, err } if user.Status != "active" { return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled } rolesJSON, _ := json.Marshal(input.Roles) user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now() WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON))) if err != nil { return ResolveOrProvisionOIDCUserResult{}, err } var auditID string if created { subjectHash := sha256.Sum256([]byte(input.Subject)) audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{ Category: "identity", Action: "identity.oidc_user.provisioned", ActorGatewayUserID: user.ID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles, TargetType: "gateway_user", TargetID: user.ID, TargetGatewayUserID: user.ID, TargetGatewayTenantID: user.GatewayTenantID, RequestIP: input.RequestIP, UserAgent: input.UserAgent, AfterState: map[string]any{"source": "oidc_v2", "tenantKey": user.TenantKey, "userGroupId": user.DefaultUserGroupID}, Metadata: map[string]any{ "provisioningMode": "oidc-multi-tenant-jit", "externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16], }, }) if err != nil { return ResolveOrProvisionOIDCUserResult{}, err } auditID = audit.ID } if err := tx.Commit(ctx); err != nil { return ResolveOrProvisionOIDCUserResult{}, err } return ResolveOrProvisionOIDCUserResult{ User: multiTenantAuthUser(user, groupKey, input, userBindingID), Created: created, AuditID: auditID, }, nil } func loadOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, string, error) { var bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus string err := tx.QueryRow(ctx, `SELECT binding.id::text,tenant.id::text,tenant.tenant_key, tenant.default_user_group_id::text,group_record.group_key,binding.access_status FROM gateway_oidc_tenant_bindings binding JOIN gateway_tenants tenant ON tenant.id=binding.gateway_tenant_id JOIN gateway_user_groups group_record ON group_record.id=tenant.default_user_group_id WHERE binding.issuer=$1 AND binding.application_id=$2 AND binding.external_tenant_id=$3 AND tenant.status='active' AND tenant.deleted_at IS NULL AND group_record.status='active' FOR UPDATE OF binding,tenant`, input.Issuer, input.ApplicationID, input.TenantID, ).Scan(&bindingID, &gatewayTenantID, &tenantKey, &groupID, &groupKey, &accessStatus) return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err } func createOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, error) { var groupID, groupKey string if err := tx.QueryRow(ctx, `SELECT id::text,group_key FROM gateway_user_groups WHERE group_key='default' AND status='active'`).Scan(&groupID, &groupKey); err != nil { return "", "", "", "", "", ErrOIDCTenantUnavailable } tenantKey := deriveOIDCMultiTenantKey(input.Issuer, input.ApplicationID, input.TenantID) name := input.TenantName if name == "" { name = "认证中心租户 " + strings.ReplaceAll(input.TenantID, "-", "")[:8] } metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit", "metadataStatus": input.TenantMetadataStatus}) var gatewayTenantID string err := tx.QueryRow(ctx, `INSERT INTO gateway_tenants( tenant_key,source,external_tenant_id,name,default_user_group_id,metadata,status,synced_at,source_updated_at ) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4::jsonb,'active', CASE WHEN $5='synced' THEN now() ELSE NULL END,$6) ON CONFLICT(tenant_key) DO UPDATE SET updated_at=gateway_tenants.updated_at RETURNING id::text`, tenantKey, name, groupID, string(metadata), input.TenantMetadataStatus, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt)).Scan(&gatewayTenantID) if err != nil { return "", "", "", "", "", err } var bindingID string err = tx.QueryRow(ctx, `INSERT INTO gateway_oidc_tenant_bindings( issuer,application_id,external_tenant_id,gateway_tenant_id,access_status,metadata_status, display_name,slug,metadata_version,metadata_etag,metadata_updated_at,last_sync_at,next_sync_at ) VALUES($1,$2,$3,$4::uuid,'active',$5,NULLIF($6,''),NULLIF($7,''),NULLIF($8,''),NULLIF($9,''), $10, CASE WHEN $5='synced' THEN now() ELSE NULL END, CASE WHEN $5='synced' THEN now()+interval '15 minutes' ELSE now()+interval '30 seconds' END) ON CONFLICT(issuer,application_id,external_tenant_id) DO UPDATE SET updated_at=gateway_oidc_tenant_bindings.updated_at RETURNING id::text`, input.Issuer, input.ApplicationID, input.TenantID, gatewayTenantID, input.TenantMetadataStatus, input.TenantName, input.TenantSlug, input.TenantMetadataVersion, input.TenantMetadataETag, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt), ).Scan(&bindingID) return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err } func loadOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, subject string) (GatewayUser, string, error) { var userBindingID, gatewayUserID string if err := tx.QueryRow(ctx, `SELECT binding.id::text,binding.gateway_user_id::text FROM gateway_oidc_user_bindings binding JOIN gateway_users ON gateway_users.id=binding.gateway_user_id WHERE binding.tenant_binding_id=$1::uuid AND binding.subject=$2 AND gateway_users.deleted_at IS NULL FOR UPDATE OF binding,gateway_users`, bindingID, subject).Scan(&userBindingID, &gatewayUserID); err != nil { return GatewayUser{}, "", err } user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+` FROM gateway_users WHERE id=$1::uuid`, gatewayUserID)) return user, userBindingID, err } func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, gatewayTenantID, tenantKey, groupID string, input ResolveOrProvisionOIDCUserInput) (GatewayUser, string, bool, error) { userKey := deriveOIDCMultiTenantUserKey(input.Issuer, input.ApplicationID, input.TenantID, input.Subject) username := input.Username if username == "" { username = "oidc-" + strings.TrimPrefix(userKey, "oidc2:")[:12] } rolesJSON, _ := json.Marshal(input.Roles) metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"}) user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users( user_key,source,external_user_id,username,gateway_tenant_id,tenant_id,tenant_key, default_user_group_id,roles,auth_profile,metadata,status,last_login_at,synced_at,source_updated_at ) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb, 'active',now(),now(),now()) ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at RETURNING `+userColumns, userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata), )) if err != nil { return GatewayUser{}, "", false, err } var userBindingID string tag, err := tx.Exec(ctx, `INSERT INTO gateway_oidc_user_bindings(tenant_binding_id,subject,gateway_user_id) VALUES($1::uuid,$2,$3::uuid) ON CONFLICT(tenant_binding_id,subject) DO NOTHING`, bindingID, input.Subject, user.ID) if err != nil { return GatewayUser{}, "", false, err } created := tag.RowsAffected() == 1 if err := tx.QueryRow(ctx, `SELECT id::text FROM gateway_oidc_user_bindings WHERE tenant_binding_id=$1::uuid AND subject=$2`, bindingID, input.Subject).Scan(&userBindingID); err != nil { return GatewayUser{}, "", false, err } if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil { return GatewayUser{}, "", false, err } return user, userBindingID, created, nil } func deriveOIDCMultiTenantKey(issuer, applicationID, tenantID string) string { sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID)) return fmt.Sprintf("oidc2-tenant-%x", sum[:16]) } func nullableOIDCTenantMetadataTime(value time.Time) any { if value.IsZero() { return nil } return value } func deriveOIDCMultiTenantUserKey(issuer, applicationID, tenantID, subject string) string { sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID + "\x00" + subject)) return fmt.Sprintf("oidc2:%x", sum) } func multiTenantAuthUser(user GatewayUser, groupKey string, input ResolveOrProvisionOIDCUserInput, userBindingID string) *auth.User { result := authUserFromOIDCProjection(user, groupKey) result.ID = input.Subject result.TenantName = input.TenantName result.Issuer = input.Issuer result.ApplicationID = input.ApplicationID result.OIDCClientID = input.OIDCClientID result.OIDCUserBindingID = userBindingID return result }