diff --git a/clients/api/http/decode.go b/clients/api/http/decode.go index 4c2377bbb6..3ba6bac896 100644 --- a/clients/api/http/decode.go +++ b/clients/api/http/decode.go @@ -12,6 +12,7 @@ import ( api "github.com/absmach/supermq/api/http" apiutil "github.com/absmach/supermq/api/http/util" "github.com/absmach/supermq/clients" + "github.com/absmach/supermq/groups" "github.com/absmach/supermq/pkg/errors" "github.com/go-chi/chi/v5" ) @@ -27,58 +28,106 @@ func decodeViewClient(_ context.Context, r *http.Request) (interface{}, error) { } func decodeListClients(_ context.Context, r *http.Request) (interface{}, error) { - s, err := apiutil.ReadStringQuery(r, api.StatusKey, api.DefClientStatus) + name, err := apiutil.ReadStringQuery(r, api.NameKey, "") if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - o, err := apiutil.ReadNumQuery[uint64](r, api.OffsetKey, api.DefOffset) + + tag, err := apiutil.ReadStringQuery(r, api.TagKey, "") if err != nil { return nil, errors.Wrap(apiutil.ErrValidation, err) } - l, err := apiutil.ReadNumQuery[uint64](r, api.LimitKey, api.DefLimit) + + s, err := apiutil.ReadStringQuery(r, api.StatusKey, api.DefGroupStatus) if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - m, err := apiutil.ReadMetadataQuery(r, api.MetadataKey, nil) + status, err := clients.ToStatus(s) if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - n, err := apiutil.ReadStringQuery(r, api.NameKey, "") + + meta, err := apiutil.ReadMetadataQuery(r, api.MetadataKey, nil) if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - t, err := apiutil.ReadStringQuery(r, api.TagKey, "") + + offset, err := apiutil.ReadNumQuery[uint64](r, api.OffsetKey, api.DefOffset) if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - id, err := apiutil.ReadStringQuery(r, api.IDOrder, "") + limit, err := apiutil.ReadNumQuery[uint64](r, api.LimitKey, api.DefLimit) if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - p, err := apiutil.ReadStringQuery(r, api.PermissionKey, api.DefPermission) + + dir, err := apiutil.ReadStringQuery(r, api.DirKey, api.DefDir) if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - lp, err := apiutil.ReadBoolQuery(r, api.ListPerms, api.DefListPerms) + order, err := apiutil.ReadStringQuery(r, api.OrderKey, api.DefOrder) if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) } - st, err := clients.ToStatus(s) + + allActions, err := apiutil.ReadStringQuery(r, api.ActionsKey, "") if err != nil { - return nil, errors.Wrap(apiutil.ErrValidation, err) + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) + } + + actions := []string{} + + allActions = strings.TrimSpace(allActions) + if allActions != "" { + actions = strings.Split(allActions, ",") } + roleID, err := apiutil.ReadStringQuery(r, api.RoleIDKey, "") + if err != nil { + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) + } + + roleName, err := apiutil.ReadStringQuery(r, api.RoleNameKey, "") + if err != nil { + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) + } + + accessType, err := apiutil.ReadStringQuery(r, api.AccessTypeKey, "") + if err != nil { + return clients.Page{}, errors.Wrap(apiutil.ErrValidation, err) + } + + userID, err := apiutil.ReadStringQuery(r, api.UserKey, "") + if err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrValidation, err) + } + + groupID, err := apiutil.ReadStringQuery(r, api.GroupKey, "") + if err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrValidation, err) + } + + channelID, err := apiutil.ReadStringQuery(r, api.ChannelKey, "") + if err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrValidation, err) + } + req := listClientsReq{ - status: st, - offset: o, - limit: l, - metadata: m, - name: n, - tag: t, - permission: p, - listPerms: lp, - userID: chi.URLParam(r, "userID"), - id: id, + name: name, + tag: tag, + status: status, + metadata: meta, + roleName: roleName, + roleID: roleID, + actions: actions, + accessType: accessType, + order: order, + dir: dir, + offset: offset, + limit: limit, + groupID: groupID, + channelID: channelID, + userID: userID, } return req, nil } diff --git a/clients/api/http/endpoints.go b/clients/api/http/endpoints.go index 575e41949c..5d01fe591e 100644 --- a/clients/api/http/endpoints.go +++ b/clients/api/http/endpoints.go @@ -104,19 +104,25 @@ func listClientsEndpoint(svc clients.Service) endpoint.Endpoint { } pm := clients.Page{ - Status: req.status, - Offset: req.offset, - Limit: req.limit, - Name: req.name, - Tag: req.tag, - Permission: req.permission, - Metadata: req.metadata, - ListPerms: req.listPerms, - Id: req.id, - } - page, err := svc.ListClients(ctx, session, req.userID, pm) + Status: req.status, + Offset: req.offset, + Limit: req.limit, + Name: req.name, + Tag: req.tag, + Metadata: req.metadata, + Group: req.groupID, + } + + var page clients.ClientsPage + var err error + switch req.userID != "" { + case true: + page, err = svc.ListUserClients(ctx, session, req.userID, pm) + default: + page, err = svc.ListClients(ctx, session, pm) + } if err != nil { - return nil, err + return clientsPageRes{}, err } res := clientsPageRes{ diff --git a/clients/api/http/requests.go b/clients/api/http/requests.go index ae2e586f21..7bb8eae306 100644 --- a/clients/api/http/requests.go +++ b/clients/api/http/requests.go @@ -71,29 +71,28 @@ func (req viewClientPermsReq) validate() error { } type listClientsReq struct { + name string + tag string status clients.Status + metadata clients.Metadata + roleName string + roleID string + actions []string + accessType string + order string + dir string offset uint64 limit uint64 - name string - tag string - permission string - visibility string + groupID string + channelID string userID string - listPerms bool - metadata clients.Metadata - id string } func (req listClientsReq) validate() error { if req.limit > api.MaxLimitSize || req.limit < 1 { return apiutil.ErrLimitSize } - if req.visibility != "" && - req.visibility != api.AllVisibility && - req.visibility != api.MyVisibility && - req.visibility != api.SharedVisibility { - return apiutil.ErrInvalidVisibilityType - } + if len(req.name) > api.MaxNameSize { return apiutil.ErrNameSize } diff --git a/clients/api/http/requests_test.go b/clients/api/http/requests_test.go index cc9fcc0d5a..c7aa70bca9 100644 --- a/clients/api/http/requests_test.go +++ b/clients/api/http/requests_test.go @@ -213,8 +213,7 @@ func TestListClientsReqValidate(t *testing.T) { { desc: "invalid visibility", req: listClientsReq{ - limit: 10, - visibility: "invalid", + limit: 10, }, err: apiutil.ErrInvalidVisibilityType, }, diff --git a/clients/clients.go b/clients/clients.go index a4194cd90d..6d0d70db80 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -35,6 +35,9 @@ type Repository interface { // RetrieveAll retrieves all clients. RetrieveAll(ctx context.Context, pm Page) (ClientsPage, error) + //RetrieveUserThings retrieve all clients of a given user id. + RetrieveUserThings(ctx context.Context, domainID, userID string, pm Page) (ClientsPage, error) + // SearchClients retrieves clients based on search criteria. SearchClients(ctx context.Context, pm Page) (ClientsPage, error) @@ -105,8 +108,11 @@ type Service interface { // View retrieves client info for a given client ID and an authorized token. View(ctx context.Context, session authn.Session, id string) (Client, error) - // ListClients retrieves clients list for a valid auth token. - ListClients(ctx context.Context, session authn.Session, reqUserID string, pm Page) (ClientsPage, error) + // ListClients retrieves clients list for given page query. + ListClients(ctx context.Context, session authn.Session, pm Page) (ClientsPage, error) + + //ListUserClients retrieves clients list for a given user id and page query + ListUserClients(ctx context.Context, session authn.Session, userID string, pm Page) (ClientsPage, error) // Update updates the client's name and metadata. Update(ctx context.Context, session authn.Session, client Client) (Client, error) @@ -161,8 +167,20 @@ type Client struct { UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedBy string `json:"updated_by,omitempty"` Status Status `json:"status,omitempty"` // 1 for enabled, 0 for disabled - Permissions []string `json:"permissions,omitempty"` Identity string `json:"identity,omitempty"` + // Extended + ParentGroupPath string `json:"parent_group_path"` + RoleID string `json:"role_id"` + RoleName string `json:"role_name"` + Actions []string `json:"actions"` + AccessType string `json:"access_type"` + AccessProviderId string `json:"access_provider_id"` + AccessProviderRoleId string `json:"access_provider_role_id"` + AccessProviderRoleName string `json:"access_provider_role_name"` + AccessProviderRoleActions []string `json:"access_provider_role_actions"` +} + +type ClientsAdditional struct { } // ClientsPage contains page related metadata as well as list. @@ -182,21 +200,26 @@ type MembersPage struct { // Page contains the page metadata that helps navigation. type Page struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - Name string `json:"name,omitempty"` - 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"` - Identity string `json:"identity,omitempty"` - ListPerms bool `json:"-"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + Name string `json:"name,omitempty"` + 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"` + Status Status `json:"status,omitempty"` + IDs []string `json:"ids,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"` } // Metadata represents arbitrary JSON. diff --git a/clients/events/events.go b/clients/events/events.go index 32fd033406..d7dfc30469 100644 --- a/clients/events/events.go +++ b/clients/events/events.go @@ -188,14 +188,12 @@ func (vcpe viewClientPermsEvent) Encode() (map[string]interface{}, error) { } type listClientEvent struct { - reqUserID string clients.Page } func (lce listClientEvent) Encode() (map[string]interface{}, error) { val := map[string]interface{}{ "operation": clientList, - "reqUserID": lce.reqUserID, "total": lce.Total, "offset": lce.Offset, "limit": lce.Limit, @@ -219,8 +217,50 @@ func (lce listClientEvent) Encode() (map[string]interface{}, error) { if lce.Tag != "" { val["tag"] = lce.Tag } - if lce.Permission != "" { - val["permission"] = lce.Permission + if lce.Status.String() != "" { + val["status"] = lce.Status.String() + } + if len(lce.IDs) > 0 { + val["ids"] = lce.IDs + } + if lce.Identity != "" { + val["identity"] = lce.Identity + } + + return val, nil +} + +type listUserClientEvent struct { + userID string + clients.Page +} + +func (lce listUserClientEvent) Encode() (map[string]interface{}, error) { + val := map[string]interface{}{ + "operation": clientList, + "userID": lce.userID, + "total": lce.Total, + "offset": lce.Offset, + "limit": lce.Limit, + } + + if lce.Name != "" { + val["name"] = lce.Name + } + if lce.Order != "" { + val["order"] = lce.Order + } + if lce.Dir != "" { + val["dir"] = lce.Dir + } + if lce.Metadata != nil { + val["metadata"] = lce.Metadata + } + if lce.Domain != "" { + val["domain"] = lce.Domain + } + if lce.Tag != "" { + val["tag"] = lce.Tag } if lce.Status.String() != "" { val["status"] = lce.Status.String() @@ -267,9 +307,6 @@ func (lcge listClientByGroupEvent) Encode() (map[string]interface{}, error) { if lcge.Tag != "" { val["tag"] = lcge.Tag } - if lcge.Permission != "" { - val["permission"] = lcge.Permission - } if lcge.Status.String() != "" { val["status"] = lcge.Status.String() } diff --git a/clients/events/streams.go b/clients/events/streams.go index 1df316b580..2e838edbfa 100644 --- a/clients/events/streams.go +++ b/clients/events/streams.go @@ -114,13 +114,28 @@ func (es *eventStore) View(ctx context.Context, session authn.Session, id string return cli, nil } -func (es *eventStore) ListClients(ctx context.Context, session authn.Session, reqUserID string, pm clients.Page) (clients.ClientsPage, error) { - cp, err := es.svc.ListClients(ctx, session, reqUserID, pm) +func (es *eventStore) ListClients(ctx context.Context, session authn.Session, pm clients.Page) (clients.ClientsPage, error) { + cp, err := es.svc.ListClients(ctx, session, pm) if err != nil { return cp, err } event := listClientEvent{ - reqUserID, + pm, + } + if err := es.Publish(ctx, event); err != nil { + return cp, err + } + + return cp, nil +} + +func (es *eventStore) ListUserClients(ctx context.Context, session authn.Session, userID string, pm clients.Page) (clients.ClientsPage, error) { + cp, err := es.svc.ListUserClients(ctx, session, userID, pm) + if err != nil { + return cp, err + } + event := listUserClientEvent{ + userID, pm, } if err := es.Publish(ctx, event); err != nil { diff --git a/clients/middleware/authorization.go b/clients/middleware/authorization.go index 3729d463c9..4bf2a3deba 100644 --- a/clients/middleware/authorization.go +++ b/clients/middleware/authorization.go @@ -129,7 +129,7 @@ func (am *authorizationMiddleware) View(ctx context.Context, session authn.Sessi return am.svc.View(ctx, session, id) } -func (am *authorizationMiddleware) ListClients(ctx context.Context, session authn.Session, reqUserID string, pm clients.Page) (clients.ClientsPage, error) { +func (am *authorizationMiddleware) ListClients(ctx context.Context, session authn.Session, pm clients.Page) (clients.ClientsPage, error) { if session.Type == authn.PersonalAccessToken { if err := am.authz.AuthorizePAT(ctx, smqauthz.PatReq{ UserID: session.UserID, @@ -144,11 +144,33 @@ func (am *authorizationMiddleware) ListClients(ctx context.Context, session auth } } - if err := am.checkSuperAdmin(ctx, session.UserID); err != nil { + if err := am.checkSuperAdmin(ctx, session.UserID); err == nil { session.SuperAdmin = true } - return am.svc.ListClients(ctx, session, reqUserID, pm) + return am.svc.ListClients(ctx, session, pm) +} + +func (am *authorizationMiddleware) ListUserClients(ctx context.Context, session authn.Session, userID string, pm clients.Page) (clients.ClientsPage, error) { + if session.Type == authn.PersonalAccessToken { + if err := am.authz.AuthorizePAT(ctx, smqauthz.PatReq{ + UserID: session.UserID, + PatID: session.ID, + PlatformEntityType: auth.PlatformDomainsScope, + OptionalDomainID: session.DomainID, + OptionalDomainEntityType: auth.DomainClientsScope, + Operation: auth.ListOp, + EntityIDs: auth.AnyIDs{}.Values(), + }); err != nil { + return clients.ClientsPage{}, errors.Wrap(svcerr.ErrUnauthorizedPAT, err) + } + } + + if err := am.checkSuperAdmin(ctx, session.UserID); err != nil { + return clients.ClientsPage{}, err + } + + return am.svc.ListUserClients(ctx, session, userID, pm) } func (am *authorizationMiddleware) Update(ctx context.Context, session authn.Session, client clients.Client) (clients.Client, error) { diff --git a/clients/middleware/logging.go b/clients/middleware/logging.go index 378409b41d..cc3eca477d 100644 --- a/clients/middleware/logging.go +++ b/clients/middleware/logging.go @@ -65,11 +65,10 @@ func (lm *loggingMiddleware) View(ctx context.Context, session authn.Session, id return lm.svc.View(ctx, session, id) } -func (lm *loggingMiddleware) ListClients(ctx context.Context, session authn.Session, reqUserID string, pm clients.Page) (cp clients.ClientsPage, err error) { +func (lm *loggingMiddleware) ListClients(ctx context.Context, session authn.Session, pm clients.Page) (cp clients.ClientsPage, err error) { defer func(begin time.Time) { args := []any{ slog.String("duration", time.Since(begin).String()), - slog.String("user_id", reqUserID), slog.Group("page", slog.Uint64("limit", pm.Limit), slog.Uint64("offset", pm.Offset), @@ -83,7 +82,28 @@ func (lm *loggingMiddleware) ListClients(ctx context.Context, session authn.Sess } lm.logger.Info("List clients completed successfully", args...) }(time.Now()) - return lm.svc.ListClients(ctx, session, reqUserID, pm) + return lm.svc.ListClients(ctx, session, pm) +} + +func (lm *loggingMiddleware) ListUserClients(ctx context.Context, session authn.Session, userID string, pm clients.Page) (cp clients.ClientsPage, err error) { + defer func(begin time.Time) { + args := []any{ + slog.String("duration", time.Since(begin).String()), + slog.String("user_id", userID), + slog.Group("page", + slog.Uint64("limit", pm.Limit), + slog.Uint64("offset", pm.Offset), + slog.Uint64("total", cp.Total), + ), + } + if err != nil { + args = append(args, slog.String("error", err.Error())) + lm.logger.Warn("List clients failed", args...) + return + } + lm.logger.Info("List clients completed successfully", args...) + }(time.Now()) + return lm.svc.ListUserClients(ctx, session, userID, pm) } func (lm *loggingMiddleware) Update(ctx context.Context, session authn.Session, client clients.Client) (c clients.Client, err error) { diff --git a/clients/middleware/metrics.go b/clients/middleware/metrics.go index ca79ae903f..12a4b1d805 100644 --- a/clients/middleware/metrics.go +++ b/clients/middleware/metrics.go @@ -49,12 +49,20 @@ func (ms *metricsMiddleware) View(ctx context.Context, session authn.Session, id return ms.svc.View(ctx, session, id) } -func (ms *metricsMiddleware) ListClients(ctx context.Context, session authn.Session, reqUserID string, pm clients.Page) (clients.ClientsPage, error) { +func (ms *metricsMiddleware) ListClients(ctx context.Context, session authn.Session, pm clients.Page) (clients.ClientsPage, error) { defer func(begin time.Time) { ms.counter.With("method", "list_clients").Add(1) ms.latency.With("method", "list_clients").Observe(time.Since(begin).Seconds()) }(time.Now()) - return ms.svc.ListClients(ctx, session, reqUserID, pm) + return ms.svc.ListClients(ctx, session, pm) +} + +func (ms *metricsMiddleware) ListUserClients(ctx context.Context, session authn.Session, userID string, pm clients.Page) (clients.ClientsPage, error) { + defer func(begin time.Time) { + ms.counter.With("method", "list_user_clients").Add(1) + ms.latency.With("method", "list_user_clients").Observe(time.Since(begin).Seconds()) + }(time.Now()) + return ms.svc.ListUserClients(ctx, session, userID, pm) } func (ms *metricsMiddleware) Update(ctx context.Context, session authn.Session, client clients.Client) (clients.Client, error) { diff --git a/clients/mocks/repository.go b/clients/mocks/repository.go index 873aadb1fc..6c3f525b2d 100644 --- a/clients/mocks/repository.go +++ b/clients/mocks/repository.go @@ -577,6 +577,34 @@ func (_m *Repository) RetrieveRole(ctx context.Context, roleID string) (roles.Ro return r0, r1 } +// RetrieveUserThings provides a mock function with given fields: ctx, domainID, userID, pm +func (_m *Repository) RetrieveUserThings(ctx context.Context, domainID string, userID string, pm clients.Page) (clients.ClientsPage, error) { + ret := _m.Called(ctx, domainID, userID, pm) + + if len(ret) == 0 { + panic("no return value specified for RetrieveUserThings") + } + + var r0 clients.ClientsPage + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, clients.Page) (clients.ClientsPage, error)); ok { + return rf(ctx, domainID, userID, pm) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, clients.Page) clients.ClientsPage); ok { + r0 = rf(ctx, domainID, userID, pm) + } else { + r0 = ret.Get(0).(clients.ClientsPage) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, clients.Page) error); ok { + r1 = rf(ctx, domainID, userID, pm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // RoleAddActions provides a mock function with given fields: ctx, role, actions func (_m *Repository) RoleAddActions(ctx context.Context, role roles.Role, actions []string) ([]string, error) { ret := _m.Called(ctx, role, actions) diff --git a/clients/mocks/service.go b/clients/mocks/service.go index 08baa914ce..3d64f162cd 100644 --- a/clients/mocks/service.go +++ b/clients/mocks/service.go @@ -198,27 +198,55 @@ func (_m *Service) ListAvailableActions(ctx context.Context, session authn.Sessi return r0, r1 } -// ListClients provides a mock function with given fields: ctx, session, reqUserID, pm -func (_m *Service) ListClients(ctx context.Context, session authn.Session, reqUserID string, pm clients.Page) (clients.ClientsPage, error) { - ret := _m.Called(ctx, session, reqUserID, pm) +// ListClients provides a mock function with given fields: ctx, session, pm +func (_m *Service) ListClients(ctx context.Context, session authn.Session, pm clients.Page) (clients.ClientsPage, error) { + ret := _m.Called(ctx, session, pm) if len(ret) == 0 { panic("no return value specified for ListClients") } + var r0 clients.ClientsPage + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, authn.Session, clients.Page) (clients.ClientsPage, error)); ok { + return rf(ctx, session, pm) + } + if rf, ok := ret.Get(0).(func(context.Context, authn.Session, clients.Page) clients.ClientsPage); ok { + r0 = rf(ctx, session, pm) + } else { + r0 = ret.Get(0).(clients.ClientsPage) + } + + if rf, ok := ret.Get(1).(func(context.Context, authn.Session, clients.Page) error); ok { + r1 = rf(ctx, session, pm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListUserClients provides a mock function with given fields: ctx, session, userID, pm +func (_m *Service) ListUserClients(ctx context.Context, session authn.Session, userID string, pm clients.Page) (clients.ClientsPage, error) { + ret := _m.Called(ctx, session, userID, pm) + + if len(ret) == 0 { + panic("no return value specified for ListUserClients") + } + var r0 clients.ClientsPage var r1 error if rf, ok := ret.Get(0).(func(context.Context, authn.Session, string, clients.Page) (clients.ClientsPage, error)); ok { - return rf(ctx, session, reqUserID, pm) + return rf(ctx, session, userID, pm) } if rf, ok := ret.Get(0).(func(context.Context, authn.Session, string, clients.Page) clients.ClientsPage); ok { - r0 = rf(ctx, session, reqUserID, pm) + r0 = rf(ctx, session, userID, pm) } else { r0 = ret.Get(0).(clients.ClientsPage) } if rf, ok := ret.Get(1).(func(context.Context, authn.Session, string, clients.Page) error); ok { - r1 = rf(ctx, session, reqUserID, pm) + r1 = rf(ctx, session, userID, pm) } else { r1 = ret.Error(1) } diff --git a/clients/postgres/clients.go b/clients/postgres/clients.go index 62717f9edf..36cb72e25a 100644 --- a/clients/postgres/clients.go +++ b/clients/postgres/clients.go @@ -20,6 +20,7 @@ import ( "github.com/absmach/supermq/pkg/postgres" rolesPostgres "github.com/absmach/supermq/pkg/roles/repo/postgres" "github.com/jackc/pgtype" + "github.com/lib/pq" ) const ( @@ -247,6 +248,301 @@ func (repo *clientRepo) RetrieveAll(ctx context.Context, pm clients.Page) (clien return page, nil } +func (repo *clientRepo) RetrieveUserThings(ctx context.Context, domainID, userID string, pm clients.Page) (clients.ClientsPage, error) { + return repo.retrieveClients(ctx, domainID, userID, pm) +} + +func (repo *clientRepo) retrieveClients(ctx context.Context, domainID, userID string, pm clients.Page) (clients.ClientsPage, error) { + pageQuery, err := PageQuery(pm) + if err != nil { + return clients.ClientsPage{}, err + } + + bq := repo.userClientBaseQuery(domainID, userID) + + q := fmt.Sprintf(` + %s + SELECT + c.id, + c.name, + c.domain_id, + c.parent_group_id, + c.identity, + c.secret, + c.tags, + c.metadata, + c.created_at, + c.updated_at, + c.updated_by, + c.status, + c.parent_group_path, + c.role_id, + c.role_name, + c.actions, + c.access_type, + c.access_provider_id, + c.access_provider_role_id, + c.access_provider_role_name, + c.access_provider_role_actions + FROM + final_clients c + %s + `, bq, pageQuery) + + q = applyOrdering(q, pm) + + dbPage, err := ToDBClientsPage(pm) + if err != nil { + return clients.ClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } + + rows, err := repo.DB.NamedQueryContext(ctx, q, dbPage) + if err != nil { + return clients.ClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } + defer rows.Close() + + var items []clients.Client + for rows.Next() { + dbc := DBClient{} + if err := rows.StructScan(&dbc); err != nil { + return clients.ClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } + + c, err := ToClient(dbc) + if err != nil { + return clients.ClientsPage{}, err + } + + items = append(items, c) + } + + chJoinQuery := "" + if pm.Channel != "" { + chJoinQuery = "JOIN connection conn ON conn.client_id = c.id" + } + cq := fmt.Sprintf(`%s + SELECT COUNT(*) AS total_count + FROM ( + SELECT + c.id, + c.name, + c.domain_id, + c.parent_group_id, + c.identity, + c.secret, + c.tags, + c.metadata, + c.created_at, + c.updated_at, + c.updated_by, + c.status, + c.parent_group_path, + c.role_id, + c.role_name, + c.actions, + c.access_type, + c.access_provider_id, + c.access_provider_role_id, + c.access_provider_role_name, + c.access_provider_role_actions + FROM + final_clients c + %s + %s + ) AS subquery; + `, bq, chJoinQuery, pageQuery) + + total, err := postgres.Total(ctx, repo.DB, cq, dbPage) + if err != nil { + return clients.ClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) + } + + page := clients.ClientsPage{ + Clients: items, + Page: clients.Page{ + Total: total, + Offset: pm.Offset, + Limit: pm.Limit, + }, + } + + return page, nil +} + +func (repo *clientRepo) userClientBaseQuery(domainID, userID string) string { + return fmt.Sprintf(` + WITH direct_clients AS ( + SELECT + c.id, + c.name, + c.domain_id, + c.parent_group_id, + c.identity, + c.secret, + c.tags, + c.metadata, + c.created_at, + c.updated_at, + c.updated_by, + c.status, + text2ltree('') as parent_group_path, + cr.id AS role_id, + cr."name" AS role_name, + array_agg(cra."action") AS actions, + 'direct' as access_type, + '' AS access_provider_id, + '' AS access_provider_role_id, + '' AS access_provider_role_name, + array[]::::text[] AS access_provider_role_actions + FROM + clients_role_members crm + JOIN + clients_role_actions cra ON cra.role_id = crm.role_id + JOIN + clients_roles cr ON cr.id = crm.role_id + JOIN + clients c ON c.id = cr.entity_id + WHERE + crm.member_id = '%s' + AND c.domain_id = '%s' + GROUP BY + cr.entity_id, crm.member_id, cr.id, cr."name", c.id + ), + direct_groups AS ( + SELECT + g.*, + gr.entity_id AS entity_id, + grm.member_id AS member_id, + gr.id AS role_id, + gr."name" AS role_name, + array_agg(gra."action") AS actions + FROM + groups_role_members grm + JOIN + groups_role_actions gra ON gra.role_id = grm.role_id + JOIN + groups_roles gr ON gr.id = grm.role_id + JOIN + "groups" g ON g.id = gr.entity_id + WHERE + grm.member_id = '%s' + AND g.domain_id = '%s' + GROUP BY + gr.entity_id, grm.member_id, gr.id, gr."name", g."path", g.id + ), + direct_groups_with_subgroup AS ( + SELECT + * + FROM direct_groups + WHERE EXISTS ( + SELECT 1 + FROM unnest(direct_groups.actions) AS action + WHERE action LIKE 'subgroup_%%' + ) + ), + indirect_child_groups AS ( + SELECT + DISTINCT indirect_child_groups.id as child_id, + indirect_child_groups.*, + dgws.id as access_provider_id, + dgws.role_id as access_provider_role_id, + dgws.role_name as access_provider_role_name, + dgws.actions as access_provider_role_actions + FROM + direct_groups_with_subgroup dgws + JOIN + groups indirect_child_groups ON indirect_child_groups.path <@ dgws.path + WHERE + indirect_child_groups.domain_id = '%s' + AND NOT EXISTS ( + SELECT 1 + FROM direct_groups_with_subgroup dgws + WHERE dgws.id = indirect_child_groups.id + ) + ), + final_groups AS ( + SELECT + id, + parent_id, + domain_id, + "name", + description, + metadata, + created_at, + updated_at, + updated_by, + status, + "path", + role_id, + role_name, + actions, + 'direct_group' AS access_type, + '' AS access_provider_id, + '' AS access_provider_role_id, + '' AS access_provider_role_name, + array[]::::text[] AS access_provider_role_actions + FROM + direct_groups + UNION + SELECT + id, + parent_id, + domain_id, + "name", + description, + metadata, + created_at, + updated_at, + updated_by, + status, + "path", + '' AS role_id, + '' AS role_name, + array[]::::text[] AS actions, + 'indirect_group' AS access_type, + access_provider_id, + access_provider_role_id, + access_provider_role_name, + access_provider_role_actions + FROM + indirect_child_groups + ), + final_clients AS ( + SELECT + c.id, + c.name, + c.domain_id, + c.parent_group_id, + c.identity, + c.secret, + c.tags, + c.metadata, + c.created_at, + c.updated_at, + c.updated_by, + c.status, + g.path AS parent_group_path, + g.role_id, + g.role_name, + g.actions, + g.access_type, + g.access_provider_id, + g.access_provider_role_id, + g.access_provider_role_name, + g.access_provider_role_actions + FROM + final_groups g + JOIN + clients c ON c.parent_group_id = g.id + WHERE + c.id NOT IN (SELECT id FROM direct_clients) + UNION + SELECT * FROM direct_clients + ) + `, userID, domainID, userID, domainID, domainID) +} + func (repo *clientRepo) SearchClients(ctx context.Context, pm clients.Page) (clients.ClientsPage, error) { query, err := PageQuery(pm) if err != nil { @@ -402,18 +698,27 @@ func (repo *clientRepo) Delete(ctx context.Context, clientIDs ...string) error { } type DBClient struct { - ID string `db:"id"` - Name string `db:"name,omitempty"` - Tags pgtype.TextArray `db:"tags,omitempty"` - Identity string `db:"identity"` - Domain string `db:"domain_id"` - ParentGroup sql.NullString `db:"parent_group_id,omitempty"` - Secret string `db:"secret"` - Metadata []byte `db:"metadata,omitempty"` - CreatedAt time.Time `db:"created_at,omitempty"` - UpdatedAt sql.NullTime `db:"updated_at,omitempty"` - UpdatedBy *string `db:"updated_by,omitempty"` - Status clients.Status `db:"status,omitempty"` + ID string `db:"id"` + Name string `db:"name,omitempty"` + Tags pgtype.TextArray `db:"tags,omitempty"` + Identity string `db:"identity"` + Domain string `db:"domain_id"` + ParentGroup sql.NullString `db:"parent_group_id,omitempty"` + Secret string `db:"secret"` + Metadata []byte `db:"metadata,omitempty"` + CreatedAt time.Time `db:"created_at,omitempty"` + UpdatedAt sql.NullTime `db:"updated_at,omitempty"` + UpdatedBy *string `db:"updated_by,omitempty"` + Status clients.Status `db:"status,omitempty"` + ParentGroupPath string `db:"parent_group_path,omitempty"` + RoleID string `db:"role_id,omitempty"` + RoleName string `db:"role_name,omitempty"` + Actions pq.StringArray `db:"actions,omitempty"` + AccessType string `db:"access_type,omitempty"` + AccessProviderId string `db:"access_provider_id,omitempty"` + AccessProviderRoleId string `db:"access_provider_role_id,omitempty"` + AccessProviderRoleName string `db:"access_provider_role_name,omitempty"` + AccessProviderRoleActions pq.StringArray `db:"access_provider_role_actions,omitempty"` } func ToDBClient(c clients.Client) (DBClient, error) { @@ -484,11 +789,19 @@ func ToClient(t DBClient) (clients.Client, error) { Identity: t.Identity, Secret: t.Secret, }, - Metadata: metadata, - CreatedAt: t.CreatedAt, - UpdatedAt: updatedAt, - UpdatedBy: updatedBy, - Status: t.Status, + Metadata: metadata, + CreatedAt: t.CreatedAt, + UpdatedAt: updatedAt, + UpdatedBy: updatedBy, + Status: t.Status, + RoleID: t.RoleID, + RoleName: t.RoleName, + Actions: t.Actions, + AccessType: t.AccessType, + AccessProviderId: t.AccessProviderId, + AccessProviderRoleId: t.AccessProviderRoleId, + AccessProviderRoleName: t.AccessProviderRoleName, + AccessProviderRoleActions: t.AccessProviderRoleActions, } return cli, nil } @@ -499,31 +812,43 @@ func ToDBClientsPage(pm clients.Page) (dbClientsPage, error) { return dbClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) } return dbClientsPage{ - Name: pm.Name, - Identity: pm.Identity, - Id: pm.Id, - Metadata: data, - Domain: pm.Domain, - Total: pm.Total, - Offset: pm.Offset, - Limit: pm.Limit, - Status: pm.Status, - Tag: pm.Tag, + Name: pm.Name, + Identity: pm.Identity, + Id: pm.Id, + Metadata: data, + Domain: pm.Domain, + Total: pm.Total, + Offset: pm.Offset, + Limit: pm.Limit, + Status: pm.Status, + Tag: pm.Tag, + GroupID: pm.Group, + ChannelID: pm.Channel, + RoleName: pm.RoleName, + RoleID: pm.RoleID, + Actions: pm.Actions, + AccessType: pm.AccessType, }, nil } type dbClientsPage struct { - Total uint64 `db:"total"` - 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"` + Total uint64 `db:"total"` + 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"` } func PageQuery(pm clients.Page) (string, error) { @@ -534,29 +859,24 @@ func PageQuery(pm clients.Page) (string, error) { var query []string if pm.Name != "" { - query = append(query, "name ILIKE '%' || :name || '%'") + query = append(query, "c.name ILIKE '%' || :name || '%'") } if pm.Identity != "" { - query = append(query, "identity ILIKE '%' || :identity || '%'") + query = append(query, "c.identity ILIKE '%' || :identity || '%'") } if pm.Id != "" { - query = append(query, "id ILIKE '%' || :id || '%'") + query = append(query, "c.id ILIKE '%' || :id || '%'") } if pm.Tag != "" { query = append(query, "EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE '%' || :tag || '%')") } - // If there are search params presents, use search and ignore other options. - // Always combine role with search params, so len(query) > 1. - if len(query) > 1 { - return fmt.Sprintf("WHERE %s", strings.Join(query, " AND ")), nil - } if mq != "" { query = append(query, mq) } if len(pm.IDs) != 0 { - query = append(query, fmt.Sprintf("id IN ('%s')", strings.Join(pm.IDs, "','"))) + query = append(query, fmt.Sprintf("c.id IN ('%s')", strings.Join(pm.IDs, "','"))) } if pm.Status != clients.AllStatus { query = append(query, "c.status = :status") @@ -564,6 +884,14 @@ func PageQuery(pm clients.Page) (string, error) { if pm.Domain != "" { query = append(query, "c.domain_id = :domain_id") } + if pm.Group != "" { + query = append(query, "c.parent_group_path @> (SELECT path from groups where id = :group_id) ") + } + if pm.Channel != "" { + if pm.ConnectionType != "" { + query = append(query, "conn.type = :conn_type ") + } + } var emq string if len(query) > 0 { emq = fmt.Sprintf("WHERE %s", strings.Join(query, " AND ")) diff --git a/clients/postgres/init.go b/clients/postgres/init.go index 11b0110ef1..0a3d347c66 100644 --- a/clients/postgres/init.go +++ b/clients/postgres/init.go @@ -4,6 +4,7 @@ package postgres import ( + gpostgres "github.com/absmach/supermq/groups/postgres" "github.com/absmach/supermq/pkg/errors" repoerr "github.com/absmach/supermq/pkg/errors/repository" rolesPostgres "github.com/absmach/supermq/pkg/roles/repo/postgres" @@ -60,5 +61,12 @@ func Migration() (*migrate.MemoryMigrationSource, error) { clientsMigration.Migrations = append(clientsMigration.Migrations, clientsRolesMigration.Migrations...) + groupsMigration, err := gpostgres.Migration() + if err != nil { + return &migrate.MemoryMigrationSource{}, err + } + + clientsMigration.Migrations = append(clientsMigration.Migrations, groupsMigration.Migrations...) + return clientsMigration, nil } diff --git a/clients/service.go b/clients/service.go index 471e30a680..0cb34df7d6 100644 --- a/clients/service.go +++ b/clients/service.go @@ -12,13 +12,11 @@ import ( grpcCommonV1 "github.com/absmach/supermq/api/grpc/common/v1" grpcGroupsV1 "github.com/absmach/supermq/api/grpc/groups/v1" apiutil "github.com/absmach/supermq/api/http/util" - smqauth "github.com/absmach/supermq/auth" "github.com/absmach/supermq/pkg/authn" "github.com/absmach/supermq/pkg/errors" svcerr "github.com/absmach/supermq/pkg/errors/service" "github.com/absmach/supermq/pkg/policies" "github.com/absmach/supermq/pkg/roles" - "golang.org/x/sync/errgroup" ) var ( @@ -131,66 +129,29 @@ func (svc service) View(ctx context.Context, session authn.Session, id string) ( return client, nil } -func (svc service) ListClients(ctx context.Context, session authn.Session, reqUserID string, pm Page) (ClientsPage, error) { - var ids []string - var err error - switch { - case (reqUserID != "" && reqUserID != session.UserID): - rtids, err := svc.listClientIDs(ctx, smqauth.EncodeDomainUserID(session.DomainID, reqUserID), pm.Permission) - if err != nil { - return ClientsPage{}, errors.Wrap(svcerr.ErrNotFound, err) - } - ids, err = svc.filterAllowedClientIDs(ctx, session.DomainUserID, pm.Permission, rtids) +func (svc service) ListClients(ctx context.Context, session authn.Session, pm Page) (ClientsPage, error) { + switch session.SuperAdmin { + case true: + cp, err := svc.repo.RetrieveAll(ctx, pm) if err != nil { - return ClientsPage{}, errors.Wrap(svcerr.ErrNotFound, err) + return ClientsPage{}, errors.Wrap(svcerr.ErrViewEntity, err) } + return cp, nil default: - switch session.SuperAdmin { - case true: - pm.Domain = session.DomainID - default: - ids, err = svc.listClientIDs(ctx, session.DomainUserID, pm.Permission) - if err != nil { - return ClientsPage{}, errors.Wrap(svcerr.ErrNotFound, err) - } - } - } - - if len(ids) == 0 && pm.Domain == "" { - return ClientsPage{}, nil - } - pm.IDs = ids - tp, err := svc.repo.SearchClients(ctx, pm) - if err != nil { - return ClientsPage{}, errors.Wrap(svcerr.ErrViewEntity, err) - } - - if pm.ListPerms && len(tp.Clients) > 0 { - g, ctx := errgroup.WithContext(ctx) - - for i := range tp.Clients { - // Copying loop variable "i" to avoid "loop variable captured by func literal" - iter := i - g.Go(func() error { - return svc.retrievePermissions(ctx, session.DomainUserID, &tp.Clients[iter]) - }) - } - - if err := g.Wait(); err != nil { - return ClientsPage{}, err + cp, err := svc.repo.RetrieveUserThings(ctx, session.DomainID, session.UserID, pm) + if err != nil { + return ClientsPage{}, errors.Wrap(svcerr.ErrViewEntity, err) } + return cp, nil } - return tp, nil } -// Experimental functions used for async calling of svc.listUserClientPermission. This might be helpful during listing of large number of entities. -func (svc service) retrievePermissions(ctx context.Context, userID string, client *Client) error { - permissions, err := svc.listUserClientPermission(ctx, userID, client.ID) +func (svc service) ListUserClients(ctx context.Context, session authn.Session, userID string, pm Page) (ClientsPage, error) { + cp, err := svc.repo.RetrieveUserThings(ctx, session.DomainID, userID, pm) if err != nil { - return err + return ClientsPage{}, errors.Wrap(svcerr.ErrViewEntity, err) } - client.Permissions = permissions - return nil + return cp, nil } func (svc service) listUserClientPermission(ctx context.Context, userID, clientID string) ([]string, error) { diff --git a/clients/service_test.go b/clients/service_test.go index 9683bdc857..9bb8601500 100644 --- a/clients/service_test.go +++ b/clients/service_test.go @@ -351,7 +351,6 @@ func TestListClients(t *testing.T) { adminID := testsutil.GenerateUUID(t) domainID := testsutil.GenerateUUID(t) nonAdminID := testsutil.GenerateUUID(t) - client.Permissions = []string{"read", "edit"} cases := []struct { desc string @@ -375,9 +374,8 @@ func TestListClients(t *testing.T) { session: smqauthn.Session{UserID: nonAdminID, DomainID: domainID, SuperAdmin: false}, id: nonAdminID, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, + Offset: 0, + Limit: 100, }, listObjectsResponse: policysvc.PolicyPage{Policies: []string{client.ID, client.ID}}, retrieveAllResponse: clients.ClientsPage{ @@ -388,7 +386,6 @@ func TestListClients(t *testing.T) { }, Clients: []clients.Client{client, client}, }, - listPermissionsResponse: client.Permissions, response: clients.ClientsPage{ Page: clients.Page{ Total: 2, @@ -405,9 +402,8 @@ func TestListClients(t *testing.T) { session: smqauthn.Session{UserID: nonAdminID, DomainID: domainID, SuperAdmin: false}, id: nonAdminID, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, + Offset: 0, + Limit: 100, }, listObjectsResponse: policysvc.PolicyPage{Policies: []string{client.ID, client.ID}}, retrieveAllResponse: clients.ClientsPage{}, @@ -421,9 +417,8 @@ func TestListClients(t *testing.T) { session: smqauthn.Session{UserID: nonAdminID, DomainID: domainID, SuperAdmin: false}, id: nonAdminID, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, + Offset: 0, + Limit: 100, }, listObjectsResponse: policysvc.PolicyPage{Policies: []string{client.ID, client.ID}}, retrieveAllResponse: clients.ClientsPage{ @@ -445,9 +440,8 @@ func TestListClients(t *testing.T) { session: smqauthn.Session{UserID: nonAdminID, DomainID: domainID, SuperAdmin: false}, id: nonAdminID, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, + Offset: 0, + Limit: 100, }, response: clients.ClientsPage{}, listObjectsResponse: policysvc.PolicyPage{}, @@ -458,9 +452,8 @@ func TestListClients(t *testing.T) { userKind: "non-admin", id: nonAdminID, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, + Offset: 0, + Limit: 100, }, response: clients.ClientsPage{}, listObjectsResponse: policysvc.PolicyPage{}, @@ -473,7 +466,7 @@ func TestListClients(t *testing.T) { listAllObjectsCall := pService.On("ListAllObjects", mock.Anything, mock.Anything).Return(tc.listObjectsResponse, tc.listObjectsErr) retrieveAllCall := repo.On("SearchClients", mock.Anything, mock.Anything).Return(tc.retrieveAllResponse, tc.retrieveAllErr) listPermissionsCall := pService.On("ListPermissions", mock.Anything, mock.Anything, mock.Anything).Return(tc.listPermissionsResponse, tc.listPermissionsErr) - page, err := svc.ListClients(context.Background(), tc.session, tc.id, tc.page) + page, err := svc.ListClients(context.Background(), tc.session, tc.page) assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err)) assert.Equal(t, tc.response, page, fmt.Sprintf("%s: expected %v got %v\n", tc.desc, tc.response, page)) listAllObjectsCall.Unset() @@ -503,10 +496,9 @@ func TestListClients(t *testing.T) { id: adminID, session: smqauthn.Session{UserID: adminID, DomainID: domainID, SuperAdmin: true}, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, - Domain: domainID, + Offset: 0, + Limit: 100, + Domain: domainID, }, listObjectsResponse: policysvc.PolicyPage{Policies: []string{client.ID, client.ID}}, retrieveAllResponse: clients.ClientsPage{ @@ -517,7 +509,6 @@ func TestListClients(t *testing.T) { }, Clients: []clients.Client{client, client}, }, - listPermissionsResponse: client.Permissions, response: clients.ClientsPage{ Page: clients.Page{ Total: 2, @@ -534,10 +525,9 @@ func TestListClients(t *testing.T) { id: adminID, session: smqauthn.Session{UserID: adminID, DomainID: domainID, SuperAdmin: true}, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, - Domain: domainID, + Offset: 0, + Limit: 100, + Domain: domainID, }, listObjectsResponse: policysvc.PolicyPage{}, retrieveAllResponse: clients.ClientsPage{}, @@ -550,10 +540,9 @@ func TestListClients(t *testing.T) { id: adminID, session: smqauthn.Session{UserID: adminID, DomainID: domainID, SuperAdmin: true}, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, - Domain: domainID, + Offset: 0, + Limit: 100, + Domain: domainID, }, listObjectsResponse: policysvc.PolicyPage{}, retrieveAllResponse: clients.ClientsPage{ @@ -574,10 +563,9 @@ func TestListClients(t *testing.T) { id: adminID, session: smqauthn.Session{UserID: adminID, DomainID: domainID, SuperAdmin: true}, page: clients.Page{ - Offset: 0, - Limit: 100, - ListPerms: true, - Domain: domainID, + Offset: 0, + Limit: 100, + Domain: domainID, }, retrieveAllResponse: clients.ClientsPage{}, retrieveAllErr: repoerr.ErrNotFound, @@ -600,7 +588,7 @@ func TestListClients(t *testing.T) { }).Return(tc.listObjectsResponse, tc.listObjectsErr) retrieveAllCall := repo.On("SearchClients", mock.Anything, mock.Anything).Return(tc.retrieveAllResponse, tc.retrieveAllErr) listPermissionsCall := pService.On("ListPermissions", mock.Anything, mock.Anything, mock.Anything).Return(tc.listPermissionsResponse, tc.listPermissionsErr) - page, err := svc.ListClients(context.Background(), tc.session, tc.id, tc.page) + page, err := svc.ListClients(context.Background(), tc.session, tc.page) assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err)) assert.Equal(t, tc.response, page, fmt.Sprintf("%s: expected %v got %v\n", tc.desc, tc.response, page)) listAllObjectsCall.Unset() diff --git a/clients/tracing/tracing.go b/clients/tracing/tracing.go index fd2b266220..df69f2891c 100644 --- a/clients/tracing/tracing.go +++ b/clients/tracing/tracing.go @@ -47,10 +47,16 @@ func (tm *tracingMiddleware) View(ctx context.Context, session authn.Session, id } // ListClients traces the "ListClients" operation of the wrapped clients.Service. -func (tm *tracingMiddleware) ListClients(ctx context.Context, session authn.Session, reqUserID string, pm clients.Page) (clients.ClientsPage, error) { +func (tm *tracingMiddleware) ListClients(ctx context.Context, session authn.Session, pm clients.Page) (clients.ClientsPage, error) { ctx, span := tm.tracer.Start(ctx, "svc_list_clients") defer span.End() - return tm.svc.ListClients(ctx, session, reqUserID, pm) + return tm.svc.ListClients(ctx, session, pm) +} + +func (tm *tracingMiddleware) ListUserClients(ctx context.Context, session authn.Session, userID string, pm clients.Page) (clients.ClientsPage, error) { + ctx, span := tm.tracer.Start(ctx, "svc_list_clients") + defer span.End() + return tm.svc.ListUserClients(ctx, session, userID, pm) } // Update traces the "Update" operation of the wrapped clients.Service. diff --git a/cmd/clients/main.go b/cmd/clients/main.go index 94b9c402d3..5164b1e388 100644 --- a/cmd/clients/main.go +++ b/cmd/clients/main.go @@ -27,12 +27,14 @@ import ( "github.com/absmach/supermq/clients/postgres" pClients "github.com/absmach/supermq/clients/private" "github.com/absmach/supermq/clients/tracing" + gpostgres "github.com/absmach/supermq/groups/postgres" redisclient "github.com/absmach/supermq/internal/clients/redis" smqlog "github.com/absmach/supermq/logger" authsvcAuthn "github.com/absmach/supermq/pkg/authn/authsvc" smqauthz "github.com/absmach/supermq/pkg/authz" authsvcAuthz "github.com/absmach/supermq/pkg/authz/authsvc" domainsAuthz "github.com/absmach/supermq/pkg/domains/grpcclient" + gconsumer "github.com/absmach/supermq/pkg/groups/events/consumer" "github.com/absmach/supermq/pkg/grpcclient" jaegerclient "github.com/absmach/supermq/pkg/jaeger" "github.com/absmach/supermq/pkg/policies" @@ -82,6 +84,7 @@ type config struct { JaegerURL url.URL `env:"SMQ_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"` SendTelemetry bool `env:"SMQ_SEND_TELEMETRY" envDefault:"true"` ESURL string `env:"SMQ_ES_URL" envDefault:"nats://localhost:4222"` + ESConsumerName string `env:"SMQ_CLIENTS_EVENT_CONSUMER" envDefault:"clients"` TraceRatio float64 `env:"SMQ_JAEGER_TRACE_RATIO" envDefault:"1.0"` SpicedbHost string `env:"SMQ_SPICEDB_HOST" envDefault:"localhost"` SpicedbPort string `env:"SMQ_SPICEDB_PORT" envDefault:"50051"` @@ -257,6 +260,15 @@ func main() { return } + gdatabase := pg.NewDatabase(db, dbConfig, tracer) + grepo := gpostgres.New(gdatabase) + + if err := gconsumer.GroupsEventsSubscribe(ctx, grepo, cfg.ESURL, cfg.ESConsumerName, logger); err != nil { + logger.Error(fmt.Sprintf("failed to create groups event store : %s", err)) + exitCode = 1 + return + } + registerClientsServer := func(srv *grpc.Server) { reflection.Register(srv) grpcClientsV1.RegisterClientsServiceServer(srv, grpcapi.NewServer(psvc)) diff --git a/groups/events/events.go b/groups/events/events.go index 629ef0da6e..41656a56c6 100644 --- a/groups/events/events.go +++ b/groups/events/events.go @@ -314,9 +314,9 @@ type addChildrenGroupsEvent struct { func (acge addChildrenGroupsEvent) Encode() (map[string]interface{}, error) { return map[string]interface{}{ - "operation": groupAddChildrenGroups, - "id": acge.id, - "childre_ids": acge.childrenIDs, + "operation": groupAddChildrenGroups, + "id": acge.id, + "children_ids": acge.childrenIDs, }, nil } diff --git a/groups/groups.go b/groups/groups.go index b9bf07046f..3a442f2335 100644 --- a/groups/groups.go +++ b/groups/groups.go @@ -142,9 +142,10 @@ type Service interface { // ViewGroup retrieves data about the group identified by ID. ViewGroup(ctx context.Context, session authn.Session, id string) (Group, error) - // ListGroups retrieves + // ListGroups retrieves groups for given filters. ListGroups(ctx context.Context, session authn.Session, pm PageMeta) (Page, error) + // ListGroups retrieves user accessible groups for given filters. ListUserGroups(ctx context.Context, session authn.Session, userID string, pm PageMeta) (Page, error) // EnableGroup logically enables the group identified with the provided ID. diff --git a/groups/middleware/authorization.go b/groups/middleware/authorization.go index 0beccdf563..668b3e8bd1 100644 --- a/groups/middleware/authorization.go +++ b/groups/middleware/authorization.go @@ -73,6 +73,7 @@ func AuthorizationMiddleware(entityType string, svc groups.Service, repo groups. } return &authorizationMiddleware{ svc: svc, + repo: repo, authz: authz, opp: opp, extOpp: extOpp, diff --git a/pkg/errors/repository/types.go b/pkg/errors/repository/types.go index d468519e16..00689251d7 100644 --- a/pkg/errors/repository/types.go +++ b/pkg/errors/repository/types.go @@ -34,7 +34,8 @@ var ( // ErrFailedToRetrieveAllGroups failed to retrieve groups. ErrFailedToRetrieveAllGroups = errors.New("failed to retrieve all groups") - ErrRoleMigration = errors.New("role migration initialization failed") + // ErrRoleMigration failed to apply role migrations + ErrRoleMigration = errors.New("failed to apply role migration") // ErrMissingNames indicates missing first and last names. ErrMissingNames = errors.New("missing first or last name") diff --git a/pkg/events/events.go b/pkg/events/events.go index 65845a785c..e695099733 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -43,6 +43,7 @@ type SubscriberConfig struct { Consumer string Stream string Handler EventHandler + Ordered bool } // Subscriber specifies event subscription API. diff --git a/pkg/events/nats/subscriber.go b/pkg/events/nats/subscriber.go index 95e78bc57b..2ebb9849f9 100644 --- a/pkg/events/nats/subscriber.go +++ b/pkg/events/nats/subscriber.go @@ -93,6 +93,7 @@ func (es *subEventStore) Subscribe(ctx context.Context, cfg events.SubscriberCon logger: es.logger, }, DeliveryPolicy: messaging.DeliverNewPolicy, + Ordered: cfg.Ordered, } return es.pubsub.Subscribe(ctx, subCfg) @@ -126,8 +127,9 @@ func (eh *eventHandler) Handle(msg *messaging.Message) error { return err } - if err := eh.handler.Handle(eh.ctx, event); err != nil { - eh.logger.Warn(fmt.Sprintf("failed to handle nats event: %s", err)) + err := eh.handler.Handle(eh.ctx, event) + if err != nil { + return fmt.Errorf("failed to handle nats event: %s", err) } return nil diff --git a/pkg/groups/events/consumer/decode.go b/pkg/groups/events/consumer/decode.go new file mode 100644 index 0000000000..c21116fe57 --- /dev/null +++ b/pkg/groups/events/consumer/decode.go @@ -0,0 +1,256 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package consumer + +import ( + "time" + + "github.com/absmach/supermq/groups" + "github.com/absmach/supermq/pkg/errors" + "github.com/absmach/supermq/pkg/roles" + rconsumer "github.com/absmach/supermq/pkg/roles/rolemanager/events/consumer" +) + +var ( + errDecodeCreateGroupEvent = errors.New("failed to decode group create event") + errDecodeUpdateGroupEvent = errors.New("failed to decode group update event") + errDecodeChangeStatusGroupEvent = errors.New("failed to decode group change status event") + errDecodeRemoveGroupEvent = errors.New("failed to decode group remove event") + errDecodeAddParentGroupEvent = errors.New("failed to decode group add parent event") + errDecodeRemoveParentGroupEvent = errors.New("failed to decode group remove parent event") + errDecodeAddChildrenGroupsEvent = errors.New("failed to decode group add children groups event") + errDecodeRemoveChildrenGroupsEvent = errors.New("failed to decode group remove children groups event") + + errID = errors.New("missing or invalid 'id'") + errName = errors.New("missing or invalid 'name'") + errDomain = errors.New("missing or invalid 'domain'") + errParent = errors.New("missing or invalid 'parent'") + errChildrenIDs = errors.New("missing or invalid 'children_ids'") + errStatus = errors.New("missing or invalid 'status'") + errConvertStatus = errors.New("failed to convert status") + errCreatedAt = errors.New("failed to parse 'created_at' time") + errUpdatedAt = errors.New("failed to parse 'updated_at' time") + errNotString = errors.New("not string type") +) + +const ( + layout = "2006-01-02T15:04:05.999999Z" +) + +func ToGroups(data map[string]interface{}) (groups.Group, error) { + var g groups.Group + id, ok := data["id"].(string) + if !ok { + return groups.Group{}, errID + } + g.ID = id + + name, ok := data["name"].(string) + if !ok { + return groups.Group{}, errName + } + g.Name = name + + dom, ok := data["domain"].(string) + if !ok { + return groups.Group{}, errDomain + } + g.Domain = dom + + stat, ok := data["status"].(string) + if !ok { + return groups.Group{}, errStatus + } + st, err := groups.ToStatus(stat) + if err != nil { + return groups.Group{}, errors.Wrap(errConvertStatus, err) + } + g.Status = st + + cat, ok := data["created_at"].(string) + if !ok { + return groups.Group{}, errCreatedAt + } + ct, err := time.Parse(layout, cat) + if err != nil { + return groups.Group{}, errors.Wrap(errCreatedAt, err) + } + g.CreatedAt = ct + + // Following fields of groups are allowed to be empty. + + desc, ok := data["description"].(string) + if ok { + g.Description = desc + } + + parent, ok := data["parent"].(string) + if ok { + g.Parent = parent + } + + meta, ok := data["metadata"].(map[string]interface{}) + if ok { + g.Metadata = meta + } + + uby, ok := data["updated_by"].(string) + if ok { + g.UpdatedBy = uby + } + + uat, ok := data["updated_at"].(string) + if ok { + ut, err := time.Parse(layout, uat) + if err != nil { + return groups.Group{}, errors.Wrap(errUpdatedAt, err) + } + g.UpdatedAt = ut + } + + return g, nil +} + +func decodeCreateGroupEvent(data map[string]interface{}) (groups.Group, []roles.RoleProvision, error) { + g, err := ToGroups(data) + if err != nil { + return groups.Group{}, []roles.RoleProvision{}, errors.Wrap(errDecodeCreateGroupEvent, err) + } + irps, ok := data["roles_provisioned"].([]interface{}) + if !ok { + return groups.Group{}, []roles.RoleProvision{}, errors.Wrap(errDecodeCreateGroupEvent, errors.New("missing or invalid 'roles_provisioned'")) + } + rps, err := rconsumer.ToRoleProvisions(irps) + if err != nil { + return groups.Group{}, []roles.RoleProvision{}, errors.Wrap(errDecodeCreateGroupEvent, err) + } + + return g, rps, nil +} + +func decodeUpdateGroupEvent(data map[string]interface{}) (groups.Group, error) { + g, err := ToGroups(data) + if err != nil { + return groups.Group{}, errors.Wrap(errDecodeUpdateGroupEvent, err) + } + return g, nil +} + +func ToGroupStatus(data map[string]interface{}) (groups.Group, error) { + var g groups.Group + id, ok := data["id"].(string) + if !ok { + return groups.Group{}, errID + } + g.ID = id + + stat, ok := data["status"].(string) + if !ok { + return groups.Group{}, errStatus + } + st, err := groups.ToStatus(stat) + if err != nil { + return groups.Group{}, errors.Wrap(errConvertStatus, err) + } + g.Status = st + + uat, ok := data["updated_at"].(string) + if ok { + ut, err := time.Parse(layout, uat) + if err != nil { + return groups.Group{}, errors.Wrap(errUpdatedAt, err) + } + g.UpdatedAt = ut + } + + uby, ok := data["updated_by"].(string) + if ok { + g.UpdatedBy = uby + } + + return g, nil +} + +func decodeChangeStatusGroupEvent(data map[string]interface{}) (groups.Group, error) { + g, err := ToGroupStatus(data) + if err != nil { + return groups.Group{}, errors.Wrap(errDecodeChangeStatusGroupEvent, err) + } + return g, nil +} + +func decodeRemoveGroupEvent(data map[string]interface{}) (groups.Group, error) { + var g groups.Group + id, ok := data["id"].(string) + if !ok { + return groups.Group{}, errors.Wrap(errDecodeRemoveGroupEvent, errID) + } + g.ID = id + + return g, nil +} + +func decodeAddParentGroupEvent(data map[string]interface{}) (id string, parent string, err error) { + id, ok := data["id"].(string) + if !ok { + return "", "", errors.Wrap(errAddParentGroupEvent, errID) + } + + parent, ok = data["parent_id"].(string) + if !ok { + return "", "", errors.Wrap(errDecodeAddParentGroupEvent, errParent) + } + + return id, parent, nil +} + +func decodeRemoveParentGroupEvent(data map[string]interface{}) (id string, err error) { + id, ok := data["id"].(string) + if !ok { + return "", errors.Wrap(errDecodeRemoveParentGroupEvent, errID) + } + + return id, nil +} + +func decodeAddChildrenGroupEvent(data map[string]interface{}) (id string, childrenIDs []string, err error) { + id, ok := data["id"].(string) + if !ok { + return "", []string{}, errors.Wrap(errDecodeAddChildrenGroupsEvent, errID) + } + chIDs, ok := data["children_ids"].([]interface{}) + if !ok { + return "", []string{}, errors.Wrap(errDecodeAddChildrenGroupsEvent, errChildrenIDs) + } + cids, err := rconsumer.ToStrings(chIDs) + if err != nil { + return "", []string{}, errors.Wrap(errDecodeAddChildrenGroupsEvent, errors.Wrap(errChildrenIDs, err)) + } + return id, cids, nil +} + +func decodeRemoveChildrenGroupEvent(data map[string]interface{}) (id string, childrenIDs []string, err error) { + id, ok := data["id"].(string) + if !ok { + return "", []string{}, errors.Wrap(errDecodeRemoveChildrenGroupsEvent, errID) + } + chIDs, ok := data["children_ids"].([]interface{}) + if !ok { + return "", []string{}, errors.Wrap(errDecodeRemoveChildrenGroupsEvent, errChildrenIDs) + } + cids, err := rconsumer.ToStrings(chIDs) + if err != nil { + return "", []string{}, errors.Wrap(errDecodeRemoveChildrenGroupsEvent, errors.Wrap(errChildrenIDs, err)) + } + return id, cids, nil +} + +func decodeRemoveAllChildrenGroupEvent(data map[string]interface{}) (id string, err error) { + id, ok := data["id"].(string) + if !ok { + return "", errors.Wrap(errDecodeRemoveChildrenGroupsEvent, errID) + } + + return id, nil +} diff --git a/pkg/groups/events/consumer/doc.go b/pkg/groups/events/consumer/doc.go new file mode 100644 index 0000000000..f3fea76f1e --- /dev/null +++ b/pkg/groups/events/consumer/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +// Package consumer contains events consumer for events +// published by Bootstrap service. +package consumer diff --git a/pkg/groups/events/consumer/streams.go b/pkg/groups/events/consumer/streams.go new file mode 100644 index 0000000000..feccb4121e --- /dev/null +++ b/pkg/groups/events/consumer/streams.go @@ -0,0 +1,253 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package consumer + +import ( + "context" + "fmt" + "log/slog" + + "github.com/absmach/supermq/groups" + "github.com/absmach/supermq/pkg/errors" + repoerr "github.com/absmach/supermq/pkg/errors/repository" + "github.com/absmach/supermq/pkg/events" + "github.com/absmach/supermq/pkg/events/store" + rconsumer "github.com/absmach/supermq/pkg/roles/rolemanager/events/consumer" +) + +const ( + stream = "events.supermq.groups" + + create = "group.create" + update = "group.update" + changeStatus = "group.change_status" + remove = "group.remove" + addParentGroup = "group.add_parent_group" + removeParentGroup = "group.remove_parent_group" + addChildrenGroups = "group.add_children_groups" + removeChildrenGroups = "group.remove_children_groups" + removeAllChildrenGroups = "group.remove_all_children_groups" + addRole = "group.role.add" + removeRole = "group.role.remove" + updateRole = "group.role.update" + addRoleActions = "group.role.actions.add" + removeRoleActions = "group.role.actions.remove" + removeAllRoleActions = "group.role.actions.remove_all" + addRoleMembers = "group.role.members.add" + removeRoleMembers = "group.role.members.remove" + removeRoleAllMembers = "group.role.members.remove_all" + removeMemberFromAllRoles = "group.role.members.remove_from_all_roles" +) + +var ( + errNoOperationKey = errors.New("operation key is not found in event message") + errCreateGroupEvent = errors.New("failed to consume group create event") + errUpdateGroupEvent = errors.New("failed to consume group update event") + errChangeStatusGroupEvent = errors.New("failed to consume group change status event") + errRemoveGroupEvent = errors.New("failed to consume group remove event") + errAddParentGroupEvent = errors.New("failed to consume group add parent group event") + errRemoveParentGroupEvent = errors.New("failed to consume group remove parent group event") + errAddChildrenGroupEvent = errors.New("failed to consume group add children groups event") + errRemoveChildrenGroupEvent = errors.New("failed to consume group remove children groups event") + errRemoveAllChildrenGroupEvent = errors.New("failed to consume group remove all children groups event") +) + +type eventHandler struct { + repo groups.Repository + rolesEventHandler rconsumer.EventHandler +} + +func GroupsEventsSubscribe(ctx context.Context, repo groups.Repository, esURL, esConsumerName string, logger *slog.Logger) error { + subscriber, err := store.NewSubscriber(ctx, esURL, logger) + if err != nil { + return err + } + + subConfig := events.SubscriberConfig{ + Stream: stream, + Consumer: esConsumerName, + Handler: NewEventHandler(repo), + Ordered: true, + } + return subscriber.Subscribe(ctx, subConfig) +} + +// NewEventHandler returns new event store handler. +func NewEventHandler(repo groups.Repository) events.EventHandler { + reh := rconsumer.NewEventHandler("group", repo) + return &eventHandler{ + repo: repo, + rolesEventHandler: reh, + } +} + +func (es *eventHandler) Handle(ctx context.Context, event events.Event) error { + msg, err := event.Encode() + if err != nil { + return err + } + + op, ok := msg["operation"] + + if !ok { + return errNoOperationKey + } + switch op { + case create: + return es.createGroupHandler(ctx, msg) + case update: + return es.updateGroupHandler(ctx, msg) + case changeStatus: + return es.changeStatusGroupHandler(ctx, msg) + case remove: + return es.removeGroupHandler(ctx, msg) + case addParentGroup: + return es.addParentGroupHandler(ctx, msg) + case removeParentGroup: + return es.removeParentGroupHandler(ctx, msg) + case addChildrenGroups: + return es.addChildrenGroupsHandler(ctx, msg) + case removeChildrenGroups: + return es.removeChildrenGroupsHandler(ctx, msg) + case removeAllChildrenGroups: + return es.removeAllChildrenGroupsHandler(ctx, msg) + case addRole: + return es.rolesEventHandler.AddEntityRoleHandler(ctx, msg) + case updateRole: + return es.rolesEventHandler.UpdateEntityRoleHandler(ctx, msg) + case removeRole: + return es.rolesEventHandler.RemoveEntityRoleHandler(ctx, msg) + case addRoleActions: + return es.rolesEventHandler.AddEntityRoleActionsHandler(ctx, msg) + case removeRoleActions: + return es.rolesEventHandler.RemoveEntityRoleActionsHandler(ctx, msg) + case removeAllRoleActions: + return es.rolesEventHandler.RemoveAllEntityRoleActionsHandler(ctx, msg) + case addRoleMembers: + return es.rolesEventHandler.AddEntityRoleMembersHandler(ctx, msg) + case removeRoleMembers: + return es.rolesEventHandler.RemoveEntityRoleMembersHandler(ctx, msg) + case removeRoleAllMembers: + return es.rolesEventHandler.RemoveAllEntityRoleMembersHandler(ctx, msg) + case removeMemberFromAllRoles: + return es.rolesEventHandler.RemoveMemberFromAllEntityHandler(ctx, msg) + } + return nil +} + +func (es *eventHandler) createGroupHandler(ctx context.Context, data map[string]interface{}) error { + g, rps, err := decodeCreateGroupEvent(data) + if err != nil { + return errors.Wrap(errCreateGroupEvent, err) + } + + if _, err := es.repo.Save(ctx, g); err != nil { + return errors.Wrap(errCreateGroupEvent, err) + } + if _, err := es.repo.AddRoles(ctx, rps); err != nil { + return errors.Wrap(errCreateGroupEvent, err) + } + + return nil +} + +func (es *eventHandler) updateGroupHandler(ctx context.Context, data map[string]interface{}) error { + g, err := decodeUpdateGroupEvent(data) + if err != nil { + return errors.Wrap(errUpdateGroupEvent, err) + } + + if _, err := es.repo.Update(ctx, g); err != nil { + return errors.Wrap(errUpdateGroupEvent, err) + } + + return nil +} + +func (es *eventHandler) changeStatusGroupHandler(ctx context.Context, data map[string]interface{}) error { + g, err := decodeChangeStatusGroupEvent(data) + if err != nil { + return errors.Wrap(errChangeStatusGroupEvent, err) + } + + if _, err := es.repo.ChangeStatus(ctx, g); err != nil { + return errors.Wrap(errChangeStatusGroupEvent, err) + } + + return nil +} + +func (es *eventHandler) removeGroupHandler(ctx context.Context, data map[string]interface{}) error { + g, err := decodeRemoveGroupEvent(data) + if err != nil { + return errors.Wrap(errRemoveGroupEvent, err) + } + + if err := es.repo.Delete(ctx, g.ID); err != nil { + return errors.Wrap(errRemoveGroupEvent, err) + } + return nil +} + +func (es *eventHandler) addParentGroupHandler(ctx context.Context, data map[string]interface{}) error { + id, parent, err := decodeAddParentGroupEvent(data) + if err != nil { + return errors.Wrap(errAddParentGroupEvent, err) + } + if err := es.repo.AssignParentGroup(ctx, parent, id); err != nil { + return errors.Wrap(errAddParentGroupEvent, err) + } + return nil +} + +func (es *eventHandler) removeParentGroupHandler(ctx context.Context, data map[string]interface{}) error { + id, err := decodeRemoveParentGroupEvent(data) + if err != nil { + return errors.Wrap(errRemoveParentGroupEvent, err) + } + g, err := es.repo.RetrieveByID(ctx, id) + if err != nil { + return errors.Wrap(errRemoveParentGroupEvent, err) + } + fmt.Println(g, g.Parent, g.ID) + if err := es.repo.UnassignParentGroup(ctx, g.Parent, id); err != nil { + return errors.Wrap(errRemoveParentGroupEvent, err) + } + return nil +} + +func (es *eventHandler) addChildrenGroupsHandler(ctx context.Context, data map[string]interface{}) error { + id, cids, err := decodeAddChildrenGroupEvent(data) + if err != nil { + return errors.Wrap(errAddChildrenGroupEvent, err) + } + + if err := es.repo.AssignParentGroup(ctx, id, cids...); err != nil { + return errors.Wrap(errAddChildrenGroupEvent, err) + } + return nil +} + +func (es *eventHandler) removeChildrenGroupsHandler(ctx context.Context, data map[string]interface{}) error { + id, cids, err := decodeRemoveChildrenGroupEvent(data) + if err != nil { + return errors.Wrap(errRemoveChildrenGroupEvent, err) + } + + if err := es.repo.UnassignParentGroup(ctx, id, cids...); err != nil { + return errors.Wrap(errRemoveChildrenGroupEvent, err) + } + return nil +} + +func (es *eventHandler) removeAllChildrenGroupsHandler(ctx context.Context, data map[string]interface{}) error { + id, err := decodeRemoveAllChildrenGroupEvent(data) + if err != nil { + return errors.Wrap(errRemoveAllChildrenGroupEvent, err) + } + if err := es.repo.UnassignAllChildrenGroups(ctx, id); err != nil && err != repoerr.ErrNotFound { + return errors.Wrap(errRemoveAllChildrenGroupEvent, err) + } + return nil +} diff --git a/pkg/groups/events/doc.go b/pkg/groups/events/doc.go new file mode 100644 index 0000000000..8f09aa3abb --- /dev/null +++ b/pkg/groups/events/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +// Package events provides the events sourcing of groups to +// provide listing in clients and channels concept definitions needed to support +package events diff --git a/pkg/messaging/nats/pubsub.go b/pkg/messaging/nats/pubsub.go index 04f90186ac..a975816bdc 100644 --- a/pkg/messaging/nats/pubsub.go +++ b/pkg/messaging/nats/pubsub.go @@ -94,7 +94,7 @@ func (ps *pubsub) Subscribe(ctx context.Context, cfg messaging.SubscriberConfig) return ErrEmptyTopic } - nh := ps.natsHandler(cfg.Handler) + nh := ps.natsHandler(cfg.Handler, cfg.AckErr) consumerConfig := jetstream.ConsumerConfig{ Name: formatConsumerName(cfg.Topic, cfg.ID), @@ -104,6 +104,10 @@ func (ps *pubsub) Subscribe(ctx context.Context, cfg messaging.SubscriberConfig) FilterSubject: cfg.Topic, } + if cfg.Ordered { + consumerConfig.MaxAckPending = 1 + } + switch cfg.DeliveryPolicy { case messaging.DeliverNewPolicy: consumerConfig.DeliverPolicy = jetstream.DeliverNewPolicy @@ -140,17 +144,22 @@ func (ps *pubsub) Unsubscribe(ctx context.Context, id, topic string) error { } } -func (ps *pubsub) natsHandler(h messaging.MessageHandler) func(m jetstream.Msg) { +func (ps *pubsub) natsHandler(h messaging.MessageHandler, ackErr bool) func(m jetstream.Msg) { return func(m jetstream.Msg) { var msg messaging.Message if err := proto.Unmarshal(m.Data(), &msg); err != nil { ps.logger.Warn(fmt.Sprintf("Failed to unmarshal received message: %s", err)) - return } if err := h.Handle(&msg); err != nil { ps.logger.Warn(fmt.Sprintf("Failed to handle SuperMQ message: %s", err)) + if ackErr { + if err := m.Ack(); err != nil { + ps.logger.Warn(fmt.Sprintf("Failed to ack message: %s", err)) + } + } + return } if err := m.Ack(); err != nil { ps.logger.Warn(fmt.Sprintf("Failed to ack message: %s", err)) diff --git a/pkg/messaging/pubsub.go b/pkg/messaging/pubsub.go index 0c954a886b..393de64fef 100644 --- a/pkg/messaging/pubsub.go +++ b/pkg/messaging/pubsub.go @@ -39,6 +39,8 @@ type SubscriberConfig struct { Topic string Handler MessageHandler DeliveryPolicy DeliveryPolicy + Ordered bool + AckErr bool } // Subscriber specifies message subscription API. diff --git a/pkg/roles/repo/postgres/init.go b/pkg/roles/repo/postgres/init.go index 905205ef65..83af3bdfd3 100644 --- a/pkg/roles/repo/postgres/init.go +++ b/pkg/roles/repo/postgres/init.go @@ -29,24 +29,24 @@ func Migration(rolesTableNamePrefix, entityTableName, entityIDColumnName string) updated_at TIMESTAMP, updated_by VARCHAR(254), created_by VARCHAR(254), - CONSTRAINT unique_role_name_entity_id_constraint UNIQUE ( name, entity_id), - CONSTRAINT fk_entity_id FOREIGN KEY(entity_id) REFERENCES %s(%s) ON DELETE CASCADE - );`, rolesTableNamePrefix, entityTableName, entityIDColumnName), + CONSTRAINT %s_roles_unique_role_name_entity_id_constraint UNIQUE ( name, entity_id), + CONSTRAINT %s_roles_fk_entity_id FOREIGN KEY(entity_id) REFERENCES %s(%s) ON DELETE CASCADE + );`, rolesTableNamePrefix, rolesTableNamePrefix, rolesTableNamePrefix, entityTableName, entityIDColumnName), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s_role_actions ( role_id VARCHAR(254) NOT NULL, action VARCHAR(254) NOT NULL, - CONSTRAINT unique_domain_role_action_constraint UNIQUE ( role_id, action), - CONSTRAINT fk_%s_roles_id FOREIGN KEY(role_id) REFERENCES %s_roles(id) ON DELETE CASCADE + CONSTRAINT %s_role_actions_unique_domain_role_action_constraint UNIQUE ( role_id, action), + CONSTRAINT %s_role_actions_fk_roles_id FOREIGN KEY(role_id) REFERENCES %s_roles(id) ON DELETE CASCADE - );`, rolesTableNamePrefix, rolesTableNamePrefix, rolesTableNamePrefix), + );`, rolesTableNamePrefix, rolesTableNamePrefix, rolesTableNamePrefix, rolesTableNamePrefix), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s_role_members ( role_id VARCHAR(254) NOT NULL, member_id VARCHAR(254) NOT NULL, - CONSTRAINT unique_role_member_constraint UNIQUE (role_id, member_id), - CONSTRAINT fk_%s_roles_id FOREIGN KEY(role_id) REFERENCES %s_roles(id) ON DELETE CASCADE - );`, rolesTableNamePrefix, rolesTableNamePrefix, rolesTableNamePrefix), + CONSTRAINT %s_role_members_unique_role_member_constraint UNIQUE (role_id, member_id), + CONSTRAINT %s_role_members_fk_roles_id FOREIGN KEY(role_id) REFERENCES %s_roles(id) ON DELETE CASCADE + );`, rolesTableNamePrefix, rolesTableNamePrefix, rolesTableNamePrefix, rolesTableNamePrefix), }, Down: []string{ fmt.Sprintf(`DROP TABLE IF EXISTS %s_roles`, rolesTableNamePrefix), diff --git a/pkg/roles/repo/postgres/roles.go b/pkg/roles/repo/postgres/roles.go index 64b8b5e2f0..bef5902efd 100644 --- a/pkg/roles/repo/postgres/roles.go +++ b/pkg/roles/repo/postgres/roles.go @@ -406,7 +406,7 @@ func (repo *Repository) RoleAddActions(ctx context.Context, role roles.Role, act return []string{}, postgres.HandleError(repoerr.ErrCreateEntity, err) } - return repo.RoleListActions(ctx, role.ID) + return actions, nil } func (repo *Repository) RoleListActions(ctx context.Context, roleID string) ([]string, error) { diff --git a/pkg/roles/rolemanager/events/consumer/decode.go b/pkg/roles/rolemanager/events/consumer/decode.go new file mode 100644 index 0000000000..44d2432f6d --- /dev/null +++ b/pkg/roles/rolemanager/events/consumer/decode.go @@ -0,0 +1,145 @@ +// Copyright (c) Abstract Machines +// SPDX-License-Identifier: Apache-2.0 + +package consumer + +import ( + "time" + + "github.com/absmach/supermq/pkg/errors" + "github.com/absmach/supermq/pkg/roles" +) + +var ( + errID = errors.New("missing or invalid 'id'") + errRoleID = errors.New("missing or invalid 'role_id'") + errName = errors.New("missing or invalid 'name'") + errEntityID = errors.New("missing or invalid 'entity_id'") + errActions = errors.New("missing or invalid 'actions'") + errMembers = errors.New("missing or invalid 'members'") + errCreatedAt = errors.New("failed to parse 'created_at' time") + errUpdatedAt = errors.New("failed to parse 'updated_at' time") + errNotString = errors.New("not string type") + + errInvalidRoleProvision = errors.New("invalid 'role_provisions'") + errRoleProvision = errors.New("failed to convert role_provisions interface'") + errRoleProvisionMembers = errors.New("failed to convert role_provisions member interface'") + errRoleProvisionActions = errors.New("failed to convert role_provisions action interface'") +) + +const ( + layout = "2006-01-02T15:04:05.999999Z" +) + +func ToRole(data map[string]interface{}) (roles.Role, error) { + var r roles.Role + + id, ok := data["id"].(string) + if !ok { + return roles.Role{}, errID + } + r.ID = id + + name, ok := data["name"].(string) + if !ok { + return roles.Role{}, errName + } + r.Name = name + + eid, ok := data["entity_id"].(string) + if !ok { + return roles.Role{}, errEntityID + } + r.EntityID = eid + + // Following fields of groups are allowed to be empty. + + cat, ok := data["created_at"].(string) + if ok { + ct, err := time.Parse(layout, cat) + if err != nil { + return roles.Role{}, errors.Wrap(errCreatedAt, err) + } + r.CreatedAt = ct + } + + cby, ok := data["created_by"].(string) + if ok { + r.CreatedBy = cby + } + + uat, ok := data["updated_at"].(string) + if ok { + ut, err := time.Parse(layout, uat) + if err != nil { + return roles.Role{}, errors.Wrap(errUpdatedAt, err) + } + r.UpdatedAt = ut + } + + uby, ok := data["updated_by"].(string) + if ok { + r.UpdatedBy = uby + } + + return r, nil +} +func ToStrings(data []interface{}) ([]string, error) { + var strs []string + for _, i := range data { + str, ok := i.(string) + if !ok { + return []string{}, errNotString + } + strs = append(strs, str) + } + return strs, nil +} + +func ToRoleProvision(data map[string]interface{}) (roles.RoleProvision, error) { + var rp roles.RoleProvision + + r, err := ToRole(data) + if err != nil { + return roles.RoleProvision{}, err + } + rp.Role = r + + // Following fields of groups are allowed to be empty. + + opActs, ok := data["optional_actions"].([]interface{}) + if ok { + a, err := ToStrings(opActs) + if err != nil { + return roles.RoleProvision{}, errors.Wrap(errRoleProvisionActions, err) + } + rp.OptionalActions = a + } + + opMems, ok := data["optional_members"].([]interface{}) + if ok { + m, err := ToStrings(opMems) + if err != nil { + return roles.RoleProvision{}, errors.Wrap(errRoleProvisionMembers, err) + } + rp.OptionalMembers = m + } + + return rp, nil +} + +func ToRoleProvisions(data []interface{}) ([]roles.RoleProvision, error) { + var rps []roles.RoleProvision + for _, d := range data { + irp, ok := d.(map[string]interface{}) + if !ok { + return []roles.RoleProvision{}, errInvalidRoleProvision + } + rp, err := ToRoleProvision(irp) + if err != nil { + return []roles.RoleProvision{}, errors.Wrap(errRoleProvision, err) + } + rps = append(rps, rp) + } + return rps, nil +} diff --git a/pkg/roles/rolemanager/events/consumer/handler.go b/pkg/roles/rolemanager/events/consumer/handler.go new file mode 100644 index 0000000000..99b1b2fe8c --- /dev/null +++ b/pkg/roles/rolemanager/events/consumer/handler.go @@ -0,0 +1,186 @@ +package consumer + +import ( + "context" + "fmt" + + "github.com/absmach/supermq/pkg/errors" + repoerr "github.com/absmach/supermq/pkg/errors/repository" + "github.com/absmach/supermq/pkg/roles" +) + +const ( + errAddEntityRoleEvent = "failed to consume %s add role event : %w" + errUpdateEntityRoleEvent = "failed to consume %s update role event : %w" + errRemoveEntityRoleEvent = "failed to consume %s remove role event : %w" + errAddEntityRoleActionsEvent = "failed to consume %s add role actions event : %w" + errRemoveEntityRoleActionsEvent = "failed to consume %s remove role actions event : %w" + errRemoveEntityRoleAllActionsEvent = "failed to consume %s remove role all actions event : %w" + errAddEntityRoleMembersEvent = "failed to consume %s add role members event : %w" + errRemoveEntityRoleMembersEvent = "failed to consume %s remove role members event : %w" + errRemoveEntityRoleAllMembersEvent = "failed to consume %s remove role all members event : %w" +) + +type EventHandler struct { + entityType string + repo roles.Repository +} + +func NewEventHandler(entityType string, repo roles.Repository) EventHandler { + return EventHandler{ + entityType: entityType, + repo: repo, + } +} +func (es *EventHandler) AddEntityRoleHandler(ctx context.Context, data map[string]interface{}) error { + rps, err := ToRoleProvision(data) + if err != nil { + return fmt.Errorf(errAddEntityRoleEvent, es.entityType, err) + } + if _, err := es.repo.AddRoles(ctx, []roles.RoleProvision{rps}); err != nil { + if !errors.Contains(err, repoerr.ErrConflict) { + return fmt.Errorf(errAddEntityRoleEvent, es.entityType, err) + } + } + + return nil +} + +func (es *EventHandler) UpdateEntityRoleHandler(ctx context.Context, data map[string]interface{}) error { + + ro, err := ToRole(data) + if err != nil { + return fmt.Errorf(errUpdateEntityRoleEvent, es.entityType, err) + } + + if _, err = es.repo.UpdateRole(ctx, ro); err != nil { + return fmt.Errorf(errUpdateEntityRoleEvent, es.entityType, err) + } + + return nil +} + +func (es *EventHandler) RemoveEntityRoleHandler(ctx context.Context, data map[string]interface{}) error { + + id, ok := data["role_id"].(string) + if !ok { + return fmt.Errorf(errRemoveEntityRoleEvent, es.entityType, errRoleID) + } + + if err := es.repo.RemoveRoles(ctx, []string{id}); err != nil { + return fmt.Errorf(errRemoveEntityRoleEvent, es.entityType, err) + } + + return nil +} + +func (es *EventHandler) AddEntityRoleActionsHandler(ctx context.Context, data map[string]interface{}) error { + id, ok := data["role_id"].(string) + if !ok { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, errRoleID) + } + iacts, ok := data["actions"].([]interface{}) + if !ok { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, errActions) + } + acts, err := ToStrings(iacts) + if err != nil { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, err) + } + + if _, err := es.repo.RoleAddActions(ctx, roles.Role{ID: id}, acts); err != nil { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, err) + } + + return nil +} + +func (es *EventHandler) RemoveEntityRoleActionsHandler(ctx context.Context, data map[string]interface{}) error { + id, ok := data["role_id"].(string) + if !ok { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, errRoleID) + } + iacts, ok := data["actions"].([]interface{}) + if !ok { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, errActions) + } + acts, err := ToStrings(iacts) + if err != nil { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, err) + } + + if err := es.repo.RoleRemoveActions(ctx, roles.Role{ID: id}, acts); err != nil { + return fmt.Errorf(errAddEntityRoleActionsEvent, es.entityType, err) + } + return nil +} + +func (es *EventHandler) RemoveAllEntityRoleActionsHandler(ctx context.Context, data map[string]interface{}) error { + id, ok := data["role_id"].(string) + if !ok { + return fmt.Errorf(errRemoveEntityRoleAllActionsEvent, es.entityType, errRoleID) + } + + if err := es.repo.RoleRemoveAllActions(ctx, roles.Role{ID: id}); err != nil { + return fmt.Errorf(errRemoveEntityRoleAllActionsEvent, es.entityType, err) + } + return nil +} + +func (es *EventHandler) AddEntityRoleMembersHandler(ctx context.Context, data map[string]interface{}) error { + id, ok := data["role_id"].(string) + if !ok { + return fmt.Errorf(errAddEntityRoleMembersEvent, es.entityType, errRoleID) + } + imems, ok := data["members"].([]interface{}) + if !ok { + return fmt.Errorf(errAddEntityRoleMembersEvent, es.entityType, errMembers) + } + mems, err := ToStrings(imems) + if err != nil { + return fmt.Errorf(errAddEntityRoleMembersEvent, es.entityType, err) + } + + if _, err := es.repo.RoleAddMembers(ctx, roles.Role{ID: id}, mems); err != nil { + return fmt.Errorf(errAddEntityRoleMembersEvent, es.entityType, err) + } + + return nil +} + +func (es *EventHandler) RemoveEntityRoleMembersHandler(ctx context.Context, data map[string]interface{}) error { + id, ok := data["role_id"].(string) + if !ok { + return fmt.Errorf(errRemoveEntityRoleMembersEvent, es.entityType, errRoleID) + } + imems, ok := data["members"].([]interface{}) + if !ok { + return fmt.Errorf(errRemoveEntityRoleMembersEvent, es.entityType, errMembers) + } + mems, err := ToStrings(imems) + if err != nil { + return fmt.Errorf(errRemoveEntityRoleMembersEvent, es.entityType, err) + } + + if err := es.repo.RoleRemoveMembers(ctx, roles.Role{ID: id}, mems); err != nil { + return fmt.Errorf(errRemoveEntityRoleMembersEvent, es.entityType, err) + } + + return nil +} + +func (es *EventHandler) RemoveAllEntityRoleMembersHandler(ctx context.Context, data map[string]interface{}) error { + id, ok := data["role_id"].(string) + if !ok { + return fmt.Errorf(errRemoveEntityRoleAllMembersEvent, es.entityType, errRoleID) + } + + if err := es.repo.RoleRemoveAllMembers(ctx, roles.Role{ID: id}); err != nil { + return fmt.Errorf(errRemoveEntityRoleAllMembersEvent, es.entityType, err) + } + return nil +} + +func (es *EventHandler) RemoveMemberFromAllEntityHandler(ctx context.Context, data map[string]interface{}) error { + return nil +} diff --git a/pkg/roles/rolemanager/events/streams.go b/pkg/roles/rolemanager/events/streams.go index 613799b76c..94c06a84e6 100644 --- a/pkg/roles/rolemanager/events/streams.go +++ b/pkg/roles/rolemanager/events/streams.go @@ -24,9 +24,10 @@ type RoleManagerEventStore struct { // events to event store. func NewRoleManagerEventStore(svcName, operationPrefix string, svc roles.RoleManager, publisher events.Publisher) RoleManagerEventStore { return RoleManagerEventStore{ - svcName: svcName, - svc: svc, - Publisher: publisher, + svcName: svcName, + operationPrefix: operationPrefix, + svc: svc, + Publisher: publisher, } } diff --git a/pkg/sdk/clients_test.go b/pkg/sdk/clients_test.go index c8bdbafcab..f753aa3864 100644 --- a/pkg/sdk/clients_test.go +++ b/pkg/sdk/clients_test.go @@ -357,9 +357,8 @@ func TestListClients(t *testing.T) { Limit: 100, }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, + Offset: 0, + Limit: 100, }, svcRes: clients.ClientsPage{ Page: clients.Page{ @@ -387,9 +386,8 @@ func TestListClients(t *testing.T) { Limit: 100, }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, + Offset: 0, + Limit: 100, }, svcRes: clients.ClientsPage{}, authenticateErr: svcerr.ErrAuthentication, @@ -435,10 +433,9 @@ func TestListClients(t *testing.T) { Status: clients.DisabledStatus.String(), }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, - Status: clients.DisabledStatus, + Offset: 0, + Limit: 100, + Status: clients.DisabledStatus, }, svcRes: clients.ClientsPage{ Page: clients.Page{ @@ -468,10 +465,9 @@ func TestListClients(t *testing.T) { Tag: "tag1", }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, - Tag: "tag1", + Offset: 0, + Limit: 100, + Tag: "tag1", }, svcRes: clients.ClientsPage{ Page: clients.Page{ @@ -517,9 +513,8 @@ func TestListClients(t *testing.T) { Limit: 100, }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, + Offset: 0, + Limit: 100, }, svcRes: clients.ClientsPage{ Page: clients.Page{ @@ -1443,9 +1438,8 @@ func TestListUserClients(t *testing.T) { Limit: 100, }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, + Offset: 0, + Limit: 100, }, svcRes: clients.ClientsPage{ Page: clients.Page{ @@ -1474,9 +1468,8 @@ func TestListUserClients(t *testing.T) { Limit: 100, }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, + Offset: 0, + Limit: 100, }, svcRes: clients.ClientsPage{}, authenticateErr: svcerr.ErrAuthentication, @@ -1525,10 +1518,9 @@ func TestListUserClients(t *testing.T) { Status: clients.DisabledStatus.String(), }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, - Status: clients.DisabledStatus, + Offset: 0, + Limit: 100, + Status: clients.DisabledStatus, }, svcRes: clients.ClientsPage{ Page: clients.Page{ @@ -1559,10 +1551,9 @@ func TestListUserClients(t *testing.T) { Tag: "tag1", }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, - Tag: "tag1", + Offset: 0, + Limit: 100, + Tag: "tag1", }, svcRes: clients.ClientsPage{ Page: clients.Page{ @@ -1609,9 +1600,8 @@ func TestListUserClients(t *testing.T) { Limit: 100, }, svcReq: clients.Page{ - Offset: 0, - Limit: 100, - Permission: defPermission, + Offset: 0, + Limit: 100, }, svcRes: clients.ClientsPage{ Page: clients.Page{ diff --git a/provision/config_test.go b/provision/config_test.go index 2140f71acc..20d4807198 100644 --- a/provision/config_test.go +++ b/provision/config_test.go @@ -39,7 +39,6 @@ var ( Metadata: map[string]interface{}{ "test": "test", }, - Permissions: []string{"test"}, }, }, Channels: []channels.Channel{