diff --git a/entity-service/internal/domain/entity.go b/entity-service/internal/domain/entity.go index 84e3374845..8a93e8b766 100644 --- a/entity-service/internal/domain/entity.go +++ b/entity-service/internal/domain/entity.go @@ -50,21 +50,68 @@ type User struct { UpdatedOn time.Time `json:"updatedOn"` } +// UserRole represents a ServiceNow role that can be assigned to a user. +type UserRole string + +const ( + UserRoleInternal UserRole = "internal" + UserRoleAgent UserRole = "agent" + UserRoleAdmin UserRole = "admin" + UserRoleCommenter UserRole = "commenter" + UserRoleExternal UserRole = "external" + UserRoleCustomer UserRole = "customer" + UserRoleCustomerAdmin UserRole = "customer_admin" + UserRolePartner UserRole = "partner" + UserRolePartnerAdmin UserRole = "partner_admin" +) + +// UserSortField enumerates the columns by which user search results may be ordered. +type UserSortField string + +const ( + UserSortFieldName UserSortField = "name" + UserSortFieldCreatedOn UserSortField = "createdOn" + UserSortFieldUpdatedOn UserSortField = "updatedOn" +) + +// UserSortOrder is the direction of a user search sort. +type UserSortOrder string + +const ( + UserSortOrderAsc UserSortOrder = "asc" + UserSortOrderDesc UserSortOrder = "desc" +) + // Pagination controls which page of results is returned. -// Limit defaults to 20 and is capped at 100 by the service layer. +// Limit defaults to 10 and is capped at 50 by the service layer. type Pagination struct { Limit int `json:"limit"` Offset int `json:"offset"` } +// SearchUsersFilters holds the optional filter criteria for a user search. +type SearchUsersFilters struct { + SearchQuery string `json:"searchQuery"` + Roles []UserRole `json:"roles"` + UserNames []string `json:"userNames"` + Emails []string `json:"emails"` + Active *bool `json:"active"` +} + +// UserSortBy specifies the sort field and direction for a user search. +type UserSortBy struct { + Field UserSortField `json:"field"` + Order UserSortOrder `json:"order"` +} + // SearchUsersRequest is the input for a user search operation. -// SearchQuery is matched case-insensitively against username and email. type SearchUsersRequest struct { - Pagination Pagination `json:"pagination"` - SearchQuery string `json:"searchQuery"` + Pagination Pagination `json:"pagination"` + Filters SearchUsersFilters `json:"filters"` + SortBy UserSortBy `json:"sortBy"` } -// SearchUsersResponse is the paginated result of a user search. +// SearchUsersResponse is the paginated result of a postgres user search. // HasMore is true when additional pages are available beyond the current offset. type SearchUsersResponse struct { Users []User `json:"users"` @@ -74,6 +121,27 @@ type SearchUsersResponse struct { HasMore bool `json:"hasMore"` } +// SNUser is the user view returned by the ServiceNow data source. +type SNUser struct { + ID string `json:"id"` + UserName string `json:"userName"` + Name string `json:"name"` + Email string `json:"email"` + TimeZone *string `json:"timeZone"` + Active bool `json:"active"` + CreatedOn string `json:"createdOn"` + UpdatedOn string `json:"updatedOn"` + Roles []string `json:"roles"` +} + +// SearchSNUsersResponse is the paginated result of a ServiceNow user search. +type SearchSNUsersResponse struct { + Users []SNUser `json:"users"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + // AccountTier represents the subscription tier of an account. type AccountTier string diff --git a/entity-service/internal/handler/user_handler.go b/entity-service/internal/handler/user_handler.go index 8198624e0b..ad6adab17e 100644 --- a/entity-service/internal/handler/user_handler.go +++ b/entity-service/internal/handler/user_handler.go @@ -39,7 +39,7 @@ func NewUserHandler(svc service.UserService) *UserHandler { return &UserHandler{svc: svc} } -// SearchUsers handles POST /users/search. +// SearchUsers handles POST /users/search for the postgres data source. func (h *UserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) { var req domain.SearchUsersRequest if !decodeRequest(w, r, &req) { @@ -53,3 +53,28 @@ func (h *UserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } + +// SNUserHandler handles HTTP requests for the user resource backed by ServiceNow. +type SNUserHandler struct { + svc service.SNUserService +} + +// NewSNUserHandler constructs an SNUserHandler with the given service. +func NewSNUserHandler(svc service.SNUserService) *SNUserHandler { + return &SNUserHandler{svc: svc} +} + +// SearchUsers handles POST /users/search for the ServiceNow data source. +func (h *SNUserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) { + var req domain.SearchUsersRequest + if !decodeRequest(w, r, &req) { + return + } + resp, err := h.svc.SearchUsers(r.Context(), req) + if err != nil { + writeServiceError(w, r, err) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/entity-service/internal/repository/user_repo.go b/entity-service/internal/repository/user_repo.go index 8fe7199ed8..8731498732 100644 --- a/entity-service/internal/repository/user_repo.go +++ b/entity-service/internal/repository/user_repo.go @@ -81,8 +81,8 @@ func (r *userRepo) SearchUsers(ctx context.Context, req domain.SearchUsersReques where := "WHERE 1=1" - if req.SearchQuery != "" { - escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(req.SearchQuery) + if req.Filters.SearchQuery != "" { + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(req.Filters.SearchQuery) pattern := "%" + escaped + "%" // Both branches reference the same positional parameter — PostgreSQL allows $N to appear multiple times. where += fmt.Sprintf( @@ -93,6 +93,26 @@ func (r *userRepo) SearchUsers(ctx context.Context, req domain.SearchUsersReques argIdx++ } + if len(req.Filters.UserNames) > 0 { + placeholders := make([]string, len(req.Filters.UserNames)) + for i, un := range req.Filters.UserNames { + placeholders[i] = fmt.Sprintf("$%d", argIdx) + filterArgs = append(filterArgs, un) + argIdx++ + } + where += " AND user_name = ANY(ARRAY[" + strings.Join(placeholders, ",") + "])" + } + + if len(req.Filters.Emails) > 0 { + placeholders := make([]string, len(req.Filters.Emails)) + for i, em := range req.Filters.Emails { + placeholders[i] = fmt.Sprintf("$%d", argIdx) + filterArgs = append(filterArgs, em) + argIdx++ + } + where += " AND email = ANY(ARRAY[" + strings.Join(placeholders, ",") + "])" + } + countQuery := "SELECT COUNT(*) FROM users " + where dataQuery := fmt.Sprintf( diff --git a/entity-service/internal/server/routes.go b/entity-service/internal/server/routes.go index aecf520973..c85e5594a8 100644 --- a/entity-service/internal/server/routes.go +++ b/entity-service/internal/server/routes.go @@ -122,10 +122,19 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) http.Handler { productVulnerabilityHandler = handler.NewProductVulnerabilityHandler(service.NewServiceNowProductVulnerabilityService(serviceNowIntegrationServiceClient)) } + var snUserHandler *handler.SNUserHandler + if cfg.DataSource == config.DataSourceServiceNow { + snUserHandler = handler.NewSNUserHandler(service.NewServiceNowUserService(serviceNowIntegrationServiceClient)) + } + mux := http.NewServeMux() mux.HandleFunc("GET /health", handler.HealthCheck) - mux.HandleFunc("POST /users/search", userHandler.SearchUsers) + if snUserHandler != nil { + mux.HandleFunc("POST /users/search", snUserHandler.SearchUsers) + } else { + mux.HandleFunc("POST /users/search", userHandler.SearchUsers) + } mux.HandleFunc("GET /accounts/{id}", accountHandler.GetAccount) mux.HandleFunc("POST /accounts/search", accountHandler.SearchAccounts) mux.HandleFunc("GET /projects/{id}", projectHandler.GetProject) diff --git a/entity-service/internal/service/interfaces.go b/entity-service/internal/service/interfaces.go index a42f90f05e..95ad106c1c 100644 --- a/entity-service/internal/service/interfaces.go +++ b/entity-service/internal/service/interfaces.go @@ -29,11 +29,19 @@ import ( // making it straightforward to substitute a test double in unit tests. type UserService interface { // SearchUsers returns a paginated list of users that match the filters in - // req. A ValidationError is returned for invalid input (e.g. limit > 100); + // req. A ValidationError is returned for invalid input (e.g. limit > 50); // any other error indicates an infrastructure failure. SearchUsers(ctx context.Context, req domain.SearchUsersRequest) (domain.SearchUsersResponse, error) } +// SNUserService defines the user search operation backed by the ServiceNow data source. +type SNUserService interface { + // SearchUsers returns a paginated list of ServiceNow users that match the + // filters in req. A ValidationError is returned for invalid input; any other + // error indicates an infrastructure failure. + SearchUsers(ctx context.Context, req domain.SearchUsersRequest) (domain.SearchSNUsersResponse, error) +} + // AccountService defines the operations available on the account entity. type AccountService interface { // SearchAccounts returns a paginated list of accounts that match the filters diff --git a/entity-service/internal/service/sn_user_service.go b/entity-service/internal/service/sn_user_service.go new file mode 100644 index 0000000000..4c28a1f061 --- /dev/null +++ b/entity-service/internal/service/sn_user_service.go @@ -0,0 +1,199 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package service + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/wso2-open-operations/cs-tools/entity-service/internal/apierror" + "github.com/wso2-open-operations/cs-tools/entity-service/internal/domain" + "github.com/wso2-open-operations/cs-tools/entity-service/internal/middleware" + integrationservice "github.com/wso2-open-operations/cs-tools/entity-service/internal/servicenow-integration-service" +) + +// snUsersResponse mirrors the Choreo POST /users/search response. +type snUsersResponse struct { + Users []snUser `json:"users"` + TotalRecords int `json:"totalRecords"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +type snUser struct { + ID string `json:"id"` + UserName string `json:"userName"` + Name string `json:"name"` + Email string `json:"email"` + TimeZone *string `json:"timeZone"` + Active bool `json:"active"` + CreatedOn string `json:"createdOn"` + UpdatedOn string `json:"updatedOn"` + Roles []string `json:"roles"` +} + +// snUserSearchPayload is the Choreo POST /users/search request body. +type snUserSearchPayload struct { + Filters snUserFilters `json:"filters,omitempty"` + SortBy *snUserSort `json:"sortBy,omitempty"` + Pagination snProjectPagination `json:"pagination"` +} + +type snUserFilters struct { + SearchQuery string `json:"searchQuery,omitempty"` + Roles []string `json:"roles,omitempty"` + UserNames []string `json:"userNames,omitempty"` + Emails []string `json:"emails,omitempty"` + Active *bool `json:"active,omitempty"` +} + +type snUserSort struct { + Field string `json:"field"` + Order string `json:"order"` +} + +var validUserRole = map[domain.UserRole]bool{ + domain.UserRoleInternal: true, + domain.UserRoleAgent: true, + domain.UserRoleAdmin: true, + domain.UserRoleCommenter: true, + domain.UserRoleExternal: true, + domain.UserRoleCustomer: true, + domain.UserRoleCustomerAdmin: true, + domain.UserRolePartner: true, + domain.UserRolePartnerAdmin: true, +} + +var validUserSortField = map[domain.UserSortField]bool{ + domain.UserSortFieldName: true, + domain.UserSortFieldCreatedOn: true, + domain.UserSortFieldUpdatedOn: true, +} + +var validUserSortOrder = map[domain.UserSortOrder]bool{ + domain.UserSortOrderAsc: true, + domain.UserSortOrderDesc: true, +} + +type snUserService struct { + client *integrationservice.Client +} + +// NewServiceNowUserService constructs an SNUserService backed by the Choreo API. +func NewServiceNowUserService(client *integrationservice.Client) SNUserService { + return &snUserService{client: client} +} + +func (s *snUserService) SearchUsers(ctx context.Context, req domain.SearchUsersRequest) (domain.SearchSNUsersResponse, error) { + if err := normalizeUserPagination(&req.Pagination); err != nil { + return domain.SearchSNUsersResponse{}, err + } + if err := validateSearchQuery(req.Filters.SearchQuery); err != nil { + return domain.SearchSNUsersResponse{}, err + } + if len(req.Filters.Roles) > 20 { + return domain.SearchSNUsersResponse{}, &apierror.ValidationError{Msg: "roles cannot contain more than 20 values"} + } + if len(req.Filters.UserNames) > 50 { + return domain.SearchSNUsersResponse{}, &apierror.ValidationError{Msg: "userNames cannot contain more than 50 values"} + } + if len(req.Filters.Emails) > 50 { + return domain.SearchSNUsersResponse{}, &apierror.ValidationError{Msg: "emails cannot contain more than 50 values"} + } + for _, role := range req.Filters.Roles { + if !validUserRole[role] { + return domain.SearchSNUsersResponse{}, &apierror.ValidationError{Msg: "roles contains invalid value: " + string(role)} + } + } + if req.SortBy.Field != "" && !validUserSortField[req.SortBy.Field] { + return domain.SearchSNUsersResponse{}, &apierror.ValidationError{Msg: "sortBy.field contains invalid value: " + string(req.SortBy.Field)} + } + if req.SortBy.Order != "" && req.SortBy.Field == "" { + return domain.SearchSNUsersResponse{}, &apierror.ValidationError{Msg: "sortBy.order requires sortBy.field to be set"} + } + if req.SortBy.Order != "" && !validUserSortOrder[req.SortBy.Order] { + return domain.SearchSNUsersResponse{}, &apierror.ValidationError{Msg: "sortBy.order contains invalid value: " + string(req.SortBy.Order)} + } + + token := middleware.UserIDTokenFromContext(ctx) + if token == "" { + return domain.SearchSNUsersResponse{}, &apierror.UnauthorizedError{Msg: "x-user-id-token header is required"} + } + + roles := make([]string, len(req.Filters.Roles)) + for i, r := range req.Filters.Roles { + roles[i] = string(r) + } + + var snSortBy *snUserSort + if req.SortBy.Field != "" { + order := string(req.SortBy.Order) + if order == "" { + order = "asc" + } + snSortBy = &snUserSort{Field: string(req.SortBy.Field), Order: order} + } + + payload := snUserSearchPayload{ + Filters: snUserFilters{ + SearchQuery: req.Filters.SearchQuery, + Roles: roles, + UserNames: req.Filters.UserNames, + Emails: req.Filters.Emails, + Active: req.Filters.Active, + }, + SortBy: snSortBy, + Pagination: snProjectPagination{Limit: req.Pagination.Limit, Offset: req.Pagination.Offset}, + } + + raw, err := s.client.Post(ctx, "/users/search", token, payload) + if err != nil { + return domain.SearchSNUsersResponse{}, err + } + + var snResp snUsersResponse + if err := json.Unmarshal(raw, &snResp); err != nil { + return domain.SearchSNUsersResponse{}, fmt.Errorf("sn users: parse response: %w", err) + } + + users := make([]domain.SNUser, 0, len(snResp.Users)) + for _, u := range snResp.Users { + roles := u.Roles + if roles == nil { + roles = []string{} + } + users = append(users, domain.SNUser{ + ID: sysidToUUID(u.ID), + UserName: u.UserName, + Name: u.Name, + Email: u.Email, + TimeZone: u.TimeZone, + Active: u.Active, + CreatedOn: u.CreatedOn, + UpdatedOn: u.UpdatedOn, + Roles: roles, + }) + } + + return domain.SearchSNUsersResponse{ + Users: users, + Total: snResp.TotalRecords, + Limit: req.Pagination.Limit, + Offset: req.Pagination.Offset, + }, nil +} diff --git a/entity-service/internal/service/user_service.go b/entity-service/internal/service/user_service.go index 95134d3b39..5861719b69 100644 --- a/entity-service/internal/service/user_service.go +++ b/entity-service/internal/service/user_service.go @@ -44,6 +44,9 @@ const ( defaultLimit = 20 maxLimit = 100 maxSearchQueryLen = 200 + + defaultUserLimit = 10 + maxUserLimit = 50 ) // normalizePagination applies defaults and clamps to p in-place. @@ -61,6 +64,20 @@ func normalizePagination(p *domain.Pagination) error { return nil } +// normalizeUserPagination applies user-search-specific defaults (limit 10, max 50). +func normalizeUserPagination(p *domain.Pagination) error { + if p.Limit <= 0 { + p.Limit = defaultUserLimit + } + if p.Limit > maxUserLimit { + return &apierror.ValidationError{Msg: "limit cannot exceed 50"} + } + if p.Offset < 0 { + p.Offset = 0 + } + return nil +} + // validateSearchQuery returns a ValidationError if q exceeds the character limit. func validateSearchQuery(q string) error { if utf8.RuneCountInString(q) > maxSearchQueryLen { @@ -80,12 +97,27 @@ func NewUserService(repo repository.UserRepository) UserService { // SearchUsers implements UserService. func (s *userService) SearchUsers(ctx context.Context, req domain.SearchUsersRequest) (domain.SearchUsersResponse, error) { - if err := normalizePagination(&req.Pagination); err != nil { + if err := normalizeUserPagination(&req.Pagination); err != nil { return domain.SearchUsersResponse{}, err } - if err := validateSearchQuery(req.SearchQuery); err != nil { + if err := validateSearchQuery(req.Filters.SearchQuery); err != nil { return domain.SearchUsersResponse{}, err } + if len(req.Filters.Roles) > 0 { + return domain.SearchUsersResponse{}, &apierror.ValidationError{Msg: "roles filter is only supported for the ServiceNow data source"} + } + if req.Filters.Active != nil { + return domain.SearchUsersResponse{}, &apierror.ValidationError{Msg: "active filter is only supported for the ServiceNow data source"} + } + if req.SortBy.Field != "" { + return domain.SearchUsersResponse{}, &apierror.ValidationError{Msg: "sortBy is only supported for the ServiceNow data source"} + } + if len(req.Filters.UserNames) > 50 { + return domain.SearchUsersResponse{}, &apierror.ValidationError{Msg: "userNames cannot contain more than 50 values"} + } + if len(req.Filters.Emails) > 50 { + return domain.SearchUsersResponse{}, &apierror.ValidationError{Msg: "emails cannot contain more than 50 values"} + } users, total, err := s.repo.SearchUsers(ctx, req) if err != nil { diff --git a/entity-service/openapi.yaml b/entity-service/openapi.yaml index 4a1195f1c9..cb2f549b2a 100644 --- a/entity-service/openapi.yaml +++ b/entity-service/openapi.yaml @@ -21,7 +21,11 @@ paths: /users/search: post: - summary: Search users by username or email. + summary: Search users with optional filters, sort, and pagination. + description: > + Returns a paginated list of users. When the data source is ServiceNow + the response uses `SNUser` (name, timeZone, active, roles); when the + data source is postgres it uses `User` (firstName, lastName, userType). operationId: searchUsers requestBody: required: true @@ -31,13 +35,15 @@ paths: $ref: '#/components/schemas/SearchUsersRequest' responses: "200": - description: Users matching the search query. + description: Users matching the supplied filters. content: application/json: schema: - $ref: '#/components/schemas/SearchUsersResponse' + oneOf: + - $ref: '#/components/schemas/SearchUsersResponse' + - $ref: '#/components/schemas/SearchSNUsersResponse' "400": - description: Bad request. + description: Bad request — invalid filter value, sort field, or pagination limit. content: application/json: schema: @@ -1317,13 +1323,73 @@ components: type: object properties: pagination: - $ref: '#/components/schemas/Pagination' + $ref: '#/components/schemas/UserPagination' + filters: + $ref: '#/components/schemas/SearchUsersFilters' + sortBy: + $ref: '#/components/schemas/UserSortBy' + + UserPagination: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 50 + default: 10 + description: Number of results per page. Defaults to 10, capped at 50. + offset: + type: integer + minimum: 0 + default: 0 + description: Zero-based offset into the result set. + + SearchUsersFilters: + type: object + properties: searchQuery: type: string + maxLength: 200 description: Case-insensitive match against username and email. + roles: + type: array + maxItems: 20 + items: + type: string + enum: [internal, agent, admin, commenter, external, customer, customer_admin, partner, partner_admin] + description: Filter by one or more ServiceNow roles. ServiceNow data source only. + userNames: + type: array + maxItems: 50 + items: + type: string + description: Filter to specific usernames (exact match). + emails: + type: array + maxItems: 50 + items: + type: string + description: Filter to specific email addresses (exact match). + active: + type: boolean + nullable: true + description: When set, restricts results to active or inactive users. + + UserSortBy: + type: object + properties: + field: + type: string + enum: [name, createdOn, updatedOn] + description: Column to sort by. + order: + type: string + enum: [asc, desc] + description: Sort direction. User: type: object + description: User as stored in the postgres data source. properties: id: type: string @@ -1351,8 +1417,37 @@ components: type: string format: date-time + SNUser: + type: object + description: User as returned by the ServiceNow data source. + properties: + id: + type: string + format: uuid + userName: + type: string + name: + type: string + email: + type: string + timeZone: + type: string + nullable: true + active: + type: boolean + createdOn: + type: string + updatedOn: + type: string + roles: + type: array + items: + type: string + SearchUsersResponse: type: object + description: Paginated user list from the postgres data source. + required: [users, total, limit, offset, hasMore] properties: users: type: array @@ -1367,6 +1462,22 @@ components: hasMore: type: boolean + SearchSNUsersResponse: + type: object + description: Paginated user list from the ServiceNow data source. + required: [users, total, limit, offset] + properties: + users: + type: array + items: + $ref: '#/components/schemas/SNUser' + total: + type: integer + limit: + type: integer + offset: + type: integer + SearchAccountsRequest: type: object properties: