1
0
forked from baron/baron-sso

feat: implement multi-identifier architecture (Issue #496)

- Database: Add user_login_ids table for 1:N identifier mapping and remove legacy login_id column
- Kratos: Update identity schema to use custom_login_ids array instead of a single id trait
- Backend: Implement syncCustomLoginIDs to collect isLoginId fields across tenant schemas
- Backend: Add backtracking logic to auto-assign session tenant based on used login identifier
- Backend: Add 409 Conflict exception handling for Create/Update operations
- AdminFront: Refactor UserDetailPage to a tabbed grid layout (Info, Tenants, Security)
- AdminFront: Show '로그인 ID' badge on tenant schema fields used for authentication
- UserFront: Remove legacy optional 'Login ID' input from signup flow
- Tests: Add multi-identifier repository tests and update handler tests
This commit is contained in:
2026-04-02 16:07:33 +09:00
parent 71a006cd7b
commit b582c82c6f
25 changed files with 1154 additions and 1160 deletions

View File

@@ -21,6 +21,12 @@ type UserRepository interface {
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
Delete(ctx context.Context, id string) error
// Multiple identifiers support
UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error
GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error)
IsLoginIDTaken(ctx context.Context, loginID string) (bool, error)
FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error)
}
type userRepository struct {
@@ -193,3 +199,45 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&domain.User{}, "id = ?", id).Error
}
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Delete existing login IDs for this user
if err := tx.Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}
// Insert new login IDs if any
if len(loginIDs) > 0 {
if err := tx.Create(&loginIDs).Error; err != nil {
return err
}
}
return nil
})
}
func (r *userRepository) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
var results []domain.UserLoginID
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *userRepository) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&domain.UserLoginID{}).Where("login_id = ?", loginID).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *userRepository) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
var record domain.UserLoginID
if err := r.db.WithContext(ctx).Where("login_id = ?", loginID).First(&record).Error; err != nil {
return "", err
}
return record.TenantID, nil
}

View File

@@ -94,4 +94,54 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, int64(1), counts["tenant-b"])
assert.Equal(t, int64(0), counts["tenant-c"])
})
t.Run("Multi-Identifier Support", func(t *testing.T) {
_ = testDB.AutoMigrate(&domain.UserLoginID{})
testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users")
user := &domain.User{Email: "multi@test.com", Name: "Multi"}
_ = repo.Create(ctx, user)
t1 := "00000000-0000-0000-0000-000000000001"
t2 := "00000000-0000-0000-0000-000000000002"
loginIDs := []domain.UserLoginID{
{UserID: user.ID, TenantID: t1, FieldKey: "emp_id", LoginID: "E001"},
{UserID: user.ID, TenantID: t2, FieldKey: "student_id", LoginID: "S001"},
}
err := repo.UpdateUserLoginIDs(ctx, user.ID, loginIDs)
assert.NoError(t, err)
// Get and Verify
saved, err := repo.GetUserLoginIDs(ctx, user.ID)
assert.NoError(t, err)
assert.Len(t, saved, 2)
// IsLoginIDTaken
taken, err := repo.IsLoginIDTaken(ctx, "E001")
assert.NoError(t, err)
assert.True(t, taken)
taken, err = repo.IsLoginIDTaken(ctx, "UNKNOWN")
assert.NoError(t, err)
assert.False(t, taken)
// FindTenantIDByLoginID
tid, err := repo.FindTenantIDByLoginID(ctx, "S001")
assert.NoError(t, err)
assert.Equal(t, t2, tid)
// Update (Replace)
newList := []domain.UserLoginID{
{UserID: user.ID, TenantID: t1, FieldKey: "emp_id", LoginID: "E002"},
}
err = repo.UpdateUserLoginIDs(ctx, user.ID, newList)
assert.NoError(t, err)
saved, _ = repo.GetUserLoginIDs(ctx, user.ID)
assert.Len(t, saved, 1)
assert.Equal(t, "E002", saved[0].LoginID)
})
}