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

@@ -36,13 +36,13 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
require.NoError(t, err)
var claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Aud interface{} `json:"aud"`
Iat int64 `json:"iat"`
Jti string `json:"jti"`
Sid string `json:"sid"`
Events map[string]interface{} `json:"events"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Aud any `json:"aud"`
Iat int64 `json:"iat"`
Jti string `json:"jti"`
Sid string `json:"sid"`
Events map[string]any `json:"events"`
}
require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims))
@@ -51,7 +51,7 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
switch aud := claims.Aud.(type) {
case string:
assert.Equal(t, "client-1", aud)
case []interface{}:
case []any:
assert.Len(t, aud, 1)
assert.Equal(t, "client-1", aud[0])
default:

View File

@@ -65,21 +65,21 @@ func (s *DeveloperService) ListRequests(ctx context.Context, userID, status stri
}
func (s *DeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.DeveloperRequestStatusApproved,
"admin_notes": adminNotes,
}).Error
}
func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.DeveloperRequestStatusRejected,
"admin_notes": adminNotes,
}).Error
}
func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.DeveloperRequestStatusCancelled,
"admin_notes": adminNotes,
}).Error

View File

@@ -309,7 +309,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
updated := *previous
updated.JWKSURI = jwksURI
updated.LastCheckedAt = &now
updated.ExpiresAt = ptrTime(now.Add(s.TTL))
updated.ExpiresAt = new(now.Add(s.TTL))
updated.NextRetryAt = nil
updated.LastRefreshStatus = "success"
updated.LastError = ""
@@ -339,7 +339,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
ClientID: client.ClientID,
JWKSURI: jwksURI,
CachedAt: &now,
ExpiresAt: ptrTime(now.Add(s.TTL)),
ExpiresAt: new(now.Add(s.TTL)),
LastCheckedAt: &now,
NextRetryAt: nil,
LastSuccessfulVerificationAt: previousLastVerification(previous),
@@ -379,7 +379,7 @@ func (s *HeadlessJWKSCacheService) persistRefreshFailure(client domain.HydraClie
state.ConsecutiveFailures = previous.ConsecutiveFailures + 1
}
if s.shouldBackoff(state.ConsecutiveFailures) {
state.NextRetryAt = ptrTime(now.Add(s.failureBackoffDuration()))
state.NextRetryAt = new(now.Add(s.failureBackoffDuration()))
}
_ = s.SaveState(client.ClientID, state)
return &state
@@ -480,8 +480,9 @@ func previousLastVerification(previous *domain.HeadlessJWKSCacheState) *time.Tim
return previous.LastSuccessfulVerificationAt
}
//go:fix inline
func ptrTime(value time.Time) *time.Time {
return &value
return new(value)
}
func (w *HeadlessJWKSCacheWorker) Start(ctx context.Context) {

View File

@@ -70,7 +70,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_UsesCachedJWKSWhenFresh(t *t
CachedKids: []string{"cached-key"},
CachedAt: &now,
LastCheckedAt: &now,
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
ExpiresAt: new(now.Add(30 * time.Minute)),
LastRefreshStatus: "success",
ConsecutiveFailures: 0,
})
@@ -114,7 +114,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_RefreshesWhenKidMissing(t *t
CachedKids: []string{"stale-key"},
CachedAt: &now,
LastCheckedAt: &now,
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
ExpiresAt: new(now.Add(30 * time.Minute)),
LastRefreshStatus: "success",
})
require.NoError(t, err)
@@ -181,7 +181,7 @@ func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testin
ClientID: "client-headless",
LastRefreshStatus: "failure",
ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)),
NextRetryAt: new(now.Add(10 * time.Minute)),
}
assert.False(t, cacheService.ShouldPrefetch(state, now))
@@ -216,7 +216,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_SkipsBackoffTargets(t *testing.T) {
JWKSURI: clients[1].HeadlessJWKSURI(),
LastRefreshStatus: "failure",
ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)),
NextRetryAt: new(now.Add(10 * time.Minute)),
}))
fetchCounts := map[string]int{}
@@ -278,7 +278,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_RetriesAfterBackoffAndClearsFailureStat
LastRefreshStatus: "failure",
LastError: "previous failure",
ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(time.Now().Add(-time.Minute)),
NextRetryAt: new(time.Now().Add(-time.Minute)),
}))
fetchCount := 0
@@ -353,7 +353,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_MixedClients(t *testing.T) {
JWKSURI: skipClient.HeadlessJWKSURI(),
LastRefreshStatus: "failure",
ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(time.Now().Add(10 * time.Minute)),
NextRetryAt: new(time.Now().Add(10 * time.Minute)),
}))
fetchCounts := map[string]int{}
@@ -416,8 +416,9 @@ func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.
return privateKey, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{publicJWK}}
}
//go:fix inline
func ptrTestTime(value time.Time) *time.Time {
return &value
return new(value)
}
func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient {

View File

@@ -97,7 +97,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) {
// JSON Patch format
payload := []map[string]interface{}{
payload := []map[string]any{
{
"op": "replace",
"path": "/metadata/status",
@@ -396,7 +396,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge
return nil, err
}
payload := map[string]interface{}{
payload := map[string]any{
"error": "access_denied",
"error_description": "The user decided to reject the consent request.",
}
@@ -438,7 +438,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e
return nil, err
}
payload := map[string]interface{}{
payload := map[string]any{
"error": error,
"error_description": errorDescription,
}
@@ -513,7 +513,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
return nil, err
}
payload := map[string]interface{}{
payload := map[string]any{
"grant_scope": grantInfo.RequestedScope,
"grant_audience": grantInfo.RequestedAudience,
"remember": true,
@@ -564,7 +564,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
return nil, err
}
payload := map[string]interface{}{
payload := map[string]any{
"subject": subject,
"remember": true,
"remember_for": 2592000,
@@ -600,13 +600,13 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
}
type HydraIntrospectionResponse struct {
Active bool `json:"active"`
Subject string `json:"sub"`
ClientID string `json:"client_id"`
Scope string `json:"scope"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Ext map[string]interface{} `json:"ext"`
Active bool `json:"active"`
Subject string `json:"sub"`
ClientID string `json:"client_id"`
Scope string `json:"scope"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Ext map[string]any `json:"ext"`
}
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {

View File

@@ -127,7 +127,7 @@ func TestHydraAdminService_PatchClientStatus(t *testing.T) {
assert.Equal(t, "PATCH", r.Method)
assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type"))
var payload []map[string]interface{}
var payload []map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
assert.Equal(t, "replace", payload[0]["op"])
assert.Equal(t, "/metadata/status", payload[0]["path"])
@@ -273,7 +273,7 @@ func TestHydraAdminService_AcceptLoginRequest(t *testing.T) {
assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path)
assert.Equal(t, challenge, r.URL.Query().Get("login_challenge"))
var body map[string]interface{}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, subject, body["subject"])

View File

@@ -110,7 +110,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
maxRetries := 5
backoff := 200 * time.Millisecond
for i := 0; i < maxRetries; i++ {
for i := range maxRetries {
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
resp, err := s.client.Do(req)
if err == nil {
@@ -143,7 +143,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
payload := map[string]interface{}{
payload := map[string]any{
"namespace": namespace,
"object": object,
"relation": relation,
@@ -156,7 +156,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
maxRetries := 5
backoff := 200 * time.Millisecond
for i := 0; i < maxRetries; i++ {
for i := range maxRetries {
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -197,7 +197,7 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
maxRetries := 5
backoff := 200 * time.Millisecond
for i := 0; i < maxRetries; i++ {
for i := range maxRetries {
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
resp, err := s.client.Do(req)
if err == nil {

View File

@@ -36,7 +36,7 @@ func TestKetoService_CreateRelation(t *testing.T) {
assert.Equal(t, "/admin/relation-tuples", r.URL.Path)
assert.Equal(t, "PUT", r.Method)
var body map[string]interface{}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "tenants", body["namespace"])
assert.Equal(t, "tenant1", body["object"])

View File

@@ -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,
},

View File

@@ -103,7 +103,7 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
return args.Get(0).(*KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
args := m.Called(ctx, identityID, traits, state)
if args.Get(0) == nil {
return nil, args.Error(1)

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) {

View File

@@ -1,6 +1,7 @@
package service
import (
"baron-sso-backend/internal/domain"
"bytes"
"encoding/json"
"io"
@@ -50,19 +51,24 @@ func TestUpdateUserPassword_Success(t *testing.T) {
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"id": identityID,
"traits": map[string]any{
"email": loginID,
},
},
})
return
}
if r.URL.Path != "/admin/identities/"+identityID {
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
_ = json.NewEncoder(w).Encode(map[string]any{
"id": identityID,
"schema_id": "default",
"state": "active",
"traits": map[string]interface{}{
"traits": map[string]any{
"email": loginID,
},
})
@@ -120,17 +126,22 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
if r.URL.Path == "/admin/identities" {
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"id": "abc",
"traits": map[string]any{
"email": "user@example.com",
},
},
})
return
}
if r.URL.Path == "/admin/identities/abc" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "abc",
"schema_id": "default",
"state": "active",
"traits": map[string]interface{}{
"traits": map[string]any{
"email": "user@example.com",
},
})
@@ -163,8 +174,13 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
if values.Get("credentials_identifier") != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "id-123"},
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"id": "id-123",
"traits": map[string]any{
"email": loginID,
},
},
})
})
@@ -181,3 +197,67 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
t.Fatalf("expected id-123, got %s", id)
}
}
func TestOryProvider_CreateUser_CustomIDSupport(t *testing.T) {
const (
email = "newuser@test.com"
name = "New User"
customUuid = "550e8400-e29b-41d4-a716-446655440000"
password = "secret123456"
kratosId = "kratos-gen-id"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
// No existing identity
_ = json.NewEncoder(w).Encode([]any{})
return
case r.URL.Path == "/admin/identities/"+customUuid && r.Method == http.MethodGet:
// No identity with this ID
http.NotFound(w, r)
return
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
// Verify ID is NOT in the root to avoid "unknown field id" error
if _, exists := body["id"]; exists {
t.Fatalf("payload MUST NOT contain root 'id' field for compatibility")
}
// Verify external_id and metadata_admin
if got := body["external_id"]; got != customUuid {
t.Fatalf("expected external_id %s, got %v", customUuid, got)
}
meta, ok := body["metadata_admin"].(map[string]any)
if !ok || meta["original_uuid"] != customUuid {
t.Fatalf("expected metadata_admin.original_uuid %s, got %v", customUuid, meta)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": kratosId,
})
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
id, err := provider.CreateUser(&domain.BrokerUser{
ID: customUuid,
Email: email,
Name: name,
}, password)
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
if id != kratosId {
t.Fatalf("expected Kratos generated ID %s, got %s", kratosId, id)
}
}

View File

@@ -100,7 +100,7 @@ func (s *relyingPartyService) enqueueDefaultRelyingPartyRelations(ctx context.Co
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
// 1. Create Client in Hydra
if client.Metadata == nil {
client.Metadata = make(map[string]interface{})
client.Metadata = make(map[string]any)
}
client.Metadata["tenant_id"] = tenantID

View File

@@ -52,7 +52,7 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
tenantID := "tenant-1"
inputClient := domain.HydraClient{
ClientName: "Test App",
Metadata: map[string]interface{}{
Metadata: map[string]any{
"user_id": "creator-1",
},
}
@@ -127,7 +127,7 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraClient{
ClientID: clientID,
ClientName: "Hydra Name",
Metadata: map[string]interface{}{
Metadata: map[string]any{
"tenant_id": "tenant-1",
},
})
@@ -180,7 +180,7 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
if r.Method == http.MethodGet && strings.Contains(r.URL.Path, clientID) {
_ = json.NewEncoder(w).Encode(domain.HydraClient{
ClientID: clientID,
Metadata: map[string]interface{}{
Metadata: map[string]any{
"tenant_id": tenantID,
"user_id": "creator-1",
},

View File

@@ -8,7 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"log/slog"
"net/http"
"os"
@@ -82,7 +82,7 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
}
defer resp.Body.Close()
respBody, err := ioutil.ReadAll(resp.Body)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %w", err)
}

View File

@@ -336,7 +336,7 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
}
for _, g := range groups {
rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{})
rawConfig, ok := g.Config["autoProvisioning"].(map[string]any)
if !ok {
continue
}
@@ -346,12 +346,12 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
continue
}
mapping, ok := rawConfig["mappingRules"].(map[string]interface{})
mapping, ok := rawConfig["mappingRules"].(map[string]any)
if !ok {
continue
}
rule, ok := mapping[domainName].(map[string]interface{})
rule, ok := mapping[domainName].(map[string]any)
if !ok {
continue
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
// --- Local Mocks to avoid collisions ---
@@ -185,6 +186,10 @@ func (m *MockUserRepoForTenant) FindTenantIDByLoginID(ctx context.Context, login
return "", nil
}
func (m *MockUserRepoForTenant) DB() *gorm.DB {
return nil
}
// --- Tests ---
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {

View File

@@ -225,7 +225,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
if err == nil && identity != nil {
traits := identity.Traits
if traits == nil {
traits = make(map[string]interface{})
traits = make(map[string]any)
}
delete(traits, "companyCode")
delete(traits, "companyCodes")
@@ -349,7 +349,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
return user
}
func userGroupTraitString(traits map[string]interface{}, key string) string {
func userGroupTraitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
@@ -363,14 +363,14 @@ func userGroupTraitString(traits map[string]interface{}, key string) string {
return fmt.Sprint(value)
}
func userGroupTraitStringArray(traits map[string]interface{}, key string) []string {
func userGroupTraitStringArray(traits map[string]any, key string) []string {
if traits == nil {
return nil
}
switch value := traits[key].(type) {
case []string:
return value
case []interface{}:
case []any:
items := make([]string, 0, len(value))
for _, item := range value {
if str, ok := item.(string); ok && str != "" {

View File

@@ -135,6 +135,10 @@ func (m *MockUserRepository) FindTenantIDByLoginID(ctx context.Context, loginID
return "", nil
}
func (m *MockUserRepository) DB() *gorm.DB {
return nil
}
type MockKetoOutboxRepository struct {
mock.Mock
}
@@ -250,7 +254,7 @@ func TestUserGroupService_AddMember(t *testing.T) {
// Mock Kratos
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID,
Traits: map[string]interface{}{"email": "user@test.com"},
Traits: map[string]any{"email": "user@test.com"},
State: "active",
}, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
@@ -295,18 +299,18 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
Traits: map[string]any{
"email": "user@test.com",
"name": "User Test",
},
State: "active",
}, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
_, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
}), "active").Return(&KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
Traits: map[string]any{
"email": "user@test.com",
"name": "User Test",
"tenant_id": tenantID,
@@ -402,7 +406,7 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{
ID: "u1",
Traits: map[string]interface{}{"name": "User One", "email": "user1@example.com"},
Traits: map[string]any{"name": "User One", "email": "user1@example.com"},
}, nil)
group, err := svc.Get(context.Background(), groupID)

View File

@@ -110,7 +110,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
return user
}
func kratosProjectionTraitString(traits map[string]interface{}, key string) string {
func kratosProjectionTraitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
@@ -124,14 +124,14 @@ func kratosProjectionTraitString(traits map[string]interface{}, key string) stri
return fmt.Sprint(value)
}
func kratosProjectionTraitStringArray(traits map[string]interface{}, key string) []string {
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
if traits == nil {
return nil
}
switch value := traits[key].(type) {
case []string:
return value
case []interface{}:
case []any:
items := make([]string, 0, len(value))
for _, item := range value {
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {

View File

@@ -48,12 +48,12 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
{
ID: "00000000-0000-0000-0000-000000000101",
Traits: map[string]interface{}{
Traits: map[string]any{
"email": "one@example.com",
"name": "One",
"phone_number": "+821012345678",
"companyCode": "saman",
"companyCodes": []interface{}{"saman", "group-a"},
"companyCodes": []any{"saman", "group-a"},
"tenant_id": tenantID,
"department": "DX",
"customAttr": "kept",
@@ -101,7 +101,7 @@ func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
user := MapKratosIdentityToLocalUser(KratosIdentity{
ID: "00000000-0000-0000-0000-000000000201",
State: domain.UserStatusArchived,
Traits: map[string]interface{}{
Traits: map[string]any{
"email": "archived@example.com",
"name": "Archived User",
},

View File

@@ -737,7 +737,7 @@ type WorksmobileUserPatchPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email,omitempty"`
UserExternalKey string `json:"userExternalKey,omitempty"`
UserName WorksmobileUserName `json:"userName,omitempty"`
UserName WorksmobileUserName `json:"userName"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
@@ -1260,3 +1261,7 @@ func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID st
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}
func (f *fakeWorksmobileUserRepo) DB() *gorm.DB {
return nil
}