From 57fd00d2388e6a4d4c4815c56d36c75b991fb6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Fri, 9 Dec 2022 21:39:33 +0000 Subject: [PATCH] handle /education/user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- services/graph/pkg/identity/backend.go | 11 +- .../graph/pkg/identity/ldap_education_user.go | 33 ++ services/graph/pkg/identity/ldap_school.go | 2 +- .../pkg/identity/mocks/education_backend.go | 114 +++- .../graph/pkg/service/v0/educationuser.go | 481 +++++++++++++++++ .../pkg/service/v0/educationuser_test.go | 508 ++++++++++++++++++ services/graph/pkg/service/v0/groups.go | 3 +- services/graph/pkg/service/v0/instrument.go | 25 + services/graph/pkg/service/v0/logging.go | 25 + services/graph/pkg/service/v0/ordering.go | 38 ++ services/graph/pkg/service/v0/schools.go | 20 +- services/graph/pkg/service/v0/schools_test.go | 23 +- services/graph/pkg/service/v0/service.go | 19 +- services/graph/pkg/service/v0/tracing.go | 25 + services/graph/pkg/service/v0/users.go | 4 +- 15 files changed, 1297 insertions(+), 34 deletions(-) create mode 100644 services/graph/pkg/identity/ldap_education_user.go create mode 100644 services/graph/pkg/service/v0/educationuser.go create mode 100644 services/graph/pkg/service/v0/educationuser_test.go diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go index 4d22ed27b..4675df7f2 100644 --- a/services/graph/pkg/identity/backend.go +++ b/services/graph/pkg/identity/backend.go @@ -42,11 +42,20 @@ type EducationBackend interface { GetSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) // GetSchools lists all schools GetSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error) - GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.User, error) + GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) // AddMembersToSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend. AddMembersToSchool(ctx context.Context, schoolID string, memberID []string) error // RemoveMemberFromSchool removes a single member (by ID) from a school RemoveMemberFromSchool(ctx context.Context, schoolID string, memberID string) error + + // CreateEducationUser creates a given education user in the identity backend. + CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) + // DeleteEducationUser deletes a given educationuser, identified by username or id, from the backend + DeleteEducationUser(ctx context.Context, nameOrID string) error + // UpdateEducationUser applies changes to given education user, identified by username or id + UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) + GetEducationUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationUser, error) + GetEducationUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationUser, error) } func CreateUserModelFromCS3(u *cs3.User) *libregraph.User { diff --git a/services/graph/pkg/identity/ldap_education_user.go b/services/graph/pkg/identity/ldap_education_user.go new file mode 100644 index 000000000..391584b25 --- /dev/null +++ b/services/graph/pkg/identity/ldap_education_user.go @@ -0,0 +1,33 @@ +package identity + +import ( + "context" + "net/url" + + libregraph "github.com/owncloud/libre-graph-api-go" +) + +// CreateEducationUser creates a given education user in the identity backend. +func (i *LDAP) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) { + return nil, errNotImplemented +} + +// DeleteEducationUser deletes a given educationuser, identified by username or id, from the backend +func (i *LDAP) DeleteEducationUser(ctx context.Context, nameOrID string) error { + return errNotImplemented +} + +// UpdateEducationUser applies changes to given education user, identified by username or id +func (i *LDAP) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) { + return nil, errNotImplemented +} + +// GetEducationUser implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetEducationUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationUser, error) { + return nil, errNotImplemented +} + +// GetEducationUsers implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetEducationUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationUser, error) { + return nil, errNotImplemented +} diff --git a/services/graph/pkg/identity/ldap_school.go b/services/graph/pkg/identity/ldap_school.go index d8d8df546..d033eef54 100644 --- a/services/graph/pkg/identity/ldap_school.go +++ b/services/graph/pkg/identity/ldap_school.go @@ -177,7 +177,7 @@ func (i *LDAP) GetSchools(ctx context.Context, queryParam url.Values) ([]*libreg } // GetSchoolMembers implements the EducationBackend interface for the LDAP backend. -func (i *LDAP) GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.User, error) { +func (i *LDAP) GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) { return nil, errNotImplemented } diff --git a/services/graph/pkg/identity/mocks/education_backend.go b/services/graph/pkg/identity/mocks/education_backend.go index 2d4ff4bf6..d72b5a9c2 100644 --- a/services/graph/pkg/identity/mocks/education_backend.go +++ b/services/graph/pkg/identity/mocks/education_backend.go @@ -31,6 +31,29 @@ func (_m *EducationBackend) AddMembersToSchool(ctx context.Context, schoolID str return r0 } +// CreateEducationUser provides a mock function with given fields: ctx, user +func (_m *EducationBackend) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) { + ret := _m.Called(ctx, user) + + var r0 *libregraph.EducationUser + if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationUser) *libregraph.EducationUser); ok { + r0 = rf(ctx, user) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*libregraph.EducationUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationUser) error); ok { + r1 = rf(ctx, user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateSchool provides a mock function with given fields: ctx, group func (_m *EducationBackend) CreateSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error) { ret := _m.Called(ctx, group) @@ -54,6 +77,20 @@ func (_m *EducationBackend) CreateSchool(ctx context.Context, group libregraph.E return r0, r1 } +// DeleteEducationUser provides a mock function with given fields: ctx, nameOrID +func (_m *EducationBackend) DeleteEducationUser(ctx context.Context, nameOrID string) error { + ret := _m.Called(ctx, nameOrID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, nameOrID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteSchool provides a mock function with given fields: ctx, id func (_m *EducationBackend) DeleteSchool(ctx context.Context, id string) error { ret := _m.Called(ctx, id) @@ -68,6 +105,52 @@ func (_m *EducationBackend) DeleteSchool(ctx context.Context, id string) error { return r0 } +// GetEducationUser provides a mock function with given fields: ctx, nameOrID, queryParam +func (_m *EducationBackend) GetEducationUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationUser, error) { + ret := _m.Called(ctx, nameOrID, queryParam) + + var r0 *libregraph.EducationUser + if rf, ok := ret.Get(0).(func(context.Context, string, url.Values) *libregraph.EducationUser); ok { + r0 = rf(ctx, nameOrID, queryParam) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*libregraph.EducationUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, url.Values) error); ok { + r1 = rf(ctx, nameOrID, queryParam) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetEducationUsers provides a mock function with given fields: ctx, queryParam +func (_m *EducationBackend) GetEducationUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationUser, error) { + ret := _m.Called(ctx, queryParam) + + var r0 []*libregraph.EducationUser + if rf, ok := ret.Get(0).(func(context.Context, url.Values) []*libregraph.EducationUser); ok { + r0 = rf(ctx, queryParam) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*libregraph.EducationUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, url.Values) error); ok { + r1 = rf(ctx, queryParam) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetSchool provides a mock function with given fields: ctx, nameOrID, queryParam func (_m *EducationBackend) GetSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) { ret := _m.Called(ctx, nameOrID, queryParam) @@ -92,15 +175,15 @@ func (_m *EducationBackend) GetSchool(ctx context.Context, nameOrID string, quer } // GetSchoolMembers provides a mock function with given fields: ctx, id -func (_m *EducationBackend) GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.User, error) { +func (_m *EducationBackend) GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) { ret := _m.Called(ctx, id) - var r0 []*libregraph.User - if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.User); ok { + var r0 []*libregraph.EducationUser + if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*libregraph.User) + r0 = ret.Get(0).([]*libregraph.EducationUser) } } @@ -151,6 +234,29 @@ func (_m *EducationBackend) RemoveMemberFromSchool(ctx context.Context, schoolID return r0 } +// UpdateEducationUser provides a mock function with given fields: ctx, nameOrID, user +func (_m *EducationBackend) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) { + ret := _m.Called(ctx, nameOrID, user) + + var r0 *libregraph.EducationUser + if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationUser) *libregraph.EducationUser); ok { + r0 = rf(ctx, nameOrID, user) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*libregraph.EducationUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, libregraph.EducationUser) error); ok { + r1 = rf(ctx, nameOrID, user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + type mockConstructorTestingTNewEducationBackend interface { mock.TestingT Cleanup(func()) diff --git a/services/graph/pkg/service/v0/educationuser.go b/services/graph/pkg/service/v0/educationuser.go new file mode 100644 index 000000000..7757b0e0f --- /dev/null +++ b/services/graph/pkg/service/v0/educationuser.go @@ -0,0 +1,481 @@ +package svc + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/CiscoM31/godata" + cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + revactx "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/cs3org/reva/v2/pkg/events" + "github.com/cs3org/reva/v2/pkg/rgrpc/status" + "github.com/cs3org/reva/v2/pkg/utils" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + libregraph "github.com/owncloud/libre-graph-api-go" + settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" + "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode" + settingssvc "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0" + "golang.org/x/exp/slices" +) + +// GetEducationUsers implements the Service interface. +func (g Graph) GetEducationUsers(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Interface("query", r.URL.Query()).Msg("calling get education users") + sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/") + odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users: query error") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + + logger.Debug().Interface("query", r.URL.Query()).Msg("calling get education users on backend") + users, err := g.identityEducationBackend.GetEducationUsers(r.Context(), r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users from backend") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + users, err = sortEducationUsers(odataReq, users) + if err != nil { + logger.Debug().Interface("query", odataReq).Msg("error while sorting education users according to query") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + render.Status(r, http.StatusOK) + render.JSON(w, r, &ListResponse{Value: users}) +} + +// PostEducationUser implements the Service interface. +func (g Graph) PostEducationUser(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Interface("body", r.Body).Msg("calling create education user") + u := libregraph.NewEducationUser() + err := json.NewDecoder(r.Body).Decode(u) + if err != nil { + logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education user: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error())) + return + } + + // Disallow user-supplied IDs. It's supposed to be readonly. We're either + // generating them in the backend ourselves or rely on the Backend's + // storage (e.g. LDAP) to provide a unique ID. + if _, ok := u.GetIdOk(); ok { + logger.Debug().Interface("user", u).Msg("could not create education user: id is a read-only attribute") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "education user id is a read-only attribute") + return + } + + if _, ok := u.GetDisplayNameOk(); !ok { + logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Attribute: 'displayName'") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'displayName'") + return + } + + identities, ok := u.GetIdentitiesOk() + if !ok { + logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Collection: 'identities'") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'identities'") + return + } + if len(identities) < 1 { + logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing entry in Collection: 'identities'") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Collection: 'identities'") + return + } + for i, identity := range identities { + if _, ok := identity.GetIssuerOk(); !ok { + logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuer'", i) + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuer'", i)) + return + } + if _, ok := identity.GetIssuerAssignedIdOk(); !ok { + logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i) + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i)) + return + } + } + + if accountName, ok := u.GetOnPremisesSamAccountNameOk(); ok { + if !isValidUsername(*accountName) { + logger.Debug().Str("username", *accountName).Msg("could not create education user: username must be at least the local part of an email") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("username %s must be at least the local part of an email", *u.OnPremisesSamAccountName)) + return + } + } else { + logger.Debug().Interface("user", u).Msg("could not create education user: missing required Attribute: 'onPremisesSamAccountName'") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'onPremisesSamAccountName'") + return + } + + if mail, ok := u.GetMailOk(); ok { + if !isValidEmail(*mail) { + logger.Debug().Str("mail", *u.Mail).Msg("could not create education user: invalid email address") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("%v is not a valid email address", *u.Mail)) + return + } + } else { + logger.Debug().Interface("user", u).Msg("could not create education user: missing required Attribute: 'mail'") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'mail'") + return + } + + if _, ok := u.GetPrimaryRoleOk(); !ok { + logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Attribute: 'primaryRole'") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'primaryRole'") + return + } + + logger.Debug().Interface("user", u).Msg("calling create education user on backend") + if u, err = g.identityEducationBackend.CreateEducationUser(r.Context(), *u); err != nil { + logger.Debug().Err(err).Msg("could not create education user: backend error") + var ecErr errorcode.Error + if errors.As(err, &ecErr) { + ecErr.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + // assign roles if possible + if g.roleService != nil { + // All users get the user role by default currently. + // to all new users for now, as create Account request does not have any role field + if _, err = g.roleService.AssignRoleToUser(r.Context(), &settings.AssignRoleToUserRequest{ + AccountUuid: *u.Id, + RoleId: settingssvc.BundleUUIDRoleUser, + }); err != nil { + // log as error, admin eventually needs to do something + logger.Error().Err(err).Str("id", *u.Id).Str("role", settingssvc.BundleUUIDRoleUser).Msg("could not create education user: role assignment failed") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "role assignment failed") + return + } + } + + e := events.UserCreated{UserID: *u.Id} + if currentUser, ok := revactx.ContextGetUser(r.Context()); ok { + e.Executant = currentUser.GetId() + } + g.publishEvent(e) + + render.Status(r, http.StatusOK) + render.JSON(w, r, u) +} + +// GetEducationUser implements the Service interface. +func (g Graph) GetEducationUser(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling get education user") + userID := chi.URLParam(r, "userID") + userID, err := url.PathUnescape(userID) + if err != nil { + logger.Debug().Err(err).Str("id", userID).Msg("could not get education user: unescaping education user id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed") + return + } + + if userID == "" { + logger.Debug().Msg("could not get user: missing education user id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id") + return + } + + logger.Debug().Str("id", userID).Msg("calling get education user from backend") + user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID, r.URL.Query()) + + if err != nil { + logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + sel := strings.Split(r.URL.Query().Get("$select"), ",") + exp := strings.Split(r.URL.Query().Get("$expand"), ",") + if slices.Contains(sel, "drive") || slices.Contains(sel, "drives") || slices.Contains(exp, "drive") || slices.Contains(exp, "drives") { + wdu, err := g.getWebDavBaseURL() + if err != nil { + // log error, wrong configuration + logger.Error(). + Err(err). + Str("webdav_base", g.config.Spaces.WebDavBase). + Str("webdav_path", g.config.Spaces.WebDavPath). + Msg("error parsing webdav URL") + render.Status(r, http.StatusInternalServerError) + return + } + logger.Debug().Str("id", user.GetId()).Msg("calling list storage spaces with education user id filter") + f := listStorageSpacesUserFilter(user.GetId()) + // use the unrestricted flag to get all possible spaces + // users with the canListAllSpaces permission should see all spaces + opaque := utils.AppendPlainToOpaque(nil, "unrestricted", "T") + lspr, err := g.gatewayClient.ListStorageSpaces(r.Context(), &storageprovider.ListStorageSpacesRequest{ + Opaque: opaque, + Filters: []*storageprovider.ListStorageSpacesRequest_Filter{f}, + }) + if err != nil { + // transport error, needs to be fixed by admin + logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("error getting storages: transport error") + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, user) + return + } + if lspr.GetStatus().GetCode() != cs3rpc.Code_CODE_OK { + logger.Debug().Str("grpc", lspr.GetStatus().GetMessage()).Msg("could not get drive for education user") + // in case of NOT_OK, we can just return the user object with empty drives + render.Status(r, status.HTTPStatusFromCode(http.StatusOK)) + render.JSON(w, r, user) + return + } + drives := []libregraph.Drive{} + for _, sp := range lspr.GetStorageSpaces() { + d, err := g.cs3StorageSpaceToDrive(r.Context(), wdu, sp) + if err != nil { + logger.Debug().Err(err).Interface("id", sp.Id).Msg("error converting space to drive") + continue + } + quota, err := g.getDriveQuota(r.Context(), sp) + if err != nil { + logger.Debug().Err(err).Interface("id", sp.Id).Msg("error calling get quota on drive") + } + d.Quota = quota + if slices.Contains(sel, "drive") || slices.Contains(exp, "drive") { + if *d.DriveType == "personal" { + user.Drive = d + } + } else { + drives = append(drives, *d) + user.Drives = drives + } + } + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, user) +} + +// DeleteEducationUser implements the Service interface. +func (g Graph) DeleteEducationUser(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling delete education user") + userID := chi.URLParam(r, "userID") + userID, err := url.PathUnescape(userID) + if err != nil { + logger.Debug().Err(err).Str("id", userID).Msg("could not delete education user: unescaping education user id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed") + return + } + + if userID == "" { + logger.Debug().Msg("could not delete education user: missing education user id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id") + return + } + logger.Debug().Str("id", userID).Msg("calling get education user on user backend") + user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID, r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Str("userID", userID).Msg("failed to get education user from backend") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + e := events.UserDeleted{UserID: user.GetId()} + if currentUser, ok := revactx.ContextGetUser(r.Context()); ok { + if currentUser.GetId().GetOpaqueId() == user.GetId() { + logger.Debug().Msg("could not delete education user: self deletion forbidden") + errorcode.NotAllowed.Render(w, r, http.StatusForbidden, "self deletion forbidden") + return + } + e.Executant = currentUser.GetId() + } + + if g.gatewayClient != nil { + logger.Debug(). + Str("user", user.GetId()). + Msg("calling list spaces with user filter to fetch the personal space for deletion") + opaque := utils.AppendPlainToOpaque(nil, "unrestricted", "T") + f := listStorageSpacesUserFilter(user.GetId()) + lspr, err := g.gatewayClient.ListStorageSpaces(r.Context(), &storageprovider.ListStorageSpacesRequest{ + Opaque: opaque, + Filters: []*storageprovider.ListStorageSpacesRequest_Filter{f}, + }) + if err != nil { + // transport error, log as error + logger.Error().Err(err).Msg("could not fetch spaces: transport error") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not fetch spaces for deletion, aborting") + return + } + for _, sp := range lspr.GetStorageSpaces() { + if !(sp.SpaceType == "personal" && sp.Owner.Id.OpaqueId == user.GetId()) { + continue + } + // TODO: check if request contains a homespace and if, check if requesting user has the privilege to + // delete it and make sure it is not deleting its own homespace + // needs modification of the cs3api + + // Deleting a space a two step process (1. disabling/trashing, 2. purging) + // Do the "disable/trash" step only if the space is not marked as trashed yet: + if _, ok := sp.Opaque.Map["trashed"]; !ok { + _, err := g.gatewayClient.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{ + Id: &storageprovider.StorageSpaceId{ + OpaqueId: sp.Id.OpaqueId, + }, + }) + if err != nil { + logger.Error().Err(err).Msg("could not disable homespace: transport error") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not disable homespace, aborting") + return + } + } + purgeFlag := utils.AppendPlainToOpaque(nil, "purge", "") + _, err := g.gatewayClient.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{ + Opaque: purgeFlag, + Id: &storageprovider.StorageSpaceId{ + OpaqueId: sp.Id.OpaqueId, + }, + }) + if err != nil { + // transport error, log as error + logger.Error().Err(err).Msg("could not delete homespace: transport error") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not delete homespace, aborting") + return + } + break + } + } + + logger.Debug().Str("id", user.GetId()).Msg("calling delete education user on backend") + err = g.identityEducationBackend.DeleteEducationUser(r.Context(), user.GetId()) + + if err != nil { + logger.Debug().Err(err).Msg("could not delete education user: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + } + + g.publishEvent(e) + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// PatchEducationUser implements the Service Interface. Updates the specified attributes of an +// ExistingUser +func (g Graph) PatchEducationUser(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling patch education user") + nameOrID := chi.URLParam(r, "userID") + nameOrID, err := url.PathUnescape(nameOrID) + if err != nil { + logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: unescaping education user id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed") + return + } + + if nameOrID == "" { + logger.Debug().Msg("could not update education user: missing education user id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id") + return + } + changes := libregraph.NewEducationUser() + err = json.NewDecoder(r.Body).Decode(changes) + if err != nil { + logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update education user: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, + fmt.Sprintf("invalid request body: %s", err.Error())) + return + } + + var features []events.UserFeature + if mail, ok := changes.GetMailOk(); ok { + if !isValidEmail(*mail) { + logger.Debug().Str("mail", *mail).Msg("could not update education user: email is not a valid email address") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, + fmt.Sprintf("'%s' is not a valid email address", *mail)) + return + } + features = append(features, events.UserFeature{Name: "email", Value: *mail}) + } + + if name, ok := changes.GetDisplayNameOk(); ok { + features = append(features, events.UserFeature{Name: "displayname", Value: *name}) + } + + logger.Debug().Str("nameid", nameOrID).Interface("changes", *changes).Msg("calling update education user on backend") + u, err := g.identityEducationBackend.UpdateEducationUser(r.Context(), nameOrID, *changes) + if err != nil { + logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + e := events.UserFeatureChanged{ + UserID: nameOrID, + Features: features, + } + if currentUser, ok := revactx.ContextGetUser(r.Context()); ok { + e.Executant = currentUser.GetId() + } + g.publishEvent(e) + + render.Status(r, http.StatusOK) // TODO StatusNoContent when prefer=minimal is used + render.JSON(w, r, u) + +} + +func sortEducationUsers(req *godata.GoDataRequest, users []*libregraph.EducationUser) ([]*libregraph.EducationUser, error) { + var sorter sort.Interface + if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 { + return users, nil + } + switch req.Query.OrderBy.OrderByItems[0].Field.Value { + case displayNameAttr: + sorter = educationUsersByDisplayName{users} + case "mail": + sorter = educationUsersByMail{users} + case "onPremisesSamAccountName": + sorter = educationUsersByOnPremisesSamAccountName{users} + default: + return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value) + } + + if req.Query.OrderBy.OrderByItems[0].Order == "desc" { + sorter = sort.Reverse(sorter) + } + sort.Sort(sorter) + return users, nil +} diff --git a/services/graph/pkg/service/v0/educationuser_test.go b/services/graph/pkg/service/v0/educationuser_test.go new file mode 100644 index 000000000..98ee06809 --- /dev/null +++ b/services/graph/pkg/service/v0/educationuser_test.go @@ -0,0 +1,508 @@ +package svc_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + revactx "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/cs3org/reva/v2/pkg/rgrpc/status" + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + + libregraph "github.com/owncloud/libre-graph-api-go" + ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc" + "github.com/owncloud/ocis/v2/ocis-pkg/shared" + settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" + "github.com/owncloud/ocis/v2/services/graph/mocks" + "github.com/owncloud/ocis/v2/services/graph/pkg/config" + "github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults" + identitymocks "github.com/owncloud/ocis/v2/services/graph/pkg/identity/mocks" + service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0" +) + +type educationUserList struct { + Value []*libregraph.EducationUser +} + +var _ = Describe("EducationUsers", func() { + var ( + svc service.Service + ctx context.Context + cfg *config.Config + gatewayClient *mocks.GatewayClient + eventsPublisher mocks.Publisher + roleService *mocks.RoleService + identityEducationBackend *identitymocks.EducationBackend + + rr *httptest.ResponseRecorder + + currentUser = &userv1beta1.User{ + Id: &userv1beta1.UserId{ + OpaqueId: "user", + }, + } + ) + + BeforeEach(func() { + eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + identityEducationBackend = &identitymocks.EducationBackend{} + roleService = &mocks.RoleService{} + gatewayClient = &mocks.GatewayClient{} + + rr = httptest.NewRecorder() + ctx = context.Background() + + cfg = defaults.FullDefaultConfig() + cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests + cfg.TokenManager.JWTSecret = "loremipsum" + cfg.Commons = &shared.Commons{} + cfg.GRPCClientTLS = &shared.GRPCClientTLS{} + + _ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...) + svc = service.NewService( + service.Config(cfg), + service.WithGatewayClient(gatewayClient), + service.EventsPublisher(&eventsPublisher), + service.WithIdentityEducationBackend(identityEducationBackend), + //service.WithRoleService(roleService), + ) + }) + + Describe("GetEducationUsers", func() { + It("handles invalid requests", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$invalid=true", nil) + svc.GetEducationUsers(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("lists the users", func() { + user := &libregraph.EducationUser{} + user.SetId("user1") + users := []*libregraph.EducationUser{user} + + identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil) + svc.GetEducationUsers(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + res := educationUserList{} + err = json.Unmarshal(data, &res) + Expect(err).ToNot(HaveOccurred()) + + Expect(len(res.Value)).To(Equal(1)) + Expect(res.Value[0].GetId()).To(Equal("user1")) + }) + + It("sorts", func() { + user := &libregraph.EducationUser{} + user.SetId("user1") + user.SetMail("z@example.com") + user.SetDisplayName("9") + user.SetOnPremisesSamAccountName("9") + user2 := &libregraph.EducationUser{} + user2.SetId("user2") + user2.SetMail("a@example.com") + user2.SetDisplayName("1") + user2.SetOnPremisesSamAccountName("1") + users := []*libregraph.EducationUser{user, user2} + + identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil) + + getUsers := func(path string) []*libregraph.EducationUser { + r := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + svc.GetEducationUsers(rec, r) + + Expect(rec.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rec.Body) + Expect(err).ToNot(HaveOccurred()) + + res := educationUserList{} + err = json.Unmarshal(data, &res) + Expect(err).ToNot(HaveOccurred()) + return res.Value + } + + unsorted := getUsers("/graph/v1.0/education/users") + Expect(len(unsorted)).To(Equal(2)) + Expect(unsorted[0].GetId()).To(Equal("user1")) + Expect(unsorted[1].GetId()).To(Equal("user2")) + + byMail := getUsers("/graph/v1.0/education/users?$orderby=mail") + Expect(len(byMail)).To(Equal(2)) + Expect(byMail[0].GetId()).To(Equal("user2")) + Expect(byMail[1].GetId()).To(Equal("user1")) + byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20asc") + Expect(len(byMail)).To(Equal(2)) + Expect(byMail[0].GetId()).To(Equal("user2")) + Expect(byMail[1].GetId()).To(Equal("user1")) + byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20desc") + Expect(len(byMail)).To(Equal(2)) + Expect(byMail[0].GetId()).To(Equal("user1")) + Expect(byMail[1].GetId()).To(Equal("user2")) + + byDisplayName := getUsers("/graph/v1.0/education/users?$orderby=displayName") + Expect(len(byDisplayName)).To(Equal(2)) + Expect(byDisplayName[0].GetId()).To(Equal("user2")) + Expect(byDisplayName[1].GetId()).To(Equal("user1")) + byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20asc") + Expect(len(byDisplayName)).To(Equal(2)) + Expect(byDisplayName[0].GetId()).To(Equal("user2")) + Expect(byDisplayName[1].GetId()).To(Equal("user1")) + byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20desc") + Expect(len(byDisplayName)).To(Equal(2)) + Expect(byDisplayName[0].GetId()).To(Equal("user1")) + Expect(byDisplayName[1].GetId()).To(Equal("user2")) + + byOnPremisesSamAccountName := getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName") + Expect(len(byOnPremisesSamAccountName)).To(Equal(2)) + Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2")) + Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1")) + byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20asc") + Expect(len(byOnPremisesSamAccountName)).To(Equal(2)) + Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2")) + Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1")) + byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20desc") + Expect(len(byOnPremisesSamAccountName)).To(Equal(2)) + Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user1")) + Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user2")) + + // Handles invalid sort field + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$orderby=invalid", nil) + svc.GetEducationUsers(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("GetEducationUser", func() { + It("handles missing userids", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil) + svc.GetEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("gets the user", func() { + user := &libregraph.EducationUser{} + user.SetId("user1") + + identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil) + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", *user.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.GetEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + responseUser := &libregraph.EducationUser{} + err = json.Unmarshal(data, &responseUser) + Expect(err).ToNot(HaveOccurred()) + Expect(responseUser.GetId()).To(Equal("user1")) + Expect(len(responseUser.GetDrives())).To(Equal(0)) + }) + + It("includes the personal space if requested", func() { + user := &libregraph.EducationUser{} + user.SetId("user1") + + identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil) + gatewayClient.On("GetQuota", mock.Anything, mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{ + Status: status.NewOK(ctx), + TotalBytes: 10, + }, nil) + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{OpaqueId: "drive1"}, + Root: &provider.ResourceId{SpaceId: "space", OpaqueId: "space"}, + SpaceType: "project", + }, + { + Id: &provider.StorageSpaceId{OpaqueId: "personal"}, + Root: &provider.ResourceId{SpaceId: "personal", OpaqueId: "personal"}, + SpaceType: "personal", + }, + }, + }, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$expand=drive", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", *user.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.GetEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + responseUser := &libregraph.EducationUser{} + err = json.Unmarshal(data, &responseUser) + Expect(err).ToNot(HaveOccurred()) + Expect(responseUser.GetId()).To(Equal("user1")) + Expect(*responseUser.GetDrive().Id).To(Equal("personal")) + }) + + It("includes the drives if requested", func() { + user := &libregraph.EducationUser{} + user.SetId("user1") + + identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil) + gatewayClient.On("GetQuota", mock.Anything, mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{ + Status: status.NewOK(ctx), + TotalBytes: 10, + }, nil) + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{OpaqueId: "drive1"}, + Root: &provider.ResourceId{SpaceId: "space", OpaqueId: "space"}, + }, + }, + }, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$expand=drives", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", *user.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.GetEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + responseUser := &libregraph.EducationUser{} + err = json.Unmarshal(data, &responseUser) + Expect(err).ToNot(HaveOccurred()) + Expect(responseUser.GetId()).To(Equal("user1")) + Expect(len(responseUser.GetDrives())).To(Equal(1)) + }) + }) + + Describe("PostEducationUser", func() { + var ( + user *libregraph.EducationUser + + assertHandleBadAttributes = func(user *libregraph.EducationUser) { + userJson, err := json.Marshal(user) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson)) + svc.PostEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + } + ) + + BeforeEach(func() { + identity := libregraph.ObjectIdentity{} + identity.SetIssuer("issu.er") + identity.SetIssuerAssignedId("our-user.1") + + user = &libregraph.EducationUser{} + user.SetDisplayName("Display Name") + user.SetOnPremisesSamAccountName("user") + user.SetMail("user@example.com") + user.SetAccountEnabled(true) + user.SetIdentities([]libregraph.ObjectIdentity{identity}) + user.SetPrimaryRole("student") + }) + + It("handles invalid bodies", func() { + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil) + svc.PostEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles missing display names", func() { + user.DisplayName = nil + assertHandleBadAttributes(user) + + }) + + It("handles missing OnPremisesSamAccountName", func() { + user.OnPremisesSamAccountName = nil + assertHandleBadAttributes(user) + + user.SetOnPremisesSamAccountName("") + assertHandleBadAttributes(user) + }) + + It("handles bad Mails", func() { + user.Mail = nil + assertHandleBadAttributes(user) + + user.SetMail("not-a-mail-address") + assertHandleBadAttributes(user) + }) + + It("handles set Ids - they are read-only", func() { + user.SetId("/users/user") + assertHandleBadAttributes(user) + }) + + It("creates a user", func() { + roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil) + identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser { + user.SetId("/users/user") + return &user + }, nil) + userJson, err := json.Marshal(user) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson)) + r = r.WithContext(revactx.ContextSetUser(ctx, currentUser)) + svc.PostEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("DeleteEducationUser", func() { + It("handles missing userids", func() { + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil) + svc.DeleteEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("prevents a user from deleting themselves", func() { + lu := libregraph.EducationUser{} + lu.SetId(currentUser.Id.OpaqueId) + identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil) + + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", currentUser.Id.OpaqueId) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusForbidden)) + }) + + It("deletes a user from deleting themselves", func() { + otheruser := &userv1beta1.User{ + Id: &userv1beta1.UserId{ + OpaqueId: "otheruser", + }, + } + + lu := libregraph.EducationUser{} + lu.SetId(otheruser.Id.OpaqueId) + identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil) + identityEducationBackend.On("DeleteEducationUser", mock.Anything, mock.Anything).Return(nil) + gatewayClient.On("DeleteStorageSpace", mock.Anything, mock.Anything).Return(&provider.DeleteStorageSpaceResponse{ + Status: status.NewOK(ctx), + }, nil) + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*provider.StorageSpace{ + { + Opaque: &typesv1beta1.Opaque{}, + Id: &provider.StorageSpaceId{OpaqueId: "drive1"}, + Root: &provider.ResourceId{SpaceId: "space", OpaqueId: "space"}, + SpaceType: "personal", + Owner: otheruser, + }, + }, + }, nil) + + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", lu.GetId()) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusNoContent)) + gatewayClient.AssertNumberOfCalls(GinkgoT(), "DeleteStorageSpace", 2) // 2 calls for the home space. first trash, then purge + }) + }) + + Describe("PatchEducationUser", func() { + var ( + user *libregraph.EducationUser + ) + + BeforeEach(func() { + user = &libregraph.EducationUser{} + user.SetDisplayName("Display Name") + user.SetOnPremisesSamAccountName("user") + user.SetMail("user@example.com") + user.SetId("/users/user") + + identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&user, nil) + }) + + It("handles missing userids", func() { + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/users/{userid}", nil) + svc.PatchEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles invalid bodies", func() { + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", user.GetId()) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles invalid email", func() { + user.SetMail("invalid") + data, err := json.Marshal(user) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", user.GetId()) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("updates attributes", func() { + identityEducationBackend.On("UpdateEducationUser", mock.Anything, user.GetId(), mock.Anything).Return(user, nil) + + user.SetDisplayName("New Display Name") + data, err := json.Marshal(user) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", user.GetId()) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationUser(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err = io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + updatedUser := libregraph.EducationUser{} + err = json.Unmarshal(data, &updatedUser) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedUser.GetDisplayName()).To(Equal("New Display Name")) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go index d0977ffb4..760105f1d 100644 --- a/services/graph/pkg/service/v0/groups.go +++ b/services/graph/pkg/service/v0/groups.go @@ -246,6 +246,7 @@ func (g Graph) DeleteGroup(w http.ResponseWriter, r *http.Request) { render.NoContent(w, r) } +// GetGroupMembers implements the Service interface. func (g Graph) GetGroupMembers(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info().Msg("calling get group members") @@ -408,7 +409,7 @@ func sortGroups(req *godata.GoDataRequest, groups []*libregraph.Group) ([]*libre return groups, nil } switch req.Query.OrderBy.OrderByItems[0].Field.Value { - case "displayName": + case displayNameAttr: sorter = groupsByDisplayName{groups} default: return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value) diff --git a/services/graph/pkg/service/v0/instrument.go b/services/graph/pkg/service/v0/instrument.go index 12aac9dd3..41caf602e 100644 --- a/services/graph/pkg/service/v0/instrument.go +++ b/services/graph/pkg/service/v0/instrument.go @@ -139,6 +139,31 @@ func (i instrument) DeleteSchoolMember(w http.ResponseWriter, r *http.Request) { i.next.DeleteSchoolMember(w, r) } +// GetEducationUsers implements the Service interface. +func (i instrument) GetEducationUsers(w http.ResponseWriter, r *http.Request) { + i.next.GetEducationUsers(w, r) +} + +// GetEducationUser implements the Service interface. +func (i instrument) GetEducationUser(w http.ResponseWriter, r *http.Request) { + i.next.GetEducationUser(w, r) +} + +// PostEducationUser implements the Service interface. +func (i instrument) PostEducationUser(w http.ResponseWriter, r *http.Request) { + i.next.PostEducationUser(w, r) +} + +// DeleteEducationUser implements the Service interface. +func (i instrument) DeleteEducationUser(w http.ResponseWriter, r *http.Request) { + i.next.DeleteEducationUser(w, r) +} + +// PatchEducationUser implements the Service interface. +func (i instrument) PatchEducationUser(w http.ResponseWriter, r *http.Request) { + i.next.PatchEducationUser(w, r) +} + // GetDrives implements the Service interface. func (i instrument) GetDrives(w http.ResponseWriter, r *http.Request) { i.next.GetDrives(w, r) diff --git a/services/graph/pkg/service/v0/logging.go b/services/graph/pkg/service/v0/logging.go index cdd39a5ab..aa822531c 100644 --- a/services/graph/pkg/service/v0/logging.go +++ b/services/graph/pkg/service/v0/logging.go @@ -139,6 +139,31 @@ func (l logging) DeleteSchoolMember(w http.ResponseWriter, r *http.Request) { l.next.DeleteSchoolMember(w, r) } +// GetEducationUsers implements the Service interface. +func (l logging) GetEducationUsers(w http.ResponseWriter, r *http.Request) { + l.next.GetEducationUsers(w, r) +} + +// GetEducationUser implements the Service interface. +func (l logging) GetEducationUser(w http.ResponseWriter, r *http.Request) { + l.next.GetEducationUser(w, r) +} + +// PostEducationUser implements the Service interface. +func (l logging) PostEducationUser(w http.ResponseWriter, r *http.Request) { + l.next.PostEducationUser(w, r) +} + +// DeleteEducationUser implements the Service interface. +func (l logging) DeleteEducationUser(w http.ResponseWriter, r *http.Request) { + l.next.DeleteEducationUser(w, r) +} + +// PatchEducationUser implements the Service interface. +func (l logging) PatchEducationUser(w http.ResponseWriter, r *http.Request) { + l.next.PatchEducationUser(w, r) +} + // GetDrives implements the Service interface. func (l logging) GetDrives(w http.ResponseWriter, r *http.Request) { l.next.GetDrives(w, r) diff --git a/services/graph/pkg/service/v0/ordering.go b/services/graph/pkg/service/v0/ordering.go index 46614d7a7..f3464619f 100644 --- a/services/graph/pkg/service/v0/ordering.go +++ b/services/graph/pkg/service/v0/ordering.go @@ -119,3 +119,41 @@ type schoolsByDisplayName struct { func (g schoolsByDisplayName) Less(i, j int) bool { return strings.ToLower(g.schoolSlice[i].GetDisplayName()) < strings.ToLower(g.schoolSlice[j].GetDisplayName()) } + +type educationUserSlice []*libregraph.EducationUser + +// Len is the number of elements in the collection. +func (d educationUserSlice) Len() int { return len(d) } + +// Swap swaps the elements with indexes i and j. +func (d educationUserSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] } + +type educationUsersByDisplayName struct { + educationUserSlice +} + +type educationUsersByMail struct { + educationUserSlice +} + +type educationUsersByOnPremisesSamAccountName struct { + educationUserSlice +} + +// Less reports whether the element with index i +// must sort before the element with index j. +func (u educationUsersByDisplayName) Less(i, j int) bool { + return strings.ToLower(u.educationUserSlice[i].GetDisplayName()) < strings.ToLower(u.educationUserSlice[j].GetDisplayName()) +} + +// Less reports whether the element with index i +// must sort before the element with index j. +func (u educationUsersByMail) Less(i, j int) bool { + return strings.ToLower(u.educationUserSlice[i].GetMail()) < strings.ToLower(u.educationUserSlice[j].GetMail()) +} + +// Less reports whether the element with index i +// must sort before the element with index j. +func (u educationUsersByOnPremisesSamAccountName) Less(i, j int) bool { + return strings.ToLower(u.educationUserSlice[i].GetOnPremisesSamAccountName()) < strings.ToLower(u.educationUserSlice[j].GetOnPremisesSamAccountName()) +} diff --git a/services/graph/pkg/service/v0/schools.go b/services/graph/pkg/service/v0/schools.go index b925981d3..8cc57f24c 100644 --- a/services/graph/pkg/service/v0/schools.go +++ b/services/graph/pkg/service/v0/schools.go @@ -63,12 +63,6 @@ func (g Graph) PostSchool(w http.ResponseWriter, r *http.Request) { return } - if _, ok := school.GetDisplayNameOk(); !ok { - logger.Debug().Err(err).Interface("school", school).Msg("could not create school: missing required attribute") - errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute") - return - } - // Disallow user-supplied IDs. It's supposed to be readonly. We're either // generating them in the backend ourselves or rely on the Backend's // storage (e.g. LDAP) to provide a unique ID. @@ -78,6 +72,18 @@ func (g Graph) PostSchool(w http.ResponseWriter, r *http.Request) { return } + if _, ok := school.GetDisplayNameOk(); !ok { + logger.Debug().Err(err).Interface("school", school).Msg("could not create school: missing required attribute") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute") + return + } + + if _, ok := school.GetSchoolNumberOk(); !ok { + logger.Debug().Err(err).Interface("school", school).Msg("could not create school: missing required attribute") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute") + return + } + if school, err = g.identityEducationBackend.CreateSchool(r.Context(), *school); err != nil { logger.Debug().Interface("school", school).Msg("could not create school: backend error") errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) @@ -391,7 +397,7 @@ func sortSchools(req *godata.GoDataRequest, schools []*libregraph.EducationSchoo return schools, nil } switch req.Query.OrderBy.OrderByItems[0].Field.Value { - case "displayName": + case displayNameAttr: sorter = schoolsByDisplayName{schools} default: return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value) diff --git a/services/graph/pkg/service/v0/schools_test.go b/services/graph/pkg/service/v0/schools_test.go index e4c4a5531..93df95e9f 100644 --- a/services/graph/pkg/service/v0/schools_test.go +++ b/services/graph/pkg/service/v0/schools_test.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "errors" - "io/ioutil" + "io" "net/http" "net/http/httptest" @@ -37,7 +37,6 @@ var _ = Describe("Schools", func() { ctx context.Context cfg *config.Config gatewayClient *mocks.GatewayClient - eventsPublisher mocks.Publisher identityEducationBackend *identitymocks.EducationBackend rr *httptest.ResponseRecorder @@ -51,7 +50,6 @@ var _ = Describe("Schools", func() { ) BeforeEach(func() { - eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil) identityEducationBackend = &identitymocks.EducationBackend{} gatewayClient = &mocks.GatewayClient{} @@ -71,7 +69,6 @@ var _ = Describe("Schools", func() { svc = service.NewService( service.Config(cfg), service.WithGatewayClient(gatewayClient), - service.EventsPublisher(&eventsPublisher), service.WithIdentityEducationBackend(identityEducationBackend), ) }) @@ -91,7 +88,7 @@ var _ = Describe("Schools", func() { svc.GetSchools(rr, r) Expect(rr.Code).To(Equal(http.StatusBadRequest)) - data, err := ioutil.ReadAll(rr.Body) + data, err := io.ReadAll(rr.Body) Expect(err).ToNot(HaveOccurred()) odataerr := libregraph.OdataError{} @@ -106,7 +103,7 @@ var _ = Describe("Schools", func() { r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil) svc.GetSchools(rr, r) Expect(rr.Code).To(Equal(http.StatusInternalServerError)) - data, err := ioutil.ReadAll(rr.Body) + data, err := io.ReadAll(rr.Body) Expect(err).ToNot(HaveOccurred()) odataerr := libregraph.OdataError{} @@ -122,7 +119,7 @@ var _ = Describe("Schools", func() { svc.GetSchools(rr, r) Expect(rr.Code).To(Equal(http.StatusInternalServerError)) - data, err := ioutil.ReadAll(rr.Body) + data, err := io.ReadAll(rr.Body) Expect(err).ToNot(HaveOccurred()) odataerr := libregraph.OdataError{} @@ -138,7 +135,7 @@ var _ = Describe("Schools", func() { svc.GetSchools(rr, r) Expect(rr.Code).To(Equal(http.StatusOK)) - data, err := ioutil.ReadAll(rr.Body) + data, err := io.ReadAll(rr.Body) Expect(err).ToNot(HaveOccurred()) res := service.ListResponse{} @@ -154,7 +151,7 @@ var _ = Describe("Schools", func() { svc.GetSchools(rr, r) Expect(rr.Code).To(Equal(http.StatusOK)) - data, err := ioutil.ReadAll(rr.Body) + data, err := io.ReadAll(rr.Body) Expect(err).ToNot(HaveOccurred()) res := schoolList{} @@ -265,6 +262,7 @@ var _ = Describe("Schools", func() { It("creates the school", func() { newSchool = libregraph.NewEducationSchool() newSchool.SetDisplayName("New School") + newSchool.SetSchoolNumber("0034") newSchoolJson, err := json.Marshal(newSchool) Expect(err).ToNot(HaveOccurred()) @@ -346,15 +344,14 @@ var _ = Describe("Schools", func() { Expect(rr.Code).To(Equal(http.StatusNoContent)) identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteSchool", 1) - eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1) }) }) Describe("GetSchoolMembers", func() { It("gets the list of members", func() { - user := libregraph.NewUser() + user := libregraph.NewEducationUser() user.SetId("user") - identityEducationBackend.On("GetSchoolMembers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.User{user}, nil) + identityEducationBackend.On("GetSchoolMembers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user}, nil) r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/members", nil) rctx := chi.NewRouteContext() @@ -363,7 +360,7 @@ var _ = Describe("Schools", func() { svc.GetSchoolMembers(rr, r) Expect(rr.Code).To(Equal(http.StatusOK)) - data, err := ioutil.ReadAll(rr.Body) + data, err := io.ReadAll(rr.Body) Expect(err).ToNot(HaveOccurred()) var members []*libregraph.User diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index daa3d9eaf..639a45f0d 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -23,7 +23,8 @@ import ( const ( // HeaderPurge defines the header name for the purge header. - HeaderPurge = "Purge" + HeaderPurge = "Purge" + displayNameAttr = "displayName" ) // Service defines the service handlers. @@ -55,6 +56,12 @@ type Service interface { PostSchoolMember(http.ResponseWriter, *http.Request) DeleteSchoolMember(http.ResponseWriter, *http.Request) + GetEducationUsers(http.ResponseWriter, *http.Request) + GetEducationUser(http.ResponseWriter, *http.Request) + PostEducationUser(http.ResponseWriter, *http.Request) + DeleteEducationUser(http.ResponseWriter, *http.Request) + PatchEducationUser(http.ResponseWriter, *http.Request) + GetDrives(w http.ResponseWriter, r *http.Request) GetSingleDrive(w http.ResponseWriter, r *http.Request) GetAllDrives(w http.ResponseWriter, r *http.Request) @@ -251,12 +258,12 @@ func NewService(opts ...Option) Service { }) }) r.Route("/users", func(r chi.Router) { - r.Get("/", svc.GetUsers) - r.Post("/", svc.PostUser) + r.Get("/", svc.GetEducationUsers) + r.Post("/", svc.PostEducationUser) r.Route("/{userID}", func(r chi.Router) { - r.Get("/", svc.GetUser) - r.Delete("/", svc.DeleteUser) - r.Patch("/", svc.PatchUser) + r.Get("/", svc.GetEducationUser) + r.Delete("/", svc.DeleteEducationUser) + r.Patch("/", svc.PatchEducationUser) }) }) r.Route("/classes", func(r chi.Router) { diff --git a/services/graph/pkg/service/v0/tracing.go b/services/graph/pkg/service/v0/tracing.go index 0b6bd8d6f..5d261a4d2 100644 --- a/services/graph/pkg/service/v0/tracing.go +++ b/services/graph/pkg/service/v0/tracing.go @@ -135,6 +135,31 @@ func (t tracing) DeleteSchoolMember(w http.ResponseWriter, r *http.Request) { t.next.DeleteSchoolMember(w, r) } +// GetEducationUsers implements the Service interface. +func (t tracing) GetEducationUsers(w http.ResponseWriter, r *http.Request) { + t.next.GetEducationUsers(w, r) +} + +// GetEducationUser implements the Service interface. +func (t tracing) GetEducationUser(w http.ResponseWriter, r *http.Request) { + t.next.GetEducationUser(w, r) +} + +// PostEducationUser implements the Service interface. +func (t tracing) PostEducationUser(w http.ResponseWriter, r *http.Request) { + t.next.PostEducationUser(w, r) +} + +// DeleteEducationUser implements the Service interface. +func (t tracing) DeleteEducationUser(w http.ResponseWriter, r *http.Request) { + t.next.DeleteEducationUser(w, r) +} + +// PatchEducationUser implements the Service interface. +func (t tracing) PatchEducationUser(w http.ResponseWriter, r *http.Request) { + t.next.PatchEducationUser(w, r) +} + // GetDrives implements the Service interface. func (t tracing) GetDrives(w http.ResponseWriter, r *http.Request) { t.next.GetDrives(w, r) diff --git a/services/graph/pkg/service/v0/users.go b/services/graph/pkg/service/v0/users.go index fcfa882de..56238e719 100644 --- a/services/graph/pkg/service/v0/users.go +++ b/services/graph/pkg/service/v0/users.go @@ -97,6 +97,7 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, &ListResponse{Value: users}) } +// PostUser implements the Service interface. func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info().Interface("body", r.Body).Msg("calling create user") @@ -278,6 +279,7 @@ func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, user) } +// DeleteUser implements the Service interface. func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info().Msg("calling delete user") @@ -489,7 +491,7 @@ func sortUsers(req *godata.GoDataRequest, users []*libregraph.User) ([]*libregra return users, nil } switch req.Query.OrderBy.OrderByItems[0].Field.Value { - case "displayName": + case displayNameAttr: sorter = usersByDisplayName{users} case "mail": sorter = usersByMail{users}