forked from baron/baron-sso
dev API 관계 사용자 검색 및 관계 목록 사용자 정보 추가
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user