diff --git a/api/http/common.go b/api/http/common.go index c9b5b1d92a..2fe7cfc6a7 100644 --- a/api/http/common.go +++ b/api/http/common.go @@ -37,6 +37,7 @@ const ( MetadataKey = "metadata" NameKey = "name" TagKey = "tag" + TagsKey = "tags" StatusKey = "status" ClientKey = "client" diff --git a/apidocs/openapi/channels.yaml b/apidocs/openapi/channels.yaml index b6f0b9beae..4b2a3cea9b 100644 --- a/apidocs/openapi/channels.yaml +++ b/apidocs/openapi/channels.yaml @@ -89,6 +89,7 @@ paths: - $ref: "#/components/parameters/Offset" - $ref: "#/components/parameters/Order" - $ref: "#/components/parameters/Direction" + - $ref: "#/components/parameters/Tags" - $ref: "#/components/parameters/Metadata" - $ref: "#/components/parameters/Status" - $ref: "#/components/parameters/ChannelName" @@ -776,16 +777,12 @@ components: Tags: name: tags - description: Client tags. + description: Channel tags. Multiple tags can be specified separated by comma for OR condition and hyphen for AND condition. in: query schema: - type: array - minItems: 0 - uniqueItems: true - items: - type: string + type: string required: false - example: ["yello", "orange"] + example: "orange,yellow" ChannelName: name: name diff --git a/apidocs/openapi/clients.yaml b/apidocs/openapi/clients.yaml index 1ff100e460..13a1074eed 100644 --- a/apidocs/openapi/clients.yaml +++ b/apidocs/openapi/clients.yaml @@ -1265,16 +1265,12 @@ components: Tags: name: tags - description: Client tags. + description: Clients tags. Multiple tags can be specified separated by comma for OR condition and hyphen for AND condition. in: query schema: - type: array - minItems: 0 - uniqueItems: true - items: - type: string + type: string required: false - example: ["yello", "orange"] + example: "orange,yellow" Metadata: name: metadata diff --git a/apidocs/openapi/domains.yaml b/apidocs/openapi/domains.yaml index bd185dbc8b..18cbcc1014 100644 --- a/apidocs/openapi/domains.yaml +++ b/apidocs/openapi/domains.yaml @@ -76,6 +76,7 @@ paths: - $ref: "#/components/parameters/Order" - $ref: "#/components/parameters/Direction" - $ref: "#/components/parameters/Metadata" + - $ref: "#/components/parameters/Tags" - $ref: "#/components/parameters/Status" - $ref: "#/components/parameters/DomainName" - $ref: "./schemas/roles.yaml#/components/parameters/ActionsQuery" @@ -1179,6 +1180,14 @@ components: schema: type: object additionalProperties: {} + Tags: + name: tags + description: Domain tags. Multiple tags can be specified separated by comma for OR condition and hyphen for AND condition. + in: query + schema: + type: string + required: false + example: "orange,yellow" Type: name: type description: The type of the API Key. diff --git a/apidocs/openapi/groups.yaml b/apidocs/openapi/groups.yaml index f18d70bc2e..fa4697c559 100644 --- a/apidocs/openapi/groups.yaml +++ b/apidocs/openapi/groups.yaml @@ -92,6 +92,7 @@ paths: - $ref: "#/components/parameters/DirectionOrder" - $ref: "#/components/parameters/Level" - $ref: "#/components/parameters/Tree" + - $ref: "#/components/parameters/Tags" - $ref: "#/components/parameters/Metadata" - $ref: "#/components/parameters/GroupName" - $ref: "#/components/parameters/RootGroup" @@ -1377,16 +1378,12 @@ components: Tags: name: tags - description: User tags. + description: Group tags. Multiple tags can be specified separated by comma for OR condition and hyphen for AND condition. in: query schema: - type: array - minItems: 0 - uniqueItems: true - items: - type: string + type: string required: false - example: ["yello", "orange"] + example: "orange,yellow" GroupName: name: name diff --git a/apidocs/openapi/users.yaml b/apidocs/openapi/users.yaml index b2226eb60a..159be329e5 100644 --- a/apidocs/openapi/users.yaml +++ b/apidocs/openapi/users.yaml @@ -83,7 +83,7 @@ paths: - $ref: "#/components/parameters/LastName" - $ref: "#/components/parameters/Username" - $ref: "#/components/parameters/Email" - - $ref: "#/components/parameters/Tag" + - $ref: "#/components/parameters/Tags" - $ref: "#/components/parameters/OnlyTotal" security: - bearerAuth: [] @@ -1138,14 +1138,14 @@ components: required: false example: enabled - Tag: - name: tag - description: User tag. + Tags: + name: tags + description: User tags. Multiple tags can be specified separated by comma for OR condition and hyphen for AND condition. in: query schema: type: string required: false - example: "orange" + example: "orange,yellow" GroupName: name: name diff --git a/channels/api/http/decode.go b/channels/api/http/decode.go index 9de479dd3f..a64172d9d5 100644 --- a/channels/api/http/decode.go +++ b/channels/api/http/decode.go @@ -63,10 +63,14 @@ func decodeListChannels(_ context.Context, r *http.Request) (any, error) { return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err) } - tag, err := apiutil.ReadStringQuery(r, api.TagKey, "") + tags, err := apiutil.ReadStringQuery(r, api.TagsKey, "") if err != nil { return nil, errors.Wrap(apiutil.ErrValidation, err) } + var tq channels.TagsQuery + if tags != "" { + tq = channels.ToTagsQuery(tags) + } s, err := apiutil.ReadStringQuery(r, api.StatusKey, api.DefGroupStatus) if err != nil { @@ -157,7 +161,7 @@ func decodeListChannels(_ context.Context, r *http.Request) (any, error) { req := listChannelsReq{ Page: channels.Page{ Name: name, - Tag: tag, + Tags: tq, Status: status, Metadata: meta, RoleName: roleName, diff --git a/channels/api/http/endpoint_test.go b/channels/api/http/endpoint_test.go index 8607c5e61f..b24baedbc7 100644 --- a/channels/api/http/endpoint_test.go +++ b/channels/api/http/endpoint_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -521,6 +522,7 @@ func TestListChannels(t *testing.T) { domainID string token string session smqauthn.Session + pageMeta channels.Page listChannelsResponse channels.ChannelsPage status int authnErr error @@ -531,6 +533,13 @@ func TestListChannels(t *testing.T) { domainID: validID, token: validToken, status: http.StatusOK, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -558,6 +567,13 @@ func TestListChannels(t *testing.T) { desc: "list channels with offset", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 1, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -580,6 +596,13 @@ func TestListChannels(t *testing.T) { desc: "list channels with limit", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 1, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -610,6 +633,14 @@ func TestListChannels(t *testing.T) { desc: "list channels with name", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Name: "clientname", + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -620,14 +651,6 @@ func TestListChannels(t *testing.T) { status: http.StatusOK, err: nil, }, - { - desc: "list channels with invalid name", - domainID: validID, - token: validToken, - query: "name=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, { desc: "list channels with duplicate name", domainID: validID, @@ -640,6 +663,14 @@ func TestListChannels(t *testing.T) { desc: "list channels with status", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Status: channels.EnabledStatus, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -667,122 +698,114 @@ func TestListChannels(t *testing.T) { err: apiutil.ErrInvalidQueryParams, }, { - desc: "list channels with tags", + desc: "list channels with single tag", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: channels.TagsQuery{Elements: []string{"tag1"}, Operator: channels.OrOp}, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, }, Channels: []channels.Channel{validChannelResp}, }, - query: "tag=tag1,tag2", + query: "tags=tag1", status: http.StatusOK, err: nil, }, { - desc: "list channels with invalid tags", - domainID: validID, - token: validToken, - query: "tag=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, - { - desc: "list channels with duplicate tags", - domainID: validID, - token: validToken, - query: "tag=tag1&tag=tag2", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list channels with metadata", + desc: "list channels with multiple tags and OR operator", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: channels.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: channels.OrOp}, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, }, Channels: []channels.Channel{validChannelResp}, }, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&", + query: "tags=tag1,tag2,tag3", status: http.StatusOK, err: nil, }, { - desc: "list channels with invalid metadata", - domainID: validID, - token: validToken, - query: "metadata=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list channels with duplicate metadata", - domainID: validID, - token: validToken, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&metadata=%7B%22domain%22%3A%20%22example.com%22%7D", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list channels with permissions", + desc: "list channels with multiple tags and AND operator", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: channels.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: channels.AndOp}, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, }, Channels: []channels.Channel{validChannelResp}, }, - query: "permission=view", + query: "tags=tag1-tag2-tag3", status: http.StatusOK, err: nil, }, { - desc: "list channels with invalid permissions", - domainID: validID, - token: validToken, - query: "permission=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, - { - desc: "list channels with duplicate permissions", + desc: "list channels with duplicate tags", domainID: validID, token: validToken, - query: "permission=view&permission=view", + query: "tags=tag1&tags=tag2", status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, { - desc: "list channels with list perms", + desc: "list channels with metadata", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Metadata: channels.Metadata{"domain": "example.com"}, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, }, Channels: []channels.Channel{validChannelResp}, }, - query: "list_perms=true", + query: fmt.Sprintf("metadata=%s", url.PathEscape(`{"domain": "example.com"}`)), status: http.StatusOK, err: nil, }, { - desc: "list channels with invalid list perms", + desc: "list channels with invalid metadata", domainID: validID, token: validToken, - query: "list_perms=invalid", + query: "metadata=invalid", status: http.StatusBadRequest, - err: apiutil.ErrValidation, + err: apiutil.ErrInvalidQueryParams, }, { - desc: "list channels with duplicate list perms", + desc: "list channels with duplicate metadata", domainID: validID, token: validToken, - query: "list_perms=true&listPerms=true", + query: fmt.Sprintf("metadata=%s&metadata=%s", url.PathEscape(`{"domain": "example.com"}`), url.PathEscape(`{"domain": "example.com"}`)), status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, @@ -790,6 +813,14 @@ func TestListChannels(t *testing.T) { desc: "list channels with client ID", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Client: validID, + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -804,6 +835,15 @@ func TestListChannels(t *testing.T) { desc: "list channels with client ID and connection type publish", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Client: validID, + ConnectionType: "publish", + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -818,6 +858,15 @@ func TestListChannels(t *testing.T) { desc: "list channels with client ID and connection type subscribe", domainID: validID, token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Client: validID, + ConnectionType: "subscribe", + }, listChannelsResponse: channels.ChannelsPage{ Page: channels.Page{ Total: 1, @@ -859,7 +908,7 @@ func TestListChannels(t *testing.T) { tc.session = smqauthn.Session{DomainUserID: validID + "_" + validID, UserID: validID, DomainID: validID} } authCall := authn.On("Authenticate", mock.Anything, tc.token).Return(tc.session, tc.authnErr) - svcCall := svc.On("ListChannels", mock.Anything, tc.session, mock.Anything).Return(tc.listChannelsResponse, tc.err) + svcCall := svc.On("ListChannels", mock.Anything, tc.session, tc.pageMeta).Return(tc.listChannelsResponse, tc.err) res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) var bodyRes respBody diff --git a/channels/channels.go b/channels/channels.go index 716513e0bf..fc0ab21228 100644 --- a/channels/channels.go +++ b/channels/channels.go @@ -5,6 +5,7 @@ package channels import ( "context" + "strings" "time" "github.com/absmach/supermq/internal/nullable" @@ -46,6 +47,37 @@ type Channel struct { Roles []roles.MemberRoleActions `json:"roles,omitempty"` } +type Operator uint8 + +const ( + OrOp Operator = iota + AndOp +) + +type TagsQuery struct { + Elements []string + Operator Operator +} + +func ToTagsQuery(s string) TagsQuery { + switch { + case strings.Contains(s, "-"): + elements := strings.Split(s, "-") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: AndOp} + case strings.Contains(s, ","): + elements := strings.Split(s, ",") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: OrOp} + default: + return TagsQuery{Elements: []string{s}, Operator: OrOp} + } +} + type Page struct { Total uint64 `json:"total"` Offset uint64 `json:"offset"` @@ -57,7 +89,7 @@ type Page struct { Name string `json:"name,omitempty"` Metadata Metadata `json:"metadata,omitempty"` Domain string `json:"domain,omitempty"` - Tag string `json:"tag,omitempty"` + Tags TagsQuery `json:"tags,omitempty"` Status Status `json:"status,omitempty"` Group nullable.Value[string] `json:"group,omitempty"` Client string `json:"client,omitempty"` diff --git a/channels/events/events.go b/channels/events/events.go index 42bc1d7db8..3e54de49da 100644 --- a/channels/events/events.go +++ b/channels/events/events.go @@ -221,8 +221,8 @@ func (lce listChannelEvent) Encode() (map[string]any, error) { if lce.Metadata != nil { val["metadata"] = lce.Metadata } - if lce.Tag != "" { - val["tag"] = lce.Tag + if len(lce.Tags.Elements) > 0 { + val["tag"] = lce.Tags.Elements } if lce.Status.String() != "" { val["status"] = lce.Status.String() @@ -270,8 +270,8 @@ func (luce listUserChannelsEvent) Encode() (map[string]any, error) { if luce.Domain != "" { val["domain"] = luce.Domain } - if luce.Tag != "" { - val["tag"] = luce.Tag + if len(luce.Tags.Elements) > 0 { + val["tag"] = luce.Tags.Elements } if luce.Status.String() != "" { val["status"] = luce.Status.String() diff --git a/channels/postgres/channels.go b/channels/postgres/channels.go index 02d3d6f46e..0311e83f30 100644 --- a/channels/postgres/channels.go +++ b/channels/postgres/channels.go @@ -1289,8 +1289,13 @@ func PageQuery(pm channels.Page) (string, error) { if pm.ID != "" { query = append(query, "c.id = :id") } - if pm.Tag != "" { - query = append(query, "EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE '%' || :tag || '%')") + if len(pm.Tags.Elements) > 0 { + switch pm.Tags.Operator { + case channels.AndOp: + query = append(query, "tags @> :tags") + default: // OR + query = append(query, "tags && :tags") + } } if mq != "" { @@ -1373,6 +1378,10 @@ func toDBChannelsPage(pm channels.Page) (dbChannelsPage, error) { if err != nil { return dbChannelsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) } + var tags pgtype.TextArray + if err := tags.Set(pm.Tags.Elements); err != nil { + return dbChannelsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } var connType uint8 if pm.ConnectionType != "" { @@ -1390,7 +1399,7 @@ func toDBChannelsPage(pm channels.Page) (dbChannelsPage, error) { Id: pm.ID, Domain: pm.Domain, Metadata: data, - Tag: pm.Tag, + Tags: tags, Status: pm.Status, GroupID: sql.NullString{Valid: pm.Group.Valid, String: pm.Group.Value}, ClientID: pm.Client, @@ -1403,21 +1412,21 @@ func toDBChannelsPage(pm channels.Page) (dbChannelsPage, error) { } type dbChannelsPage struct { - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Name string `db:"name"` - Id string `db:"id"` - Domain string `db:"domain_id"` - Metadata []byte `db:"metadata"` - Tag string `db:"tag"` - Status channels.Status `db:"status"` - GroupID sql.NullString `db:"group_id"` - ClientID string `db:"client_id"` - ConnType uint8 `db:"conn_type"` - RoleName string `db:"role_name"` - RoleID string `db:"role_id"` - Actions pq.StringArray `db:"actions"` - AccessType string `db:"access_type"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Name string `db:"name"` + Id string `db:"id"` + Domain string `db:"domain_id"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + Status channels.Status `db:"status"` + GroupID sql.NullString `db:"group_id"` + ClientID string `db:"client_id"` + ConnType uint8 `db:"conn_type"` + RoleName string `db:"role_name"` + RoleID string `db:"role_id"` + Actions pq.StringArray `db:"actions"` + AccessType string `db:"access_type"` } type dbConnection struct { diff --git a/channels/postgres/channels_test.go b/channels/postgres/channels_test.go index 0f6d8a2368..fa4d55226f 100644 --- a/channels/postgres/channels_test.go +++ b/channels/postgres/channels_test.go @@ -615,6 +615,10 @@ func TestRetrieveAll(t *testing.T) { CreatedAt: time.Now().UTC().Truncate(time.Microsecond), Status: channels.EnabledStatus, ConnectionTypes: []connections.ConnType{}, + Tags: []string{"tag1", "tag2"}, + } + if i%99 == 0 { + channel.Tags = []string{"tag1", "tag3"} } _, err := repo.Save(context.Background(), channel) require.Nil(t, err, fmt.Sprintf("create channel unexpected error: %s", err)) @@ -936,56 +940,80 @@ func TestRetrieveAll(t *testing.T) { err: nil, }, { - desc: "retrieve channels with client ID", + desc: "retrieve channels with single tag", page: channels.ChannelsPage{ Page: channels.Page{ Offset: 0, - Limit: 10, - Client: testsutil.GenerateUUID(t), + Limit: uint64(num), + Tags: channels.TagsQuery{Elements: []string{"tag1"}, Operator: channels.OrOp}, + Status: channels.AllStatus, }, }, response: channels.ChannelsPage{ Page: channels.Page{ - Total: 0, + Total: 200, Offset: 0, - Limit: 10, + Limit: uint64(num), }, - Channels: []channels.Channel(nil), + Channels: items, }, - err: nil, }, { - desc: "retrieve channels with client ID and connection type", + desc: "retrieve channel with multiple tags and OR operator", page: channels.ChannelsPage{ Page: channels.Page{ - Offset: 0, - Limit: 10, - Client: testsutil.GenerateUUID(t), - ConnectionType: "subscribe", + Offset: 0, + Limit: uint64(num), + Tags: channels.TagsQuery{Elements: []string{"tag2", "tag3"}, Operator: channels.OrOp}, + Status: channels.AllStatus, }, }, response: channels.ChannelsPage{ Page: channels.Page{ - Total: 0, + Total: 200, Offset: 0, - Limit: 10, + Limit: uint64(num), }, - Channels: []channels.Channel(nil), + Channels: items, }, - err: nil, }, { - desc: "retrieve channels with invalid connection type", + desc: "retrieve channel with multiple tags and AND operator", page: channels.ChannelsPage{ Page: channels.Page{ - Offset: 0, - Limit: 10, - Client: testsutil.GenerateUUID(t), - ConnectionType: "invalid_type", + Offset: 0, + Limit: uint64(num), + Tags: channels.TagsQuery{Elements: []string{"tag1", "tag3"}, Operator: channels.AndOp}, + Status: channels.AllStatus, }, }, - response: channels.ChannelsPage{}, - err: repoerr.ErrViewEntity, + response: channels.ChannelsPage{ + Page: channels.Page{ + Total: 3, + Offset: 0, + Limit: uint64(num), + }, + Channels: []channels.Channel{items[0], items[99], items[198]}, + }, + }, + { + desc: "retrieve channel with invalid tags", + page: channels.ChannelsPage{ + Page: channels.Page{ + Offset: 0, + Limit: uint64(num), + Tags: channels.TagsQuery{Elements: []string{namegen.Generate(), namegen.Generate()}, Operator: channels.OrOp}, + Status: channels.AllStatus, + }, + }, + response: channels.ChannelsPage{ + Page: channels.Page{ + Total: 0, + Offset: 0, + Limit: uint64(num), + }, + Channels: []channels.Channel(nil), + }, }, } @@ -2259,7 +2287,7 @@ func TestRetrieveUserChannels(t *testing.T) { pm: channels.Page{ Offset: 0, Limit: nChannels, - Tag: directChannels[0].Tags[0], + Tags: channels.TagsQuery{Elements: []string{directChannels[0].Tags[0]}, Operator: channels.OrOp}, Status: channels.AllStatus, Order: defOrder, Dir: defDir, @@ -2280,7 +2308,7 @@ func TestRetrieveUserChannels(t *testing.T) { pm: channels.Page{ Offset: 0, Limit: nChannels, - Tag: namegen.Generate(), + Tags: channels.TagsQuery{Elements: []string{namegen.Generate()}, Operator: channels.OrOp}, Status: channels.AllStatus, Order: defOrder, Dir: defDir, @@ -2303,7 +2331,7 @@ func TestRetrieveUserChannels(t *testing.T) { Limit: nChannels, Metadata: directChannels[0].Metadata, Name: directChannels[0].Name, - Tag: directChannels[0].Tags[0], + Tags: channels.TagsQuery{Elements: []string{directChannels[0].Tags[0]}, Operator: channels.OrOp}, Status: channels.AllStatus, }, response: channels.ChannelsPage{ diff --git a/clients/api/http/decode.go b/clients/api/http/decode.go index 74641181d5..14b0648f9f 100644 --- a/clients/api/http/decode.go +++ b/clients/api/http/decode.go @@ -38,10 +38,14 @@ func decodeListClients(_ context.Context, r *http.Request) (any, error) { return listClientsReq{}, errors.Wrap(apiutil.ErrValidation, err) } - tag, err := apiutil.ReadStringQuery(r, api.TagKey, "") + tags, err := apiutil.ReadStringQuery(r, api.TagsKey, "") if err != nil { return nil, errors.Wrap(apiutil.ErrValidation, err) } + var tq clients.TagsQuery + if tags != "" { + tq = clients.ToTagsQuery(tags) + } s, err := apiutil.ReadStringQuery(r, api.StatusKey, api.DefGroupStatus) if err != nil { @@ -139,7 +143,7 @@ func decodeListClients(_ context.Context, r *http.Request) (any, error) { req := listClientsReq{ Page: clients.Page{ Name: name, - Tag: tag, + Tags: tq, Status: status, Metadata: meta, RoleName: roleName, diff --git a/clients/api/http/endpoints_test.go b/clients/api/http/endpoints_test.go index fe3eb6ecb8..b2edfa5d91 100644 --- a/clients/api/http/endpoints_test.go +++ b/clients/api/http/endpoints_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -427,6 +428,7 @@ func TestListClients(t *testing.T) { query string domainID string token string + pageMeta clients.Page listClientsResponse clients.ClientsPage status int authnRes smqauthn.Session @@ -439,6 +441,13 @@ func TestListClients(t *testing.T) { token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, status: http.StatusOK, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, @@ -453,6 +462,13 @@ func TestListClients(t *testing.T) { token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, status: http.StatusOK, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, @@ -481,6 +497,13 @@ func TestListClients(t *testing.T) { domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 1, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Offset: 1, @@ -506,6 +529,13 @@ func TestListClients(t *testing.T) { domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 1, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Limit: 1, @@ -540,6 +570,14 @@ func TestListClients(t *testing.T) { domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Name: "clientname", + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, @@ -550,15 +588,6 @@ func TestListClients(t *testing.T) { status: http.StatusOK, err: nil, }, - { - desc: "list clients with invalid name", - domainID: domainID, - token: validToken, - authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "name=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, { desc: "list clients with duplicate name", domainID: domainID, @@ -573,6 +602,14 @@ func TestListClients(t *testing.T) { domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Status: clients.EnabledStatus, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, @@ -602,134 +639,121 @@ func TestListClients(t *testing.T) { err: apiutil.ErrInvalidQueryParams, }, { - desc: "list clients with tags", + desc: "list clients with single tag", domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: clients.TagsQuery{Elements: []string{"tag1"}, Operator: clients.OrOp}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, }, Clients: []clients.Client{client}, }, - query: "tag=tag1,tag2", + query: "tags=tag1", status: http.StatusOK, err: nil, }, { - desc: "list clients with invalid tags", - domainID: domainID, - token: validToken, - authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "tag=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, - { - desc: "list clients with duplicate tags", - domainID: domainID, - token: validToken, - authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "tag=tag1&tag=tag2", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list clients with metadata", + desc: "list clients with multiple tags and OR operator", domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: clients.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: clients.OrOp}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, }, Clients: []clients.Client{client}, }, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&", + query: "tags=tag1,tag2,tag3", status: http.StatusOK, err: nil, }, { - desc: "list clients with invalid metadata", - domainID: domainID, - token: validToken, - authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "metadata=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list clients with duplicate metadata", - domainID: domainID, - token: validToken, - authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&metadata=%7B%22domain%22%3A%20%22example.com%22%7D", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list clients with permissions", + desc: "list clients with multiple tags and AND operator", domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: clients.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: clients.AndOp}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, }, Clients: []clients.Client{client}, }, - query: "permission=view", + query: "tags=tag1-tag2-tag3", status: http.StatusOK, err: nil, }, { - desc: "list clients with invalid permissions", + desc: "list clients with duplicate tags", domainID: domainID, - token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "permission=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, - { - desc: "list clients with duplicate permissions", - domainID: domainID, token: validToken, - authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "permission=view&permission=view", + query: "tags=tag1&tags=tag2", status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, { - desc: "list clients with list perms", + desc: "list clients with metadata", domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Metadata: clients.Metadata{"domain": "example.com"}, + }, listClientsResponse: clients.ClientsPage{ Page: clients.Page{ Total: 1, }, Clients: []clients.Client{client}, }, - query: "list_perms=true", + query: fmt.Sprintf("metadata=%s", url.PathEscape(`{"domain": "example.com"}`)), status: http.StatusOK, err: nil, }, { - desc: "list clients with invalid list perms", + desc: "list clients with invalid metadata", domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "list_perms=invalid", + query: "metadata=invalid", status: http.StatusBadRequest, - err: apiutil.ErrValidation, + err: apiutil.ErrInvalidQueryParams, }, { - desc: "list clients with duplicate list perms", + desc: "list clients with duplicate metadata", domainID: domainID, token: validToken, authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, - query: "list_perms=true&listPerms=true", + query: fmt.Sprintf("metadata=%s&metadata=%s", url.PathEscape(`{"domain": "example.com"}`), url.PathEscape(`{"domain": "example.com"}`)), status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, @@ -746,7 +770,7 @@ func TestListClients(t *testing.T) { } authCall := authn.On("Authenticate", mock.Anything, tc.token).Return(tc.authnRes, tc.authnErr) - svcCall := svc.On("ListClients", mock.Anything, tc.authnRes, mock.Anything).Return(tc.listClientsResponse, tc.err) + svcCall := svc.On("ListClients", mock.Anything, tc.authnRes, tc.pageMeta).Return(tc.listClientsResponse, tc.err) res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) diff --git a/clients/clients.go b/clients/clients.go index be9c1031bd..6826bc4bc2 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -5,6 +5,7 @@ package clients import ( "context" + "strings" "time" "github.com/absmach/supermq/pkg/authn" @@ -194,30 +195,61 @@ type MembersPage struct { Members []Client } +type Operator uint8 + +const ( + OrOp Operator = iota + AndOp +) + +type TagsQuery struct { + Elements []string + Operator Operator +} + +func ToTagsQuery(s string) TagsQuery { + switch { + case strings.Contains(s, "-"): + elements := strings.Split(s, "-") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: AndOp} + case strings.Contains(s, ","): + elements := strings.Split(s, ",") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: OrOp} + default: + return TagsQuery{Elements: []string{s}, Operator: OrOp} + } +} + // Page contains the page metadata that helps navigation. type Page struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - OnlyTotal bool `json:"only_total"` - Order string `json:"order,omitempty"` - Dir string `json:"dir,omitempty"` - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Metadata Metadata `json:"metadata,omitempty"` - Domain string `json:"domain,omitempty"` - Tag string `json:"tag,omitempty"` - Status Status `json:"status,omitempty"` - Identity string `json:"identity,omitempty"` - Group *string `json:"group,omitempty"` - Channel string `json:"channel,omitempty"` - ConnectionType string `json:"connection_type,omitempty"` - RoleName string `json:"role_name,omitempty"` - RoleID string `json:"role_id,omitempty"` - Actions []string `json:"actions,omitempty"` - AccessType string `json:"access_type,omitempty"` - IDs []string `json:"-"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + OnlyTotal bool `json:"only_total"` + Order string `json:"order,omitempty"` + Dir string `json:"dir,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Domain string `json:"domain,omitempty"` + Tags TagsQuery `json:"tags,omitempty"` + Status Status `json:"status,omitempty"` + Identity string `json:"identity,omitempty"` + Group *string `json:"group,omitempty"` + Channel string `json:"channel,omitempty"` + ConnectionType string `json:"connection_type,omitempty"` + RoleName string `json:"role_name,omitempty"` + RoleID string `json:"role_id,omitempty"` + Actions []string `json:"actions,omitempty"` + AccessType string `json:"access_type,omitempty"` + IDs []string `json:"-"` } // Metadata represents arbitrary JSON. diff --git a/clients/events/events.go b/clients/events/events.go index 7510b5e832..e1e295391e 100644 --- a/clients/events/events.go +++ b/clients/events/events.go @@ -246,8 +246,8 @@ func (lce listClientEvent) Encode() (map[string]any, error) { if lce.Metadata != nil { val["metadata"] = lce.Metadata } - if lce.Tag != "" { - val["tag"] = lce.Tag + if len(lce.Tags.Elements) > 0 { + val["tag"] = lce.Tags.Elements } if lce.Status.String() != "" { val["status"] = lce.Status.String() @@ -294,8 +294,8 @@ func (lce listUserClientEvent) Encode() (map[string]any, error) { if lce.Metadata != nil { val["metadata"] = lce.Metadata } - if lce.Tag != "" { - val["tag"] = lce.Tag + if len(lce.Tags.Elements) > 0 { + val["tag"] = lce.Tags.Elements } if lce.Status.String() != "" { val["status"] = lce.Status.String() @@ -343,8 +343,8 @@ func (lcge listClientByGroupEvent) Encode() (map[string]any, error) { if lcge.Metadata != nil { val["metadata"] = lcge.Metadata } - if lcge.Tag != "" { - val["tag"] = lcge.Tag + if len(lcge.Tags.Elements) > 0 { + val["tag"] = lcge.Tags.Elements } if lcge.Status.String() != "" { val["status"] = lcge.Status.String() diff --git a/clients/postgres/clients.go b/clients/postgres/clients.go index 318037e37e..287319bfae 100644 --- a/clients/postgres/clients.go +++ b/clients/postgres/clients.go @@ -1166,6 +1166,10 @@ func ToDBClientsPage(pm clients.Page) (dbClientsPage, error) { if err != nil { return dbClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) } + var tags pgtype.TextArray + if err := tags.Set(pm.Tags.Elements); err != nil { + return dbClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } return dbClientsPage{ Offset: pm.Offset, Limit: pm.Limit, @@ -1175,7 +1179,7 @@ func ToDBClientsPage(pm clients.Page) (dbClientsPage, error) { Metadata: data, Domain: pm.Domain, Status: pm.Status, - Tag: pm.Tag, + Tags: tags, GroupID: pm.Group, ChannelID: pm.Channel, RoleName: pm.RoleName, @@ -1187,22 +1191,22 @@ func ToDBClientsPage(pm clients.Page) (dbClientsPage, error) { } type dbClientsPage struct { - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Name string `db:"name"` - Id string `db:"id"` - Domain string `db:"domain_id"` - Identity string `db:"identity"` - Metadata []byte `db:"metadata"` - Tag string `db:"tag"` - Status clients.Status `db:"status"` - GroupID *string `db:"group_id"` - ChannelID string `db:"channel_id"` - ConnType string `db:"type"` - RoleName string `db:"role_name"` - RoleID string `db:"role_id"` - Actions pq.StringArray `db:"actions"` - AccessType string `db:"access_type"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Name string `db:"name"` + Id string `db:"id"` + Domain string `db:"domain_id"` + Identity string `db:"identity"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + Status clients.Status `db:"status"` + GroupID *string `db:"group_id"` + ChannelID string `db:"channel_id"` + ConnType string `db:"type"` + RoleName string `db:"role_name"` + RoleID string `db:"role_id"` + Actions pq.StringArray `db:"actions"` + AccessType string `db:"access_type"` } func PageQuery(pm clients.Page) (string, error) { @@ -1221,10 +1225,14 @@ func PageQuery(pm clients.Page) (string, error) { if pm.ID != "" { query = append(query, "c.id = :id") } - if pm.Tag != "" { - query = append(query, "EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE '%' || :tag || '%')") + if len(pm.Tags.Elements) > 0 { + switch pm.Tags.Operator { + case clients.AndOp: + query = append(query, "tags @> :tags") + default: // OR + query = append(query, "tags && :tags") + } } - if mq != "" { query = append(query, mq) } diff --git a/clients/postgres/clients_test.go b/clients/postgres/clients_test.go index 724250e74e..95a4cde5ef 100644 --- a/clients/postgres/clients_test.go +++ b/clients/postgres/clients_test.go @@ -1056,7 +1056,7 @@ func TestRetrieveAll(t *testing.T) { Identity: namegen.Generate() + emailSuffix, Secret: testsutil.GenerateUUID(t), }, - Tags: namegen.GenerateMultiple(5), + Tags: []string{"tag1", "tag2"}, Metadata: clients.Metadata{ "department": namegen.Generate(), }, @@ -1066,6 +1066,9 @@ func TestRetrieveAll(t *testing.T) { if i%50 == 0 { client.Status = clients.DisabledStatus } + if i%99 == 0 { + client.Tags = []string{"tag1", "tag3"} + } _, err := repo.Save(context.Background(), client) if i == 0 { conn := clients.Connection{ @@ -1421,20 +1424,54 @@ func TestRetrieveAll(t *testing.T) { }, }, { - desc: "with tag", + desc: "with single tag", pm: clients.Page{ Offset: 0, Limit: nClients, - Tag: expectedClients[0].Tags[0], + Tags: clients.TagsQuery{Elements: []string{"tag1"}, Operator: clients.OrOp}, Status: clients.AllStatus, }, response: clients.ClientsPage{ Page: clients.Page{ - Total: 1, + Total: 200, Offset: 0, Limit: uint64(nClients), }, - Clients: []clients.Client{expectedClients[0]}, + Clients: expectedClients, + }, + }, + { + desc: "with multiple tags and OR operator", + pm: clients.Page{ + Offset: 0, + Limit: nClients, + Tags: clients.TagsQuery{Elements: []string{"tag2", "tag3"}, Operator: clients.OrOp}, + Status: clients.AllStatus, + }, + response: clients.ClientsPage{ + Page: clients.Page{ + Total: 200, + Offset: 0, + Limit: uint64(nClients), + }, + Clients: expectedClients, + }, + }, + { + desc: "with multiple tags and AND operator", + pm: clients.Page{ + Offset: 0, + Limit: nClients, + Tags: clients.TagsQuery{Elements: []string{"tag1", "tag3"}, Operator: clients.AndOp}, + Status: clients.AllStatus, + }, + response: clients.ClientsPage{ + Page: clients.Page{ + Total: 3, + Offset: 0, + Limit: uint64(nClients), + }, + Clients: []clients.Client{expectedClients[0], expectedClients[99], expectedClients[198]}, }, }, { @@ -1442,7 +1479,7 @@ func TestRetrieveAll(t *testing.T) { pm: clients.Page{ Offset: 0, Limit: nClients, - Tag: namegen.Generate(), + Tags: clients.TagsQuery{Elements: []string{namegen.Generate(), namegen.Generate()}, Operator: clients.OrOp}, Status: clients.AllStatus, }, response: clients.ClientsPage{ @@ -1461,7 +1498,7 @@ func TestRetrieveAll(t *testing.T) { Limit: nClients, Metadata: expectedClients[0].Metadata, Name: expectedClients[0].Name, - Tag: expectedClients[0].Tags[0], + Tags: clients.TagsQuery{Elements: []string{expectedClients[0].Tags[0]}, Operator: clients.OrOp}, Identity: expectedClients[0].Credentials.Identity, Domain: expectedClients[0].Domain, Status: clients.AllStatus, @@ -1929,7 +1966,7 @@ func TestRetrieveUserClients(t *testing.T) { pm: clients.Page{ Offset: 0, Limit: nClients, - Tag: directClients[0].Tags[0], + Tags: clients.TagsQuery{Elements: []string{directClients[0].Tags[0]}, Operator: clients.OrOp}, Status: clients.AllStatus, Order: defOrder, Dir: defDir, @@ -1950,7 +1987,7 @@ func TestRetrieveUserClients(t *testing.T) { pm: clients.Page{ Offset: 0, Limit: nClients, - Tag: namegen.Generate(), + Tags: clients.TagsQuery{Elements: []string{namegen.Generate()}, Operator: clients.OrOp}, Status: clients.AllStatus, Order: defOrder, Dir: defDir, @@ -1973,7 +2010,7 @@ func TestRetrieveUserClients(t *testing.T) { Limit: nClients, Metadata: directClients[0].Metadata, Name: directClients[0].Name, - Tag: directClients[0].Tags[0], + Tags: clients.TagsQuery{Elements: []string{directClients[0].Tags[0]}, Operator: clients.OrOp}, Identity: directClients[0].Credentials.Identity, Status: clients.AllStatus, }, diff --git a/domains/api/http/decode.go b/domains/api/http/decode.go index f0180008e8..72a5e018c2 100644 --- a/domains/api/http/decode.go +++ b/domains/api/http/decode.go @@ -130,10 +130,14 @@ func decodePageRequest(_ context.Context, r *http.Request) (domains.Page, error) if err != nil { return domains.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - t, err := apiutil.ReadStringQuery(r, api.TagKey, "") + t, err := apiutil.ReadStringQuery(r, api.TagsKey, "") if err != nil { return domains.Page{}, errors.Wrap(apiutil.ErrValidation, err) } + var tq domains.TagsQuery + if t != "" { + tq = domains.ToTagsQuery(t) + } allActions, err := apiutil.ReadStringQuery(r, api.ActionsKey, "") if err != nil { @@ -173,7 +177,7 @@ func decodePageRequest(_ context.Context, r *http.Request) (domains.Page, error) Limit: l, Name: n, Metadata: m, - Tag: t, + Tags: tq, RoleID: roleID, RoleName: roleName, Actions: actions, diff --git a/domains/api/http/endpoint_test.go b/domains/api/http/endpoint_test.go index 0bcecda852..a40c83120b 100644 --- a/domains/api/http/endpoint_test.go +++ b/domains/api/http/endpoint_test.go @@ -419,34 +419,63 @@ func TestListDomains(t *testing.T) { err: apiutil.ErrInvalidQueryParams, }, { - desc: "list domains with tags", + desc: "list domains with single tag", token: validToken, + page: domains.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Tags: domains.TagsQuery{Elements: []string{"tag1"}, Operator: domains.OrOp}, + }, listDomainsResp: domains.DomainsPage{ Total: 1, Domains: []domains.Domain{domain}, }, - query: "tag=tag1", + query: "tags=tag1", + status: http.StatusOK, + err: nil, + }, + { + desc: "list domains with multiple tags and OR operator", + token: validToken, page: domains.Page{ - Offset: api.DefOffset, - Limit: api.DefLimit, + Offset: 0, + Limit: 10, Order: api.DefOrder, Dir: api.DefDir, - Tag: "tag1", + Tags: domains.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: domains.OrOp}, + }, + listDomainsResp: domains.DomainsPage{ + Total: 1, + Domains: []domains.Domain{domain}, }, + query: "tags=tag1,tag2,tag3", status: http.StatusOK, err: nil, }, { - desc: "list domains with empty tags", - token: validToken, - query: "tag= ", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, + desc: "list domains with multiple tags and AND operator", + token: validToken, + page: domains.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Tags: domains.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: domains.AndOp}, + }, + listDomainsResp: domains.DomainsPage{ + Total: 1, + Domains: []domains.Domain{domain}, + }, + query: "tags=tag1-tag2-tag3", + status: http.StatusOK, + err: nil, }, { - desc: "list domains with duplicate tags", + desc: "list domains with duplicate tags", token: validToken, - query: "tag=tag1&tag=tag2", + query: "tags=tag1&tags=tag2", status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, diff --git a/domains/domains.go b/domains/domains.go index 84e07d58f8..cc91eb3dc2 100644 --- a/domains/domains.go +++ b/domains/domains.go @@ -123,24 +123,55 @@ type Domain struct { Roles []roles.MemberRoleActions `json:"roles,omitempty"` } +type Operator uint8 + +const ( + OrOp Operator = iota + AndOp +) + +type TagsQuery struct { + Elements []string + Operator Operator +} + +func ToTagsQuery(s string) TagsQuery { + switch { + case strings.Contains(s, "-"): + elements := strings.Split(s, "-") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: AndOp} + case strings.Contains(s, ","): + elements := strings.Split(s, ",") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: OrOp} + default: + return TagsQuery{Elements: []string{s}, Operator: OrOp} + } +} + type Page struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - OnlyTotal bool `json:"only_total"` - Name string `json:"name,omitempty"` - Order string `json:"-"` - Dir string `json:"-"` - Metadata Metadata `json:"metadata,omitempty"` - Tag string `json:"tag,omitempty"` - RoleName string `json:"role_name,omitempty"` - RoleID string `json:"role_id,omitempty"` - Actions []string `json:"actions,omitempty"` - Status Status `json:"status,omitempty"` - ID string `json:"id,omitempty"` - IDs []string `json:"-"` - Identity string `json:"identity,omitempty"` - UserID string `json:"user_id,omitempty"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + OnlyTotal bool `json:"only_total"` + Name string `json:"name,omitempty"` + Order string `json:"-"` + Dir string `json:"-"` + Metadata Metadata `json:"metadata,omitempty"` + Tags TagsQuery `json:"tags,omitempty"` + RoleName string `json:"role_name,omitempty"` + RoleID string `json:"role_id,omitempty"` + Actions []string `json:"actions,omitempty"` + Status Status `json:"status,omitempty"` + ID string `json:"id,omitempty"` + IDs []string `json:"-"` + Identity string `json:"identity,omitempty"` + UserID string `json:"user_id,omitempty"` } type DomainsPage struct { diff --git a/domains/events/events.go b/domains/events/events.go index 38f9a72f72..29fafdc50d 100644 --- a/domains/events/events.go +++ b/domains/events/events.go @@ -272,8 +272,8 @@ func (lde listDomainsEvent) Encode() (map[string]any, error) { if lde.Metadata != nil { val["metadata"] = lde.Metadata } - if lde.Tag != "" { - val["tag"] = lde.Tag + if len(lde.Tags.Elements) > 0 { + val["tag"] = lde.Tags.Elements } if lde.RoleID != "" { val["role_id"] = lde.RoleID diff --git a/domains/postgres/domains.go b/domains/postgres/domains.go index 70901aa5e3..f08730b422 100644 --- a/domains/postgres/domains.go +++ b/domains/postgres/domains.go @@ -660,21 +660,21 @@ func toDomain(d dbDomain) (domains.Domain, error) { } type dbDomainsPage struct { - Total uint64 `db:"total"` - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Order string `db:"order"` - Dir string `db:"dir"` - Name string `db:"name"` - RoleID string `db:"role_id"` - RoleName string `db:"role_name"` - Actions pq.StringArray `db:"actions"` - ID string `db:"id"` - IDs []string `db:"ids"` - Metadata []byte `db:"metadata"` - Tag string `db:"tag"` - Status domains.Status `db:"status"` - UserID string `db:"member_id"` + Total uint64 `db:"total"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Order string `db:"order"` + Dir string `db:"dir"` + Name string `db:"name"` + RoleID string `db:"role_id"` + RoleName string `db:"role_name"` + Actions pq.StringArray `db:"actions"` + ID string `db:"id"` + IDs []string `db:"ids"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + Status domains.Status `db:"status"` + UserID string `db:"member_id"` } func toDBDomainsPage(pm domains.Page) (dbDomainsPage, error) { @@ -682,6 +682,10 @@ func toDBDomainsPage(pm domains.Page) (dbDomainsPage, error) { if err != nil { return dbDomainsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) } + var tags pgtype.TextArray + if err := tags.Set(pm.Tags.Elements); err != nil { + return dbDomainsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } return dbDomainsPage{ Total: pm.Total, Limit: pm.Limit, @@ -695,7 +699,7 @@ func toDBDomainsPage(pm domains.Page) (dbDomainsPage, error) { ID: pm.ID, IDs: pm.IDs, Metadata: data, - Tag: pm.Tag, + Tags: tags, Status: pm.Status, UserID: pm.UserID, }, nil @@ -737,8 +741,13 @@ func buildPageQuery(pm domains.Page) (string, error) { } } - if pm.Tag != "" { - query = append(query, "EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE '%' || :tag || '%')") + if len(pm.Tags.Elements) > 0 { + switch pm.Tags.Operator { + case domains.AndOp: + query = append(query, "tags @> :tags") + default: // OR + query = append(query, "tags && :tags") + } } mq, _, err := postgres.CreateMetadataQuery("", pm.Metadata) diff --git a/domains/postgres/domains_test.go b/domains/postgres/domains_test.go index 502f8ed11d..31dce08339 100644 --- a/domains/postgres/domains_test.go +++ b/domains/postgres/domains_test.go @@ -18,7 +18,11 @@ import ( "github.com/stretchr/testify/require" ) -const invalid = "invalid" +const ( + invalid = "invalid" + defOrder = "created_at" + defDir = "asc" +) var ( domainID = testsutil.GenerateUUID(&testing.T{}) @@ -313,8 +317,8 @@ func TestRetrieveAllByIDs(t *testing.T) { Offset: 0, Limit: 10, IDs: []string{items[1].ID, items[2].ID}, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 2, @@ -374,8 +378,8 @@ func TestRetrieveAllByIDs(t *testing.T) { Limit: 10, IDs: []string{items[0].ID, items[1].ID}, Status: 5, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 2, @@ -390,7 +394,7 @@ func TestRetrieveAllByIDs(t *testing.T) { Offset: 0, Limit: 10, IDs: []string{items[0].ID, items[1].ID}, - Tag: "test", + Tags: domains.TagsQuery{Elements: []string{"test"}, Operator: domains.OrOp}, }, response: domains.DomainsPage{ Total: 1, @@ -409,8 +413,8 @@ func TestRetrieveAllByIDs(t *testing.T) { "test": "test", }, Status: domains.EnabledStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 2, @@ -705,7 +709,7 @@ func TestListDomains(t *testing.T) { ID: testsutil.GenerateUUID(t), Name: fmt.Sprintf(`"test%d"`, i), Route: fmt.Sprintf(`"test%d"`, i), - Tags: []string{"test"}, + Tags: []string{"tag1", "tag2"}, Metadata: map[string]any{ "test": "test", }, @@ -716,11 +720,13 @@ func TestListDomains(t *testing.T) { } if i%5 == 0 { domain.Status = domains.DisabledStatus - domain.Tags = []string{"test", "admin"} domain.Metadata = map[string]any{ "test1": "test1", } } + if i%9 == 0 { + domain.Tags = []string{"tag1", "tag3"} + } _, err := repo.SaveDomain(context.Background(), domain) require.Nil(t, err, fmt.Sprintf("save domain unexpected error: %s", err)) items = append(items, domain) @@ -737,8 +743,8 @@ func TestListDomains(t *testing.T) { Offset: 0, Limit: 10, Status: domains.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 10, @@ -754,8 +760,8 @@ func TestListDomains(t *testing.T) { Offset: 0, Limit: 10, Status: domains.EnabledStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 8, @@ -772,8 +778,8 @@ func TestListDomains(t *testing.T) { Limit: 10, Name: items[0].Name, Status: domains.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 1, @@ -789,8 +795,8 @@ func TestListDomains(t *testing.T) { Offset: 0, Limit: 10, Status: domains.DisabledStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 2, @@ -801,30 +807,67 @@ func TestListDomains(t *testing.T) { err: nil, }, { - desc: "list all domains with tags", + desc: "list all domains with single tag", pm: domains.Page{ Offset: 0, Limit: 10, - Tag: "admin", + Tags: domains.TagsQuery{Elements: []string{"tag1"}, Operator: domains.OrOp}, Status: domains.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ - Total: 2, + Total: 10, Offset: 0, Limit: 10, - Domains: []domains.Domain{items[0], items[5]}, + Domains: items, }, err: nil, }, { - desc: "list all domains with invalid tag", + desc: "list all domain with multiple tags and OR operator", pm: domains.Page{ Offset: 0, Limit: 10, - Tag: "invalid", + Tags: domains.TagsQuery{Elements: []string{"tag2", "tag3"}, Operator: domains.OrOp}, Status: domains.AllStatus, + Order: defOrder, + Dir: defDir, + }, + response: domains.DomainsPage{ + Total: 10, + Offset: 0, + Limit: 10, + Domains: items, + }, + err: nil, + }, + { + desc: "retrieve domain with multiple tags and AND operator", + pm: domains.Page{ + Offset: 0, + Limit: 10, + Tags: domains.TagsQuery{Elements: []string{"tag1", "tag3"}, Operator: domains.AndOp}, + Status: domains.AllStatus, + Order: defOrder, + Dir: defDir, + }, + response: domains.DomainsPage{ + Total: 2, + Offset: 0, + Limit: 10, + Domains: []domains.Domain{items[0], items[9]}, + }, + }, + { + desc: "retrieve domain with invalid tags", + pm: domains.Page{ + Offset: 0, + Limit: 10, + Tags: domains.TagsQuery{Elements: []string{"invalid-tag"}, Operator: domains.OrOp}, + Status: domains.AllStatus, + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 0, @@ -832,7 +875,6 @@ func TestListDomains(t *testing.T) { Limit: 10, Domains: []domains.Domain(nil), }, - err: nil, }, { desc: "list all domains with metadata", @@ -843,8 +885,8 @@ func TestListDomains(t *testing.T) { "test1": "test1", }, Status: domains.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: domains.DomainsPage{ Total: 2, diff --git a/domains/postgres/invitations_test.go b/domains/postgres/invitations_test.go index 61e38e9bab..02929f7ef5 100644 --- a/domains/postgres/invitations_test.go +++ b/domains/postgres/invitations_test.go @@ -840,8 +840,8 @@ func saveDomain(t *testing.T, repo domains.Repository) domains.Domain { }, CreatedBy: userID, UpdatedBy: userID, - CreatedAt: time.Now().UTC().Truncate(time.Millisecond), - UpdatedAt: time.Now().UTC().Truncate(time.Millisecond), + CreatedAt: time.Now().UTC().Truncate(time.Microsecond), + UpdatedAt: time.Now().UTC().Truncate(time.Microsecond), Status: domains.EnabledStatus, } diff --git a/groups/api/http/decode.go b/groups/api/http/decode.go index b4c23cc627..772dba5f0a 100644 --- a/groups/api/http/decode.go +++ b/groups/api/http/decode.go @@ -292,6 +292,14 @@ func decodePageMeta(r *http.Request) (groups.PageMeta, error) { if err != nil { return groups.PageMeta{}, errors.Wrap(apiutil.ErrValidation, err) } + tags, err := apiutil.ReadStringQuery(r, api.TagsKey, "") + if err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrValidation, err) + } + var tq groups.TagsQuery + if tags != "" { + tq = groups.ToTagsQuery(tags) + } ret := groups.PageMeta{ Offset: offset, @@ -308,6 +316,7 @@ func decodePageMeta(r *http.Request) (groups.PageMeta, error) { OnlyTotal: ot, Order: order, Dir: dir, + Tags: tq, } return ret, nil } diff --git a/groups/api/http/endpoint_test.go b/groups/api/http/endpoint_test.go index a7824262b7..609d51df2b 100644 --- a/groups/api/http/endpoint_test.go +++ b/groups/api/http/endpoint_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -833,6 +834,7 @@ func TestListGroups(t *testing.T) { domainID string token string session smqauthn.Session + pageMeta groups.PageMeta listGroupsResponse groups.Page status int authnErr error @@ -843,6 +845,13 @@ func TestListGroups(t *testing.T) { domainID: validID, token: validToken, status: http.StatusOK, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, @@ -870,6 +879,13 @@ func TestListGroups(t *testing.T) { desc: "list groups with offset", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 1, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, @@ -892,6 +908,13 @@ func TestListGroups(t *testing.T) { desc: "list groups with limit", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 1, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, @@ -922,6 +945,14 @@ func TestListGroups(t *testing.T) { desc: "list groups with name", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Name: "clientname", + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, @@ -932,14 +963,6 @@ func TestListGroups(t *testing.T) { status: http.StatusOK, err: nil, }, - { - desc: "list groups with invalid name", - domainID: validID, - token: validToken, - query: "name=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, { desc: "list groups with duplicate name", domainID: validID, @@ -952,6 +975,14 @@ func TestListGroups(t *testing.T) { desc: "list groups with status", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Status: groups.EnabledStatus, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, @@ -979,122 +1010,114 @@ func TestListGroups(t *testing.T) { err: apiutil.ErrInvalidQueryParams, }, { - desc: "list groups with tags", + desc: "list groups with single tag", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: groups.TagsQuery{Elements: []string{"tag1"}, Operator: groups.OrOp}, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, }, Groups: []groups.Group{validGroupResp}, }, - query: "tag=tag1,tag2", + query: "tags=tag1", status: http.StatusOK, err: nil, }, { - desc: "list groups with invalid tags", - domainID: validID, - token: validToken, - query: "tag=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, - { - desc: "list groups with duplicate tags", - domainID: validID, - token: validToken, - query: "tag=tag1&tag=tag2", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list groups with metadata", + desc: "list groups with multiple tags and OR operator", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: groups.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: groups.OrOp}, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, }, Groups: []groups.Group{validGroupResp}, }, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&", + query: "tags=tag1,tag2,tag3", status: http.StatusOK, err: nil, }, { - desc: "list groups with invalid metadata", - domainID: validID, - token: validToken, - query: "metadata=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list groups with duplicate metadata", - domainID: validID, - token: validToken, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&metadata=%7B%22domain%22%3A%20%22example.com%22%7D", - status: http.StatusBadRequest, - err: apiutil.ErrInvalidQueryParams, - }, - { - desc: "list groups with permissions", + desc: "list groups with multiple tags and AND operator", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Tags: groups.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: groups.AndOp}, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, }, Groups: []groups.Group{validGroupResp}, }, - query: "permission=view", + query: "tags=tag1-tag2-tag3", status: http.StatusOK, err: nil, }, { - desc: "list groups with invalid permissions", - domainID: validID, - token: validToken, - query: "permission=invalid", - status: http.StatusBadRequest, - err: apiutil.ErrValidation, - }, - { - desc: "list groups with duplicate permissions", + desc: "list groups with duplicate tags", domainID: validID, token: validToken, - query: "permission=view&permission=view", + query: "tags=tag1&tags=tag2", status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, { - desc: "list groups with list perms", + desc: "list groups with metadata", domainID: validID, token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + Metadata: map[string]any{"domain": "example.com"}, + }, listGroupsResponse: groups.Page{ PageMeta: groups.PageMeta{ Total: 1, }, Groups: []groups.Group{validGroupResp}, }, - query: "list_perms=true", + query: fmt.Sprintf("metadata=%s", url.PathEscape(`{"domain": "example.com"}`)), status: http.StatusOK, err: nil, }, { - desc: "list groups with invalid list perms", + desc: "list groups with invalid metadata", domainID: validID, token: validToken, - query: "list_perms=invalid", + query: "metadata=invalid", status: http.StatusBadRequest, - err: apiutil.ErrValidation, + err: apiutil.ErrInvalidQueryParams, }, { - desc: "list groups with duplicate list perms", + desc: "list groups with duplicate metadata", domainID: validID, token: validToken, - query: "list_perms=true&listPerms=true", + query: fmt.Sprintf("metadata=%s&metadata=%s", url.PathEscape(`{"domain": "example.com"}`), url.PathEscape(`{"domain": "example.com"}`)), status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, @@ -1113,7 +1136,7 @@ func TestListGroups(t *testing.T) { tc.session = smqauthn.Session{DomainUserID: validID + "_" + validID, UserID: validID, DomainID: validID} } authCall := authn.On("Authenticate", mock.Anything, tc.token).Return(tc.session, tc.authnErr) - svcCall := svc.On("ListGroups", mock.Anything, tc.session, mock.Anything).Return(tc.listGroupsResponse, tc.err) + svcCall := svc.On("ListGroups", mock.Anything, tc.session, tc.pageMeta).Return(tc.listGroupsResponse, tc.err) res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) var bodyRes respBody diff --git a/groups/events/events.go b/groups/events/events.go index 53364f3e73..2e667bbd42 100644 --- a/groups/events/events.go +++ b/groups/events/events.go @@ -231,8 +231,8 @@ func (lge listGroupEvent) Encode() (map[string]any, error) { if lge.Name != "" { val["name"] = lge.Name } - if lge.Tag != "" { - val["tag"] = lge.Tag + if len(lge.Tags.Elements) > 0 { + val["tag"] = lge.Tags.Elements } if lge.Metadata != nil { val["metadata"] = lge.Metadata @@ -269,8 +269,8 @@ func (luge listUserGroupEvent) Encode() (map[string]any, error) { if luge.Name != "" { val["name"] = luge.Name } - if luge.Tag != "" { - val["tag"] = luge.Tag + if len(luge.Tags.Elements) > 0 { + val["tag"] = luge.Tags.Elements } if luge.Metadata != nil { val["metadata"] = luge.Metadata @@ -464,8 +464,8 @@ func (vcge listChildrenGroupsEvent) Encode() (map[string]any, error) { if vcge.Name != "" { val["name"] = vcge.Name } - if vcge.Tag != "" { - val["tag"] = vcge.Tag + if len(vcge.Tags.Elements) > 0 { + val["tag"] = vcge.Tags.Elements } if vcge.Metadata != nil { val["metadata"] = vcge.Metadata diff --git a/groups/middleware/tracing.go b/groups/middleware/tracing.go index c2784b685a..d4ff78b9ef 100644 --- a/groups/middleware/tracing.go +++ b/groups/middleware/tracing.go @@ -49,7 +49,7 @@ func (tm *tracingMiddleware) ViewGroup(ctx context.Context, session authn.Sessio func (tm *tracingMiddleware) ListGroups(ctx context.Context, session authn.Session, pm groups.PageMeta) (groups.Page, error) { attr := []attribute.KeyValue{ attribute.String("name", pm.Name), - attribute.String("tag", pm.Tag), + attribute.StringSlice("tags", pm.Tags.Elements), attribute.String("status", pm.Status.String()), attribute.Int64("offset", int64(pm.Offset)), attribute.Int64("limit", int64(pm.Limit)), @@ -67,7 +67,7 @@ func (tm *tracingMiddleware) ListUserGroups(ctx context.Context, session authn.S attr := []attribute.KeyValue{ attribute.String("user_id", userID), attribute.String("name", pm.Name), - attribute.String("tag", pm.Tag), + attribute.StringSlice("tag", pm.Tags.Elements), attribute.String("status", pm.Status.String()), attribute.Int64("offset", int64(pm.Offset)), attribute.Int64("limit", int64(pm.Limit)), @@ -176,7 +176,7 @@ func (tm *tracingMiddleware) ListChildrenGroups(ctx context.Context, session aut attr := []attribute.KeyValue{ attribute.String("id", id), attribute.String("name", pm.Name), - attribute.String("tag", pm.Tag), + attribute.StringSlice("tags", pm.Tags.Elements), attribute.String("status", pm.Status.String()), attribute.Int64("start_level", startLevel), attribute.Int64("end_level", endLevel), diff --git a/groups/page.go b/groups/page.go index ed6c38bda9..270a58d5b9 100644 --- a/groups/page.go +++ b/groups/page.go @@ -3,24 +3,57 @@ package groups +import "strings" + +type Operator uint8 + +const ( + OrOp Operator = iota + AndOp +) + +type TagsQuery struct { + Elements []string + Operator Operator +} + +func ToTagsQuery(s string) TagsQuery { + switch { + case strings.Contains(s, "-"): + elements := strings.Split(s, "-") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: AndOp} + case strings.Contains(s, ","): + elements := strings.Split(s, ",") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: OrOp} + default: + return TagsQuery{Elements: []string{s}, Operator: OrOp} + } +} + // PageMeta contains page metadata that helps navigation. type PageMeta struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - OnlyTotal bool `json:"only_total"` - Name string `json:"name,omitempty"` - ID string `json:"id,omitempty"` - Dir string `json:"dir,omitempty"` - Order string `json:"order,omitempty"` - Path string `json:"path,omitempty"` - DomainID string `json:"domain_id,omitempty"` - Tag string `json:"tag,omitempty"` - Metadata Metadata `json:"metadata,omitempty"` - Status Status `json:"status,omitempty"` - RoleName string `json:"role_name,omitempty"` - RoleID string `json:"role_id,omitempty"` - Actions []string `json:"actions,omitempty"` - AccessType string `json:"access_type,omitempty"` - RootGroup bool `json:"root_group,omitempty"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + OnlyTotal bool `json:"only_total"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Dir string `json:"dir,omitempty"` + Order string `json:"order,omitempty"` + Path string `json:"path,omitempty"` + DomainID string `json:"domain_id,omitempty"` + Tags TagsQuery `json:"tags,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Status Status `json:"status,omitempty"` + RoleName string `json:"role_name,omitempty"` + RoleID string `json:"role_id,omitempty"` + Actions []string `json:"actions,omitempty"` + AccessType string `json:"access_type,omitempty"` + RootGroup bool `json:"root_group,omitempty"` } diff --git a/groups/postgres/groups.go b/groups/postgres/groups.go index 62ff70b912..f5059003d9 100644 --- a/groups/postgres/groups.go +++ b/groups/postgres/groups.go @@ -1170,6 +1170,14 @@ func buildQuery(gm groups.PageMeta, ids ...string) string { if gm.Status != groups.AllStatus { queries = append(queries, "g.status = :status") } + if len(gm.Tags.Elements) > 0 { + switch gm.Tags.Operator { + case groups.AndOp: + queries = append(queries, "tags @> :tags") + default: // OR + queries = append(queries, "tags && :tags") + } + } if gm.DomainID != "" { queries = append(queries, "g.domain_id = :domain_id") } @@ -1328,10 +1336,15 @@ func toDBGroupPageMeta(pm groups.PageMeta) (dbGroupPageMeta, error) { } data = b } + var tags pgtype.TextArray + if err := tags.Set(pm.Tags.Elements); err != nil { + return dbGroupPageMeta{}, errors.Wrap(repoerr.ErrViewEntity, err) + } return dbGroupPageMeta{ ID: pm.ID, Name: pm.Name, Metadata: data, + Tags: tags, Total: pm.Total, Offset: pm.Offset, Limit: pm.Limit, @@ -1345,22 +1358,23 @@ func toDBGroupPageMeta(pm groups.PageMeta) (dbGroupPageMeta, error) { } type dbGroupPageMeta struct { - ID string `db:"id"` - Name string `db:"name"` - ParentID string `db:"parent_id"` - DomainID string `db:"domain_id"` - Metadata []byte `db:"metadata"` - Path string `db:"path"` - Level uint64 `db:"level"` - Total uint64 `db:"total"` - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Subject string `db:"subject"` - RoleName string `db:"role_name"` - RoleID string `db:"role_id"` - Actions pq.StringArray `db:"actions"` - AccessType string `db:"access_type"` - Status groups.Status `db:"status"` + ID string `db:"id"` + Name string `db:"name"` + ParentID string `db:"parent_id"` + DomainID string `db:"domain_id"` + Metadata []byte `db:"metadata"` + Path string `db:"path"` + Level uint64 `db:"level"` + Total uint64 `db:"total"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Subject string `db:"subject"` + RoleName string `db:"role_name"` + RoleID string `db:"role_id"` + Actions pq.StringArray `db:"actions"` + AccessType string `db:"access_type"` + Status groups.Status `db:"status"` + Tags pgtype.TextArray `db:"tags"` } func (repo groupRepository) processRows(rows *sqlx.Rows) ([]groups.Group, error) { diff --git a/groups/postgres/groups_test.go b/groups/postgres/groups_test.go index 0a3b3d8dc2..8c72755c87 100644 --- a/groups/postgres/groups_test.go +++ b/groups/postgres/groups_test.go @@ -687,6 +687,10 @@ func TestRetrieveAll(t *testing.T) { Metadata: map[string]any{"name": name}, CreatedAt: time.Now().UTC().Truncate(time.Microsecond), Status: groups.EnabledStatus, + Tags: []string{"tag1", "tag2"}, + } + if i%99 == 0 { + group.Tags = []string{"tag1", "tag3"} } _, err := repo.Save(context.Background(), group) require.Nil(t, err, fmt.Sprintf("create group unexpected error: %s", err)) @@ -970,6 +974,83 @@ func TestRetrieveAll(t *testing.T) { }, err: nil, }, + { + desc: "retrieve groups with single tag", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: uint64(num), + Tags: groups.TagsQuery{Elements: []string{"tag1"}, Operator: groups.OrOp}, + Status: groups.AllStatus, + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 200, + Offset: 0, + Limit: uint64(num), + }, + Groups: items, + }, + err: nil, + }, + { + desc: "retrieve group with multiple tags and OR operator", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: uint64(num), + Tags: groups.TagsQuery{Elements: []string{"tag2", "tag3"}, Operator: groups.OrOp}, + Status: groups.AllStatus, + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 200, + Offset: 0, + Limit: uint64(num), + }, + Groups: items, + }, + }, + { + desc: "retrieve group with multiple tags and AND operator", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: uint64(num), + Tags: groups.TagsQuery{Elements: []string{"tag1", "tag3"}, Operator: groups.AndOp}, + Status: groups.AllStatus, + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 3, + Offset: 0, + Limit: uint64(num), + }, + Groups: []groups.Group{items[0], items[99], items[198]}, + }, + }, + { + desc: "retrieve group with invalid tags", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: uint64(num), + Tags: groups.TagsQuery{Elements: []string{namegen.Generate(), namegen.Generate()}, Operator: groups.OrOp}, + Status: groups.AllStatus, + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 0, + Offset: 0, + Limit: uint64(num), + }, + Groups: []groups.Group(nil), + }, + }, } for _, tc := range cases { diff --git a/pkg/sdk/clients_test.go b/pkg/sdk/clients_test.go index 9e313c6931..6d6e12d31d 100644 --- a/pkg/sdk/clients_test.go +++ b/pkg/sdk/clients_test.go @@ -485,7 +485,7 @@ func TestListClients(t *testing.T) { pageMeta: sdk.PageMetadata{ Offset: 0, Limit: 100, - Tag: "tag1", + Tags: sdk.TagsQuery{Elements: []string{"tag1"}, Operator: sdk.OrOp}, }, svcReq: clients.Page{ Actions: []string{}, @@ -493,7 +493,7 @@ func TestListClients(t *testing.T) { Dir: "desc", Offset: 0, Limit: 100, - Tag: "tag1", + Tags: clients.TagsQuery{Elements: []string{"tag1"}, Operator: clients.OrOp}, }, svcRes: clients.ClientsPage{ Page: clients.Page{ diff --git a/pkg/sdk/sdk.go b/pkg/sdk/sdk.go index dc49a72cc7..f49de15628 100644 --- a/pkg/sdk/sdk.go +++ b/pkg/sdk/sdk.go @@ -89,48 +89,79 @@ type MessagePageMetadata struct { Protocol string `json:"protocol,omitempty"` } +type Operator uint8 + +const ( + OrOp Operator = iota + AndOp +) + +type TagsQuery struct { + Elements []string + Operator Operator +} + +func ToTagsQuery(s string) TagsQuery { + switch { + case strings.Contains(s, "-"): + elements := strings.Split(s, "-") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: AndOp} + case strings.Contains(s, ","): + elements := strings.Split(s, ",") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: OrOp} + default: + return TagsQuery{Elements: []string{s}, Operator: OrOp} + } +} + type PageMetadata struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - Order string `json:"order,omitempty"` - Direction string `json:"direction,omitempty"` - Level uint64 `json:"level,omitempty"` - Identity string `json:"identity,omitempty"` - Email string `json:"email,omitempty"` - Username string `json:"username,omitempty"` - LastName string `json:"last_name,omitempty"` - FirstName string `json:"first_name,omitempty"` - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Metadata Metadata `json:"metadata,omitempty"` - Status string `json:"status,omitempty"` - Action string `json:"action,omitempty"` - Subject string `json:"subject,omitempty"` - Object string `json:"object,omitempty"` - Permission string `json:"permission,omitempty"` - Tag string `json:"tag,omitempty"` - Owner string `json:"owner,omitempty"` - SharedBy string `json:"shared_by,omitempty"` - Visibility string `json:"visibility,omitempty"` - OwnerID string `json:"owner_id,omitempty"` - Topic string `json:"topic,omitempty"` - Contact string `json:"contact,omitempty"` - State string `json:"state,omitempty"` - ListPermissions string `json:"list_perms,omitempty"` - InvitedBy string `json:"invited_by,omitempty"` - UserID string `json:"user_id,omitempty"` - DomainID string `json:"domain_id,omitempty"` - Relation string `json:"relation,omitempty"` - Operation string `json:"operation,omitempty"` - From int64 `json:"from,omitempty"` - To int64 `json:"to,omitempty"` - WithMetadata bool `json:"with_metadata,omitempty"` - WithAttributes bool `json:"with_attributes,omitempty"` - ID string `json:"id,omitempty"` - Tree bool `json:"tree,omitempty"` - StartLevel int64 `json:"start_level,omitempty"` - EndLevel int64 `json:"end_level,omitempty"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + Order string `json:"order,omitempty"` + Direction string `json:"direction,omitempty"` + Level uint64 `json:"level,omitempty"` + Identity string `json:"identity,omitempty"` + Email string `json:"email,omitempty"` + Username string `json:"username,omitempty"` + LastName string `json:"last_name,omitempty"` + FirstName string `json:"first_name,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Status string `json:"status,omitempty"` + Action string `json:"action,omitempty"` + Subject string `json:"subject,omitempty"` + Object string `json:"object,omitempty"` + Permission string `json:"permission,omitempty"` + Tags TagsQuery `json:"tags,omitempty"` + Owner string `json:"owner,omitempty"` + SharedBy string `json:"shared_by,omitempty"` + Visibility string `json:"visibility,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + Topic string `json:"topic,omitempty"` + Contact string `json:"contact,omitempty"` + State string `json:"state,omitempty"` + ListPermissions string `json:"list_perms,omitempty"` + InvitedBy string `json:"invited_by,omitempty"` + UserID string `json:"user_id,omitempty"` + DomainID string `json:"domain_id,omitempty"` + Relation string `json:"relation,omitempty"` + Operation string `json:"operation,omitempty"` + From int64 `json:"from,omitempty"` + To int64 `json:"to,omitempty"` + WithMetadata bool `json:"with_metadata,omitempty"` + WithAttributes bool `json:"with_attributes,omitempty"` + ID string `json:"id,omitempty"` + Tree bool `json:"tree,omitempty"` + StartLevel int64 `json:"start_level,omitempty"` + EndLevel int64 `json:"end_level,omitempty"` } type Role struct { @@ -1619,8 +1650,15 @@ func (pm PageMetadata) query() (string, error) { if pm.Object != "" { q.Add("object", pm.Object) } - if pm.Tag != "" { - q.Add("tag", pm.Tag) + if len(pm.Tags.Elements) > 0 { + switch pm.Tags.Operator { + case AndOp: + str := strings.Join(pm.Tags.Elements, "-") + q.Add("tags", str) + default: + str := strings.Join(pm.Tags.Elements, ",") + q.Add("tags", str) + } } if pm.Owner != "" { q.Add("owner", pm.Owner) diff --git a/pkg/sdk/users_test.go b/pkg/sdk/users_test.go index b24a1cfbe7..7b32a7ae11 100644 --- a/pkg/sdk/users_test.go +++ b/pkg/sdk/users_test.go @@ -480,12 +480,12 @@ func TestListUsers(t *testing.T) { pageMeta: sdk.PageMetadata{ Offset: offset, Limit: limit, - Tag: "tag1", + Tags: sdk.TagsQuery{Elements: []string{"tag1"}, Operator: sdk.OrOp}, }, svcReq: users.Page{ Offset: offset, Limit: limit, - Tag: "tag1", + Tags: users.TagsQuery{Elements: []string{"tag1"}, Operator: users.OrOp}, Order: api.DefOrder, Dir: api.DefDir, }, diff --git a/users/api/endpoint_test.go b/users/api/endpoint_test.go index dbf6e57108..1ce8d099c1 100644 --- a/users/api/endpoint_test.go +++ b/users/api/endpoint_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "regexp" "strings" "testing" @@ -421,6 +422,7 @@ func TestListUsers(t *testing.T) { desc string query string token string + pageMeta users.Page listUsersResponse users.UsersPage status int authnRes smqauthn.Session @@ -431,6 +433,12 @@ func TestListUsers(t *testing.T) { desc: "list users as admin with valid token", token: validToken, status: http.StatusOK, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, @@ -459,6 +467,12 @@ func TestListUsers(t *testing.T) { { desc: "list users with offset", token: validToken, + pageMeta: users.Page{ + Offset: 1, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Offset: 1, @@ -482,6 +496,12 @@ func TestListUsers(t *testing.T) { { desc: "list users with limit", token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 1, + Order: api.DefOrder, + Dir: api.DefDir, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Limit: 1, @@ -511,43 +531,57 @@ func TestListUsers(t *testing.T) { err: apiutil.ErrLimitSize, }, { - desc: "list users with name", + desc: "list users with username", token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Username: "username", + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, }, Users: []users.User{user}, }, - query: "name=username", + query: "username=username", status: http.StatusOK, authnRes: verifiedSession, err: nil, }, { - desc: "list users with duplicate name", + desc: "list users with duplicate username", token: validToken, - query: "name=1&name=2", + query: "username=1&username=2", status: http.StatusBadRequest, authnRes: verifiedSession, err: apiutil.ErrInvalidQueryParams, }, { - desc: "list users with status", + desc: "list users with first name", token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + FirstName: "firstname", + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, }, Users: []users.User{user}, }, - query: "status=enabled", + query: "first_name=firstname", status: http.StatusOK, authnRes: verifiedSession, err: nil, }, { - desc: "list users with invalid status", + desc: "list users with duplicate firstname", token: validToken, query: "status=invalid", status: http.StatusBadRequest, @@ -563,105 +597,175 @@ func TestListUsers(t *testing.T) { err: apiutil.ErrInvalidQueryParams, }, { - desc: "list users with tags", + desc: "list users with lastname", token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + LastName: "lastname", + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, }, Users: []users.User{user}, }, - query: "tag=tag1,tag2", + query: "last_name=lastname", status: http.StatusOK, authnRes: verifiedSession, err: nil, }, { - desc: "list users with duplicate tags", + desc: "list users with duplicate lastname", token: validToken, - query: "tag=tag1&tag=tag2", + query: "last_name=lastname1&last_name=lastname2", status: http.StatusBadRequest, authnRes: verifiedSession, err: apiutil.ErrInvalidQueryParams, }, { - desc: "list users with metadata", + desc: "list users with status", token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Status: users.EnabledStatus, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, }, Users: []users.User{user}, }, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&", + query: "status=enabled", status: http.StatusOK, authnRes: verifiedSession, err: nil, }, { - desc: "list users with invalid metadata", + desc: "list users with invalid status", token: validToken, - query: "metadata=invalid", + query: "status=invalid", status: http.StatusBadRequest, authnRes: verifiedSession, - err: apiutil.ErrInvalidQueryParams, + err: svcerr.ErrInvalidStatus, }, { - desc: "list users with duplicate metadata", + desc: "list users with duplicate status", token: validToken, - query: "metadata=%7B%22domain%22%3A%20%22example.com%22%7D&metadata=%7B%22domain%22%3A%20%22example.com%22%7D", + query: "status=enabled&status=disabled", status: http.StatusBadRequest, authnRes: verifiedSession, err: apiutil.ErrInvalidQueryParams, }, { - desc: "list users with permissions", + desc: "list users with single tag", token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Tags: users.TagsQuery{Elements: []string{"tag1"}, Operator: users.OrOp}, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, }, Users: []users.User{user}, }, - query: "permission=view", + query: "tags=tag1", status: http.StatusOK, authnRes: verifiedSession, err: nil, }, { - desc: "list users with duplicate permissions", - token: validToken, - query: "permission=view&permission=view", - status: http.StatusBadRequest, + desc: "list users with multiple tags and OR operator", + token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Tags: users.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: users.OrOp}, + }, + listUsersResponse: users.UsersPage{ + Page: users.Page{ + Total: 1, + }, + Users: []users.User{user}, + }, + query: "tags=tag1,tag2,tag3", + status: http.StatusOK, authnRes: verifiedSession, - err: apiutil.ErrInvalidQueryParams, + err: nil, }, { - desc: "list users with duplicate list perms", + desc: "list users with multiple tags and AND operator", + token: validToken, + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Tags: users.TagsQuery{Elements: []string{"tag1", "tag2", "tag3"}, Operator: users.AndOp}, + }, + listUsersResponse: users.UsersPage{ + Page: users.Page{ + Total: 1, + }, + Users: []users.User{user}, + }, + query: "tags=tag1-tag2-tag3", + status: http.StatusOK, + authnRes: verifiedSession, + err: nil, + }, + { + desc: "list users with duplicate tags", token: validToken, - query: "list_perms=true&list_perms=true", + query: "tags=tag1&tags=tag2", status: http.StatusBadRequest, authnRes: verifiedSession, err: apiutil.ErrInvalidQueryParams, }, { - desc: "list users with email", + desc: "list users with metadata", token: validToken, - query: fmt.Sprintf("email=%s", user.Email), + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Metadata: users.Metadata{"domain": "example.com"}, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, }, Users: []users.User{user}, }, + query: "metadata=" + url.PathEscape(`{"domain": "example.com"}`), status: http.StatusOK, authnRes: verifiedSession, err: nil, }, { - desc: "list users with duplicate email", + desc: "list users with invalid metadata", token: validToken, - query: "email=1&email=2", + query: "metadata=invalid", + status: http.StatusBadRequest, + authnRes: verifiedSession, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list users with duplicate metadata", + token: validToken, + query: fmt.Sprintf("metadata=%s&metadata=%s", url.PathEscape(`{"domain": "example.com"}`), url.PathEscape(`{"domain": "example.com"}`)), status: http.StatusBadRequest, authnRes: verifiedSession, err: apiutil.ErrInvalidQueryParams, @@ -670,16 +774,21 @@ func TestListUsers(t *testing.T) { desc: "list users with email", token: validToken, query: fmt.Sprintf("email=%s", user.Email), + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Email: user.Email, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, }, - Users: []users.User{ - user, - }, + Users: []users.User{user}, }, status: http.StatusOK, - authnRes: smqauthn.Session{UserID: validID, DomainID: validID, Verified: true}, + authnRes: verifiedSession, err: nil, }, { @@ -687,11 +796,17 @@ func TestListUsers(t *testing.T) { token: validToken, query: "email=1&email=2", status: http.StatusBadRequest, - authnRes: smqauthn.Session{UserID: validID, DomainID: validID, Verified: true}, + authnRes: verifiedSession, err: apiutil.ErrInvalidQueryParams, }, { desc: "list users with order", + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: "username", + Dir: api.DefDir, + }, listUsersResponse: users.UsersPage{ Page: users.Page{ Total: 1, @@ -743,7 +858,7 @@ func TestListUsers(t *testing.T) { } authnCall := authn.On("Authenticate", mock.Anything, tc.token).Return(tc.authnRes, tc.authnErr) - svcCall := svc.On("ListUsers", mock.Anything, tc.authnRes, mock.Anything).Return(tc.listUsersResponse, tc.err) + svcCall := svc.On("ListUsers", mock.Anything, tc.authnRes, tc.pageMeta).Return(tc.listUsersResponse, tc.err) res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) var bodyRes respBody diff --git a/users/api/endpoints.go b/users/api/endpoints.go index 60b5910db4..1dd8fe4372 100644 --- a/users/api/endpoints.go +++ b/users/api/endpoints.go @@ -127,7 +127,7 @@ func listUsersEndpoint(svc users.Service) endpoint.Endpoint { Limit: req.limit, OnlyTotal: req.onlyTotal, Username: req.userName, - Tag: req.tag, + Tags: req.tags, Metadata: req.metadata, FirstName: req.firstName, LastName: req.lastName, diff --git a/users/api/requests.go b/users/api/requests.go index 6b620e54fe..a9a38da7ec 100644 --- a/users/api/requests.go +++ b/users/api/requests.go @@ -99,7 +99,7 @@ type listUsersReq struct { limit uint64 onlyTotal bool userName string - tag string + tags users.TagsQuery firstName string lastName string email string diff --git a/users/api/users.go b/users/api/users.go index 01f3ab6184..d842015058 100644 --- a/users/api/users.go +++ b/users/api/users.go @@ -274,10 +274,14 @@ func decodeListUsers(_ context.Context, r *http.Request) (any, error) { if err != nil { return nil, errors.Wrap(apiutil.ErrValidation, err) } - t, err := apiutil.ReadStringQuery(r, api.TagKey, "") + t, err := apiutil.ReadStringQuery(r, api.TagsKey, "") if err != nil { return nil, errors.Wrap(apiutil.ErrValidation, err) } + var tq users.TagsQuery + if t != "" { + tq = users.ToTagsQuery(t) + } order, err := apiutil.ReadStringQuery(r, api.OrderKey, api.DefOrder) if err != nil { return nil, errors.Wrap(apiutil.ErrValidation, err) @@ -310,7 +314,7 @@ func decodeListUsers(_ context.Context, r *http.Request) (any, error) { userName: n, firstName: i, lastName: f, - tag: t, + tags: tq, order: order, dir: dir, id: id, diff --git a/users/events/events.go b/users/events/events.go index f298845350..a4caf3d930 100644 --- a/users/events/events.go +++ b/users/events/events.go @@ -392,8 +392,8 @@ func (lue listUserEvent) Encode() (map[string]any, error) { if lue.Domain != "" { val["domain"] = lue.Domain } - if lue.Tag != "" { - val["tag"] = lue.Tag + if len(lue.Tags.Elements) > 0 { + val["tags"] = lue.Tags.Elements } if lue.Permission != "" { val["permission"] = lue.Permission diff --git a/users/postgres/users.go b/users/postgres/users.go index 6e13cb8043..25ef87d1d7 100644 --- a/users/postgres/users.go +++ b/users/postgres/users.go @@ -601,19 +601,19 @@ func ToUser(dbu DBUser) (users.User, error) { } type DBUsersPage struct { - Total uint64 `db:"total"` - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - FirstName string `db:"first_name"` - LastName string `db:"last_name"` - Username string `db:"username"` - Id string `db:"id"` - Email string `db:"email"` - Metadata []byte `db:"metadata"` - Tag string `db:"tag"` - GroupID string `db:"group_id"` - Role users.Role `db:"role"` - Status users.Status `db:"status"` + Total uint64 `db:"total"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + FirstName string `db:"first_name"` + LastName string `db:"last_name"` + Username string `db:"username"` + Id string `db:"id"` + Email string `db:"email"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + GroupID string `db:"group_id"` + Role users.Role `db:"role"` + Status users.Status `db:"status"` } func ToDBUsersPage(pm users.Page) (DBUsersPage, error) { @@ -622,6 +622,11 @@ func ToDBUsersPage(pm users.Page) (DBUsersPage, error) { return DBUsersPage{}, errors.Wrap(repoerr.ErrViewEntity, err) } + var tags pgtype.TextArray + if err := tags.Set(pm.Tags.Elements); err != nil { + return DBUsersPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } + return DBUsersPage{ FirstName: pm.FirstName, LastName: pm.LastName, @@ -633,7 +638,7 @@ func ToDBUsersPage(pm users.Page) (DBUsersPage, error) { Offset: pm.Offset, Limit: pm.Limit, Status: pm.Status, - Tag: pm.Tag, + Tags: tags, Role: pm.Role, }, nil } @@ -660,8 +665,13 @@ func PageQuery(pm users.Page) (string, error) { if pm.Id != "" { query = append(query, "id ILIKE '%' || :id || '%'") } - if pm.Tag != "" { - query = append(query, "EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE '%' || :tag || '%')") + if len(pm.Tags.Elements) > 0 { + switch pm.Tags.Operator { + case users.AndOp: + query = append(query, "tags @> :tags") + default: // OR + query = append(query, "tags && :tags") + } } if pm.Role != users.AllRole { query = append(query, "u.role = :role") diff --git a/users/postgres/users_test.go b/users/postgres/users_test.go index 8ce2f8cca5..3ea0aa2df0 100644 --- a/users/postgres/users_test.go +++ b/users/postgres/users_test.go @@ -20,7 +20,11 @@ import ( "github.com/stretchr/testify/require" ) -const maxNameSize = 254 +const ( + maxNameSize = 254 + defOrder = "created_at" + defDir = "asc" +) var ( invalidName = strings.Repeat("m", maxNameSize+10) @@ -378,6 +382,9 @@ func TestRetrieveAll(t *testing.T) { user.Role = users.AdminRole user.Status = users.DisabledStatus } + if i%99 == 0 { + user.Tags = []string{"tag1", "tag2"} + } _, err := repo.Save(context.Background(), user) require.Nil(t, err, fmt.Sprintf("failed to save user %s", user.ID)) items = append(items, user) @@ -399,8 +406,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 50, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -419,8 +426,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 200, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -439,8 +446,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 50, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -476,8 +483,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 1000, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -510,8 +517,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 3, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -550,8 +557,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 3, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -571,8 +578,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 3, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -591,8 +598,8 @@ func TestRetrieveAll(t *testing.T) { Offset: 0, Limit: 200, Role: users.AllRole, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -611,8 +618,8 @@ func TestRetrieveAll(t *testing.T) { Offset: 0, Limit: 200, Role: users.AllRole, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -630,8 +637,8 @@ func TestRetrieveAll(t *testing.T) { Offset: 0, Limit: 200, Role: users.AllRole, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -643,9 +650,9 @@ func TestRetrieveAll(t *testing.T) { }, }, { - desc: "retrieve by tags", + desc: "retrieve by tags with OR operator", pageMeta: users.Page{ - Tag: "tag1", + Tags: users.TagsQuery{Operator: users.OrOp, Elements: []string{"tag1"}}, Offset: 0, Limit: 200, Role: users.AllRole, @@ -661,6 +668,63 @@ func TestRetrieveAll(t *testing.T) { }, err: nil, }, + { + desc: "retrieve by tags with OR operator no match", + pageMeta: users.Page{ + Tags: users.TagsQuery{Operator: users.OrOp, Elements: []string{"non-existing-tag"}}, + Offset: 0, + Limit: 200, + Role: users.AllRole, + Status: users.AllStatus, + }, + page: users.UsersPage{ + Page: users.Page{ + Total: 0, + Offset: 0, + Limit: 200, + }, + Users: []users.User{}, + }, + err: nil, + }, + { + desc: "retrieve by tags with AND operator", + pageMeta: users.Page{ + Tags: users.TagsQuery{Operator: users.AndOp, Elements: []string{"tag1", "tag2"}}, + Offset: 0, + Limit: 200, + Role: users.AllRole, + Status: users.AllStatus, + }, + page: users.UsersPage{ + Page: users.Page{ + Total: 3, + Offset: 0, + Limit: 200, + }, + Users: []users.User{items[0], items[99], items[198]}, + }, + err: nil, + }, + { + desc: "retrieve by tags with AND operator no match", + pageMeta: users.Page{ + Tags: users.TagsQuery{Operator: users.AndOp, Elements: []string{"tag1", "non-existing-tag"}}, + Offset: 0, + Limit: 200, + Role: users.AllRole, + Status: users.AllStatus, + }, + page: users.UsersPage{ + Page: users.Page{ + Total: 0, + Offset: 0, + Limit: 200, + }, + Users: []users.User{}, + }, + err: nil, + }, { desc: "retrieve with invalid first name", pageMeta: users.Page{ @@ -689,8 +753,8 @@ func TestRetrieveAll(t *testing.T) { Limit: 200, Role: users.AllRole, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -730,8 +794,8 @@ func TestRetrieveAll(t *testing.T) { Offset: 0, Limit: 200, Status: users.AllStatus, - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, page: users.UsersPage{ Page: users.Page{ @@ -842,7 +906,7 @@ func TestSearch(t *testing.T) { page: users.Page{ Limit: 10, Order: "name", - Dir: "asc", + Dir: defDir, }, response: users.UsersPage{ Users: expectedUsers[0:10], @@ -875,7 +939,7 @@ func TestSearch(t *testing.T) { Offset: 10, Limit: 10, Order: "name", - Dir: "asc", + Dir: defDir, }, response: users.UsersPage{ Users: expectedUsers[10:20], @@ -908,7 +972,7 @@ func TestSearch(t *testing.T) { Offset: 190, Limit: 50, Order: "name", - Dir: "asc", + Dir: defDir, }, response: users.UsersPage{ Page: users.Page{ @@ -926,7 +990,7 @@ func TestSearch(t *testing.T) { Offset: 0, Limit: 10, Order: "first_name", - Dir: "asc", + Dir: defDir, }, response: users.UsersPage{ Users: findUsers(expectedUsers, expectedUsers[0].FirstName[:4], 0, 10), @@ -979,7 +1043,7 @@ func TestSearch(t *testing.T) { Offset: 0, Limit: 10, Order: "first_name", - Dir: "asc", + Dir: defDir, }, response: users.UsersPage{ Users: findUsers(expectedUsers, expectedUsers[0].FirstName[:4], 0, 10), @@ -1046,7 +1110,7 @@ func TestSearch(t *testing.T) { desc: "with name in asc order", page: users.Page{ Order: "first_name", - Dir: "asc", + Dir: defDir, FirstName: expectedUsers[0].FirstName[:1], Offset: 0, Limit: 10, @@ -1071,7 +1135,7 @@ func TestSearch(t *testing.T) { page: users.Page{ LastName: expectedUsers[0].LastName[:1], Order: "last_name", - Dir: "asc", + Dir: defDir, }, response: users.UsersPage{ Users: []users.User{expectedUsers[0]}, @@ -1088,7 +1152,7 @@ func TestSearch(t *testing.T) { page: users.Page{ Username: expectedUsers[0].Credentials.Username[:1], Order: "username", - Dir: "asc", + Dir: defDir, }, response: users.UsersPage{ Users: []users.User{expectedUsers[0]}, @@ -1806,8 +1870,8 @@ func TestRetrieveByIDs(t *testing.T) { Offset: 0, Limit: 10, IDs: getIDs(items[0:3]), - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: users.UsersPage{ Page: users.Page{ @@ -1840,8 +1904,8 @@ func TestRetrieveByIDs(t *testing.T) { page: users.Page{ Offset: 10, IDs: getIDs(items[0:20]), - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: users.UsersPage{ Page: users.Page{ @@ -1858,8 +1922,8 @@ func TestRetrieveByIDs(t *testing.T) { page: users.Page{ Limit: 10, IDs: getIDs(items[0:20]), - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: users.UsersPage{ Page: users.Page{ @@ -1894,8 +1958,8 @@ func TestRetrieveByIDs(t *testing.T) { Offset: 15, Limit: 10, IDs: getIDs(items[0:20]), - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: users.UsersPage{ Page: users.Page{ @@ -1931,8 +1995,8 @@ func TestRetrieveByIDs(t *testing.T) { Limit: 10, FirstName: items[0].FirstName, IDs: getIDs(items[0:20]), - Order: "created_at", - Dir: "asc", + Order: defOrder, + Dir: defDir, }, response: users.UsersPage{ Page: users.Page{ diff --git a/users/users.go b/users/users.go index 4a269f4215..3f048d332e 100644 --- a/users/users.go +++ b/users/users.go @@ -6,6 +6,7 @@ package users import ( "context" "net/mail" + "strings" "time" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" @@ -140,28 +141,59 @@ func isEmail(email string) bool { return err == nil } +type Operator uint8 + +const ( + OrOp Operator = iota + AndOp +) + +type TagsQuery struct { + Elements []string + Operator Operator +} + +func ToTagsQuery(s string) TagsQuery { + switch { + case strings.Contains(s, "-"): + elements := strings.Split(s, "-") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: AndOp} + case strings.Contains(s, ","): + elements := strings.Split(s, ",") + for i := range elements { + elements[i] = strings.TrimSpace(elements[i]) + } + return TagsQuery{Elements: elements, Operator: OrOp} + default: + return TagsQuery{Elements: []string{s}, Operator: OrOp} + } +} + // Page contains page metadata that helps navigation. type Page struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - OnlyTotal bool `json:"only_total"` - Id string `json:"id,omitempty"` - Order string `json:"order,omitempty"` - Dir string `json:"dir,omitempty"` - Metadata Metadata `json:"metadata,omitempty"` - Domain string `json:"domain,omitempty"` - Tag string `json:"tag,omitempty"` - Permission string `json:"permission,omitempty"` - Status Status `json:"status,omitempty"` - IDs []string `json:"ids,omitempty"` - Role Role `json:"-"` - ListPerms bool `json:"-"` - Username string `json:"username,omitempty"` - FirstName string `json:"first_name,omitempty"` - LastName string `json:"last_name,omitempty"` - Email string `json:"email,omitempty"` - Verified bool `json:"verified,omitempty"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + OnlyTotal bool `json:"only_total"` + Id string `json:"id,omitempty"` + Order string `json:"order,omitempty"` + Dir string `json:"dir,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Domain string `json:"domain,omitempty"` + Tags TagsQuery `json:"tag,omitempty"` + Permission string `json:"permission,omitempty"` + Status Status `json:"status,omitempty"` + IDs []string `json:"ids,omitempty"` + Role Role `json:"-"` + ListPerms bool `json:"-"` + Username string `json:"username,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Email string `json:"email,omitempty"` + Verified bool `json:"verified,omitempty"` } // Service specifies an API that must be fullfiled by the domain service