forked from baron/baron-sso
feat(user): support fixed UUID registration and enhance bulk import results
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,15 +18,15 @@ import (
|
||||
)
|
||||
|
||||
type KratosIdentity struct {
|
||||
ID string `json:"id"`
|
||||
SchemaID string `json:"schema_id,omitempty"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
|
||||
MetadataPublic interface{} `json:"metadata_public,omitempty"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
SchemaID string `json:"schema_id,omitempty"`
|
||||
Traits map[string]any `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
MetadataAdmin any `json:"metadata_admin,omitempty"`
|
||||
MetadataPublic any `json:"metadata_public,omitempty"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type KratosSessionDevice struct {
|
||||
@@ -36,9 +37,9 @@ type KratosSessionDevice struct {
|
||||
type KratosSession struct {
|
||||
ID string `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
AuthenticatedAt time.Time `json:"authenticated_at,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
AuthenticatedAt time.Time `json:"authenticated_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
Identity *KratosIdentity `json:"identity,omitempty"`
|
||||
Devices []KratosSessionDevice `json:"devices,omitempty"`
|
||||
}
|
||||
@@ -47,7 +48,7 @@ type KratosAdminService interface {
|
||||
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
|
||||
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
|
||||
GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error)
|
||||
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
|
||||
UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error)
|
||||
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
||||
DeleteIdentity(ctx context.Context, identityID string) error
|
||||
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
||||
@@ -99,13 +100,81 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
|
||||
}
|
||||
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
|
||||
// 1. Try credentials_identifier (Email/LoginID/Phone)
|
||||
id, err := s.searchIdentities(ctx, endpoint, "credentials_identifier", identifier)
|
||||
if err == nil && id != "" {
|
||||
// VERIFY: Kratos sometimes ignores unknown query params and returns the first identity.
|
||||
if s.verifyIdentityMatch(ctx, id, identifier) {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If it looks like a UUID, try external_id
|
||||
if matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(identifier)); matched {
|
||||
id, err = s.searchIdentities(ctx, endpoint, "external_id", identifier)
|
||||
if err == nil && id != "" {
|
||||
if s.verifyIdentityMatch(ctx, id, identifier) {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Also try direct ID lookup
|
||||
identity, err := s.GetIdentity(ctx, identifier)
|
||||
if err == nil && identity != nil {
|
||||
return identity.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) verifyIdentityMatch(ctx context.Context, id, identifier string) bool {
|
||||
identity, err := s.GetIdentity(ctx, id)
|
||||
if err != nil || identity == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Exact ID match
|
||||
if strings.EqualFold(identity.ID, identifier) {
|
||||
return true
|
||||
}
|
||||
// Exact ExternalID match
|
||||
if strings.EqualFold(identity.ExternalID, identifier) {
|
||||
return true
|
||||
}
|
||||
// Check traits (Email, CustomLoginIDs)
|
||||
if email, ok := identity.Traits["email"].(string); ok && strings.EqualFold(email, identifier) {
|
||||
return true
|
||||
}
|
||||
if phone, ok := identity.Traits["phone_number"].(string); ok && strings.EqualFold(phone, identifier) {
|
||||
return true
|
||||
}
|
||||
if lids, ok := identity.Traits["custom_login_ids"].([]any); ok {
|
||||
for _, lid := range lids {
|
||||
if s, ok := lid.(string); ok && strings.EqualFold(s, identifier) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if lids, ok := identity.Traits["custom_login_ids"].([]string); ok {
|
||||
for _, lid := range lids {
|
||||
if strings.EqualFold(lid, identifier) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) searchIdentities(ctx context.Context, endpoint, key, value string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := req.URL.Query()
|
||||
query.Set("credentials_identifier", identifier)
|
||||
query.Set(key, value)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
@@ -119,7 +188,7 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
return "", fmt.Errorf("kratos admin search by %s failed status=%d body=%s", key, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identities []struct {
|
||||
@@ -162,8 +231,8 @@ func (s *kratosAdminService) GetIdentity(ctx context.Context, identityID string)
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
payload := map[string]interface{}{
|
||||
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
|
||||
payload := map[string]any{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
}
|
||||
@@ -211,12 +280,12 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
||||
return err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"schema_id": identity.SchemaID,
|
||||
"traits": identity.Traits,
|
||||
"state": identity.State,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"credentials": map[string]any{
|
||||
"password": map[string]any{
|
||||
"config": map[string]string{
|
||||
"hashed_password": hashedPassword,
|
||||
},
|
||||
@@ -264,7 +333,7 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
return "", fmt.Errorf("kratos admin: user payload is nil")
|
||||
}
|
||||
|
||||
traits := map[string]interface{}{
|
||||
traits := map[string]any{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
}
|
||||
@@ -278,11 +347,11 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
traits[k] = v
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"credentials": map[string]any{
|
||||
"password": map[string]any{
|
||||
"config": map[string]string{
|
||||
"password": password,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user