1
0
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:
2026-06-01 15:34:08 +09:00
parent 4a1e89e421
commit 31d107ff2e
85 changed files with 2104 additions and 1149 deletions

View File

@@ -59,6 +59,13 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
}
// 중복 확인
if user.ID != "" {
existing, err := o.getIdentity(user.ID)
if err == nil && existing != nil {
return "", fmt.Errorf("ory provider: identity already exists for uuid=%s", user.ID)
}
}
existingID, err := o.findIdentityID(user.Email)
if err != nil {
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
@@ -102,7 +109,7 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
}
}
traits := map[string]interface{}{
traits := map[string]any{
"email": user.Email,
"name": user.Name,
}
@@ -123,18 +130,26 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
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,
},
},
},
}
verifiable := []map[string]interface{}{
if user.ID != "" {
// Use external_id as a fallback/mapping for the requested ID
payload["external_id"] = user.ID
// Also store in metadata_admin for audit/migration purposes
payload["metadata_admin"] = map[string]any{
"original_uuid": user.ID,
}
}
verifiable := []map[string]any{
{
"value": user.Email,
"verified": true,
@@ -142,14 +157,14 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
},
}
if user.PhoneNumber != "" {
verifiable = append(verifiable, map[string]interface{}{
verifiable = append(verifiable, map[string]any{
"value": user.PhoneNumber,
"verified": true,
"via": "sms",
})
}
payload["verifiable_addresses"] = verifiable
payload["recovery_addresses"] = []map[string]interface{}{
payload["recovery_addresses"] = []map[string]any{
{
"value": user.Email,
"via": "email",
@@ -454,12 +469,12 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
existingIndex = idx
}
}
ops := make([]map[string]interface{}, 0, 2)
ops := make([]map[string]any, 0, 2)
if !exists {
ops = append(ops, map[string]interface{}{
ops = append(ops, map[string]any{
"op": "add",
"path": "/verifiable_addresses/-",
"value": map[string]interface{}{
"value": map[string]any{
"value": loginID,
"via": via,
"verified": true,
@@ -469,14 +484,14 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
} else {
addr := identity.VerifiableAddresses[existingIndex]
if !addr.Verified {
ops = append(ops, map[string]interface{}{
ops = append(ops, map[string]any{
"op": "replace",
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
"value": true,
})
}
if addr.Status != "" && addr.Status != "completed" {
ops = append(ops, map[string]interface{}{
ops = append(ops, map[string]any{
"op": "replace",
"path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
"value": "completed",
@@ -520,7 +535,7 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
})
}
payload := map[string]interface{}{
payload := map[string]any{
"schema_id": fullIdentity.SchemaID,
"traits": fullIdentity.Traits,
"verifiable_addresses": addresses,
@@ -561,12 +576,12 @@ type kratosRecoveryAddress struct {
type kratosIdentityFull struct {
SchemaID string `json:"schema_id"`
Traits map[string]interface{} `json:"traits"`
Traits map[string]any `json:"traits"`
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
}
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]any) error {
body, _ := json.Marshal(ops)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
if err != nil {
@@ -750,12 +765,12 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
return fmt.Errorf("ory provider: hash password failed: %w", 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,
},
@@ -806,6 +821,57 @@ func getenv(key, fallback string) string {
return fallback
}
// findIdentityByID: Kratos Admin API에서 ID(UUID)로 직접 조회
func (o *OryProvider) findIdentityByID(id string) (string, error) {
identity, err := o.getIdentity(id)
if err != nil {
return "", err
}
if identity != nil {
return identity.ID, nil
}
return "", nil
}
// findIdentityByExternalID: external_id 필드로 identity 검색
func (o *OryProvider) findIdentityByExternalID(externalID string) (string, error) {
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
if err != nil {
return "", err
}
query := u.Query()
// Kratos v1.1+ supports filtering by external_id
query.Set("external_id", externalID)
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
resp, err := o.httpClient().Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", nil // Ignore errors for search
}
var identities []struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return "", nil
}
if len(identities) == 0 {
return "", nil
}
return identities[0].ID, nil
}
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
func (o *OryProvider) findIdentityID(loginID string) (string, error) {
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
@@ -837,7 +903,8 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
}
var identities []struct {
ID string `json:"id"`
ID string `json:"id"`
Traits map[string]any `json:"traits"`
}
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return "", fmt.Errorf("decode response failed: %w", err)
@@ -845,7 +912,30 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
if len(identities) == 0 {
return "", nil
}
return identities[0].ID, nil
// VERIFY: Double check traits to avoid Kratos ignoring the query param
candidate := identities[0]
if email, ok := candidate.Traits["email"].(string); ok && strings.EqualFold(email, loginID) {
return candidate.ID, nil
}
if phone, ok := candidate.Traits["phone_number"].(string); ok && strings.EqualFold(phone, loginID) {
return candidate.ID, nil
}
if lids, ok := candidate.Traits["custom_login_ids"].([]any); ok {
for _, lid := range lids {
if s, ok := lid.(string); ok && strings.EqualFold(s, loginID) {
return candidate.ID, nil
}
}
} else if lids, ok := candidate.Traits["custom_login_ids"].([]string); ok {
for _, lid := range lids {
if strings.EqualFold(lid, loginID) {
return candidate.ID, nil
}
}
}
return "", nil
}
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {