diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3d5344ff..c2aedead 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -705,6 +705,7 @@ func main() { dev := api.Group("/dev") dev.Get("/stats", devHandler.GetStats) dev.Get("/my-tenants", devHandler.ListMyTenants) + dev.Get("/users", devHandler.SearchUsers) dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) dev.Get("/clients/:id", devHandler.GetClient) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 63776688..2507e6a9 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -127,12 +127,26 @@ type clientRelationSummary struct { Subject string `json:"subject"` SubjectType string `json:"subjectType"` SubjectID string `json:"subjectId"` + UserName string `json:"userName,omitempty"` + UserEmail string `json:"userEmail,omitempty"` + UserLoginID string `json:"userLoginId,omitempty"` } type clientRelationListResponse struct { Items []clientRelationSummary `json:"items"` } +type devUserSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + LoginID string `json:"loginId,omitempty"` +} + +type devUserListResponse struct { + Items []devUserSummary `json:"items"` +} + type clientRelationUpsertRequest struct { Relation string `json:"relation"` Subject string `json:"subject"` @@ -416,6 +430,68 @@ func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string return ok } +func manageableTenantKeysFromProfile(profile *domain.UserProfileResponse) map[string]struct{} { + keys := make(map[string]struct{}) + if profile == nil { + return keys + } + + addKey := func(value string) { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed != "" { + keys[trimmed] = struct{}{} + } + } + + addKey(profile.CompanyCode) + if profile.TenantID != nil { + addKey(*profile.TenantID) + } + for _, tenant := range profile.ManageableTenants { + addKey(tenant.ID) + addKey(tenant.Slug) + } + for _, tenant := range profile.JoinedTenants { + addKey(tenant.ID) + addKey(tenant.Slug) + } + + return keys +} + +func canAccessIdentityByTenant(profile *domain.UserProfileResponse, identity service.KratosIdentity) bool { + if normalizeUserRole(profileRole(profile)) == domain.RoleSuperAdmin { + return true + } + + keys := manageableTenantKeysFromProfile(profile) + if len(keys) == 0 { + return false + } + + for _, raw := range []string{ + extractTraitString(identity.Traits, "tenant_id"), + extractTraitString(identity.Traits, "companyCode"), + extractTraitString(identity.Traits, "company_code"), + } { + if _, ok := keys[strings.ToLower(strings.TrimSpace(raw))]; ok { + return true + } + } + + return false +} + +func mapDevUserSummary(identity service.KratosIdentity) devUserSummary { + traits := identity.Traits + return devUserSummary{ + ID: identity.ID, + Name: extractTraitString(traits, "name"), + Email: extractTraitString(traits, "email"), + LoginID: resolvePasswordLoginID(traits), + } +} + func profileRole(profile *domain.UserProfileResponse) string { if profile == nil { return "" @@ -496,14 +572,20 @@ func validateClientRelationWriteInput(relation, subject string) error { return nil } -func mapRelationTupleSummary(tuple service.RelationTuple) clientRelationSummary { +func mapRelationTupleSummary(tuple service.RelationTuple, identity *service.KratosIdentity) clientRelationSummary { subjectType, subjectID := parseClientRelationSubject(tuple.SubjectID) - return clientRelationSummary{ + summary := clientRelationSummary{ Relation: tuple.Relation, Subject: tuple.SubjectID, SubjectType: subjectType, SubjectID: subjectID, } + if identity != nil { + summary.UserName = extractTraitString(identity.Traits, "name") + summary.UserEmail = extractTraitString(identity.Traits, "email") + summary.UserLoginID = resolvePasswordLoginID(identity.Traits) + } + return summary } func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) { @@ -522,6 +604,65 @@ func (h *DevHandler) getRelationRequestProfile(c *fiber.Ctx) *domain.UserProfile return h.getCurrentProfile(c) } +func (h *DevHandler) SearchUsers(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") + } + if !isDevConsoleRoleAllowed(normalizeUserRole(profile.Role)) { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + search := strings.ToLower(strings.TrimSpace(c.Query("search"))) + limit := c.QueryInt("limit", 10) + if limit <= 0 { + limit = 10 + } + if limit > 20 { + limit = 20 + } + + identities, err := h.KratosAdmin.ListIdentities(c.Context()) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + items := make([]devUserSummary, 0, limit) + for _, identity := range identities { + if !canAccessIdentityByTenant(profile, identity) { + continue + } + + summary := mapDevUserSummary(identity) + if search != "" { + matched := false + for _, candidate := range []string{ + strings.ToLower(summary.Name), + strings.ToLower(summary.Email), + strings.ToLower(summary.LoginID), + } { + if candidate != "" && strings.Contains(candidate, search) { + matched = true + break + } + } + if !matched { + continue + } + } + + items = append(items, summary) + if len(items) >= limit { + break + } + } + + return c.JSON(devUserListResponse{Items: items}) +} + func validateReservedSystemClientName(clientID, name string) error { ownerID, reserved := reservedSystemClientOwnerID(name) if !reserved { @@ -892,7 +1033,14 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } for _, tuple := range tuples { - items = append(items, mapRelationTupleSummary(tuple)) + var identity *service.KratosIdentity + if tuple.SubjectID != "" && h.KratosAdmin != nil { + _, subjectID := parseClientRelationSubject(tuple.SubjectID) + if subjectID != "" { + identity, _ = h.KratosAdmin.GetIdentity(c.Context(), subjectID) + } + } + items = append(items, mapRelationTupleSummary(tuple, identity)) } } @@ -950,7 +1098,7 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error { Object: clientID, Relation: req.Relation, SubjectID: req.Subject, - })) + }, nil)) } func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index bfaf47b4..0ef00aea 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -52,6 +52,66 @@ type devMockRedisRepo struct { data map[string]string } +type devMockKratosAdmin struct { + mock.Mock +} + +func (m *devMockKratosAdmin) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) { + args := m.Called(ctx) + return args.Get(0).([]service.KratosIdentity), args.Error(1) +} + +func (m *devMockKratosAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { + args := m.Called(ctx, identifier) + return args.String(0), args.Error(1) +} + +func (m *devMockKratosAdmin) GetIdentity(ctx context.Context, identityID string) (*service.KratosIdentity, error) { + args := m.Called(ctx, identityID) + if identity, ok := args.Get(0).(*service.KratosIdentity); ok { + return identity, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { + args := m.Called(ctx, identityID, traits, state) + if identity, ok := args.Get(0).(*service.KratosIdentity); ok { + return identity, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockKratosAdmin) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { + return m.Called(ctx, identityID, newPassword).Error(0) +} + +func (m *devMockKratosAdmin) DeleteIdentity(ctx context.Context, identityID string) error { + return m.Called(ctx, identityID).Error(0) +} + +func (m *devMockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + args := m.Called(ctx, user, password) + return args.String(0), args.Error(1) +} + +func (m *devMockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *devMockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if session, ok := args.Get(0).(*service.KratosSession); ok { + return session, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error { + return m.Called(ctx, sessionID).Error(0) +} + type devMockKetoOutboxRepository struct { mock.Mock } @@ -1273,13 +1333,23 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "status_operator"} { mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil) } + mockKratos := new(devMockKratosAdmin) + mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{ + ID: "user-2", + Traits: map[string]interface{}{ + "name": "김용연", + "email": "kyy@example.com", + "id": "kyy01", + }, + }, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, - Keto: mockKeto, + Keto: mockKeto, + KratosAdmin: mockKratos, } app := fiber.New() @@ -1304,6 +1374,9 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test assert.Equal(t, "config_editor", result.Items[0].Relation) assert.Equal(t, "User", result.Items[0].SubjectType) assert.Equal(t, "user-2", result.Items[0].SubjectID) + assert.Equal(t, "김용연", result.Items[0].UserName) + assert.Equal(t, "kyy@example.com", result.Items[0].UserEmail) + assert.Equal(t, "kyy01", result.Items[0].UserLoginID) } func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) { @@ -1421,3 +1494,58 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) { assert.Equal(t, http.StatusNoContent, resp.StatusCode) mockOutbox.AssertExpectations(t) } + +func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { + mockKratos := new(devMockKratosAdmin) + mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ + { + ID: "user-1", + Traits: map[string]interface{}{ + "name": "Alice Kim", + "email": "alice@example.com", + "id": "alice01", + "tenant_id": "tenant-1", + }, + }, + { + ID: "user-2", + Traits: map[string]interface{}{ + "name": "Bob Lee", + "email": "bob@example.com", + "id": "bob01", + "tenant_id": "tenant-2", + }, + }, + }, nil) + + h := &DevHandler{ + KratosAdmin: mockKratos, + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + tenantID := "tenant-1" + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-9", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + ManageableTenants: []domain.Tenant{ + {ID: "tenant-1", Slug: "tenant-one"}, + }, + }) + return c.Next() + }) + app.Get("/api/v1/dev/users", h.SearchUsers) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?search=alice", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result devUserListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Len(t, result.Items, 1) + assert.Equal(t, "user-1", result.Items[0].ID) + assert.Equal(t, "Alice Kim", result.Items[0].Name) + assert.Equal(t, "alice@example.com", result.Items[0].Email) + mockKratos.AssertExpectations(t) +}