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:
@@ -87,6 +87,14 @@ func (m *MockTenantServiceForUser) GetTenantBySlug(ctx context.Context, slug str
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) == nil {
|
||||
@@ -117,11 +125,21 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true},
|
||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
|
||||
ID: "t-123",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("u-1", nil).Twice()
|
||||
|
||||
@@ -188,11 +206,21 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true},
|
||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
|
||||
ID: "t-123",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"users": []map[string]interface{}{
|
||||
{
|
||||
@@ -391,23 +419,32 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"loginIdField": "emp_no",
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_no", "label": "Employee No"},
|
||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil) // Allow multiple calls for validation and sync
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||
|
||||
// Expect traits to include 'id' synced from 'emp_no'
|
||||
// Expect traits to include 'custom_login_ids' synced from 'emp_no'
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["id"] == "E1001"
|
||||
ids, ok := traits["custom_login_ids"].([]string)
|
||||
return ok && len(ids) > 0 && ids[0] == "E1001"
|
||||
}), mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"id": "E1001",
|
||||
"email": "user@test.com",
|
||||
"custom_login_ids": []interface{}{"E1001"},
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
@@ -459,7 +496,18 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"loginIdField": "emp_no",
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_no", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_no", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
@@ -467,11 +515,12 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
||||
|
||||
// Even if metadata is empty, it should sync from existing traits
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["id"] == "E2002"
|
||||
ids, ok := traits["custom_login_ids"].([]string)
|
||||
return ok && len(ids) > 0 && ids[0] == "E2002"
|
||||
}), mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"id": "E2002",
|
||||
"custom_login_ids": []interface{}{"E2002"},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
@@ -508,25 +557,42 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"id": "dyddus1210",
|
||||
"email": "dyddus1210@gmail.com",
|
||||
"companyCode": "test-tenant",
|
||||
"custom_login_ids": []interface{}{"dyddus1210"},
|
||||
"email": "dyddus1210@gmail.com",
|
||||
"companyCode": "test-tenant",
|
||||
"tenant_id": "t-1",
|
||||
"emp_id": "dyddus1210",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_id", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "t-1").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_id", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["id"] == "dyddus1210"
|
||||
ids, ok := traits["custom_login_ids"].([]string)
|
||||
return ok && len(ids) > 0 && ids[0] == "dyddus1210"
|
||||
}), "").Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"id": "dyddus1210",
|
||||
"email": "dyddus1210@gmail.com",
|
||||
"custom_login_ids": []interface{}{"dyddus1210"},
|
||||
"email": "dyddus1210@gmail.com",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
@@ -617,27 +683,36 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"loginIdField": "emp_no",
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_no", "label": "Employee No"},
|
||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{
|
||||
"userSchema": []interface{}{
|
||||
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
|
||||
// Expect OryProvider.CreateUser to be called with attributes["id"] synced from metadata
|
||||
// Expect OryProvider.CreateUser to be called with attributes["custom_login_ids"] synced from metadata
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
return user.LoginID == "E1001" && user.Attributes["id"] == "E1001"
|
||||
customIDs, ok := user.Attributes["custom_login_ids"].([]string)
|
||||
return ok && len(customIDs) > 0 && customIDs[0] == "E1001"
|
||||
}), mock.Anything).Return("u-1", nil).Once()
|
||||
|
||||
// Mock GetIdentity after creation
|
||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||
ID: "u-1",
|
||||
Traits: map[string]interface{}{
|
||||
"id": "E1001",
|
||||
"email": "new@test.com",
|
||||
"companyCode": "test-tenant",
|
||||
"custom_login_ids": []interface{}{"E1001"},
|
||||
"email": "new@test.com",
|
||||
"companyCode": "test-tenant",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user