Files
opencloud/services/graph/pkg/service/v0/users.go
Florian Schade ad06a192d8 enhancement: add graph beta listPermissions endpoint (#7753)
* enhancement: add graph beta listPermissions endpoint

besides the new api endpoint it includes several utilities to simplify the graph api development.

* resolve drive and item id from the request path
* generic pointer and value utilities
* space root detection

* update GetDriveAndItemIDParam signature to return a error

* move errorcode package

* enhancement: add generic error code handling

* fix: rebase
2023-11-28 17:06:04 +01:00

926 lines
34 KiB
Go

package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
"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"
settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
"github.com/owncloud/ocis/v2/services/graph/pkg/errorcode"
"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
ocissettingssvc "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
)
// GetMe implements the Service interface.
func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("calling get user in /me")
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 users: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
logger.Debug().Msg("could not get user: user not in context")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
exp, err := identity.GetExpandValues(odataReq.Query)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: $expand error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
var me *libregraph.User
// We can just return the user from context unless we need to expand the group memberships
if !slices.Contains(exp, "memberOf") {
me = identity.CreateUserModelFromCS3(u)
} else {
var err error
logger.Debug().Msg("calling get user on backend")
me, err = g.identityBackend.GetUser(r.Context(), u.GetId().GetOpaqueId(), odataReq)
if err != nil {
logger.Debug().Err(err).Interface("user", u).Msg("could not get user from backend")
errorcode.RenderError(w, r, err)
return
}
if me.MemberOf == nil {
me.MemberOf = []libregraph.Group{}
}
}
// expand appRoleAssignments if requested
if slices.Contains(exp, "appRoleAssignments") {
var err error
me.AppRoleAssignments, err = g.fetchAppRoleAssignments(r.Context(), me.GetId())
if err != nil {
logger.Debug().Err(err).Str("userid", me.GetId()).Msg("could not get appRoleAssignments for self")
errorcode.RenderError(w, r, err)
return
}
}
preferedLanguage, _, err := getUserLanguage(r.Context(), g.valueService, me.GetId())
if err != nil {
logger.Error().Err(err).Msg("could not get user language")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not get user language")
return
}
me.PreferredLanguage = &preferedLanguage
render.Status(r, http.StatusOK)
render.JSON(w, r, me)
}
func (g Graph) fetchAppRoleAssignments(ctx context.Context, accountuuid string) ([]libregraph.AppRoleAssignment, error) {
lrar, err := g.roleService.ListRoleAssignments(ctx, &settings.ListRoleAssignmentsRequest{
AccountUuid: accountuuid,
})
if err != nil {
return []libregraph.AppRoleAssignment{}, err
}
values := make([]libregraph.AppRoleAssignment, 0, len(lrar.Assignments))
for _, assignment := range lrar.GetAssignments() {
values = append(values, g.assignmentToAppRoleAssignment(assignment))
}
return values, nil
}
// GetUserDrive implements the Service interface.
func (g Graph) GetUserDrive(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Interface("query", r.URL.Query()).Msg("calling get user drive")
userID, err := url.PathUnescape(chi.URLParam(r, "userID"))
if err != nil {
logger.Debug().Err(err).Str("userID", chi.URLParam(r, "userID")).Msg("could not get drive: unescaping drive id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
return
}
if userID == "" {
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
logger.Debug().Msg("could not get user: user not in context")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
userID = u.GetId().GetOpaqueId()
}
logger.Debug().Str("userID", userID).Msg("calling list storage spaces with user and personal filter")
ctx := r.Context()
filters := []*storageprovider.ListStorageSpacesRequest_Filter{listStorageSpacesTypeFilter("personal"), listStorageSpacesUserFilter(userID)}
res, err := g.ListStorageSpacesWithFilters(ctx, filters, true)
switch {
case err != nil:
logger.Error().Err(err).Msg("could not get drive: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
// the client is doing a lookup for a specific space, therefore we need to return
// not found to the caller
logger.Debug().Str("userID", userID).Msg("could not get personal drive for user: not found")
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "drive not found")
return
}
logger.Debug().
Str("userID", userID).
Str("grpcmessage", res.GetStatus().GetMessage()).
Msg("could not get personal drive for user: grpc error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
return
}
webDavBaseURL, err := g.getWebDavBaseURL()
if err != nil {
logger.Error().Err(err).Str("url", webDavBaseURL.String()).Msg("could not get personal drive: error parsing webdav base url")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
spaces, err := g.formatDrives(ctx, webDavBaseURL, res.StorageSpaces)
if err != nil {
logger.Debug().Err(err).Msg("could not get personal drive: error parsing grpc response")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
switch num := len(spaces); {
case num == 0:
logger.Debug().Str("userID", userID).Msg("could not get personal drive: no drive returned from storage")
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "no drive returned from storage")
return
case num == 1:
render.Status(r, http.StatusOK)
render.JSON(w, r, spaces[0])
default:
logger.Debug().Int("number", num).Msg("could not get personal drive: expected to find a single drive but fetched more")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not get personal drive: expected to find a single drive but fetched more")
return
}
}
// GetUsers implements the Service interface.
func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Interface("query", r.URL.Query()).Msg("calling get 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 users: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
logger.Debug().Interface("query", r.URL.Query()).Msg("calling get users on backend")
var users []*libregraph.User
if odataReq.Query.Filter != nil {
users, err = g.applyUserFilter(r.Context(), odataReq, nil)
} else {
users, err = g.identityBackend.GetUsers(r.Context(), odataReq)
}
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users from backend")
var errcode errorcode.Error
var godataerr *godata.GoDataError
switch {
case errors.As(err, &errcode):
errcode.Render(w, r)
case errors.As(err, &godataerr):
errorcode.GeneralException.Render(w, r, godataerr.ResponseCode, err.Error())
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
return
}
exp, err := identity.GetExpandValues(odataReq.Query)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: $expand error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
expandAppRoleAssignments := slices.Contains(exp, "appRoleAssignments")
expandMemberOf := slices.Contains(exp, "memberOf")
for _, u := range users {
if expandAppRoleAssignments {
u.AppRoleAssignments, err = g.fetchAppRoleAssignments(r.Context(), u.GetId())
if err != nil {
// TODO I think we should not continue here, see http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionexpand
// > The $expand system query option indicates the related entities and stream values that MUST be represented inline. The service MUST return the specified content, and MAY choose to return additional information.
logger.Debug().Err(err).Str("userid", u.GetId()).Msg("could not get appRoleAssignments when listing user")
}
}
if expandMemberOf {
if u.MemberOf == nil {
u.MemberOf = []libregraph.Group{}
}
}
}
users, err = sortUsers(odataReq, users)
if err != nil {
logger.Debug().Interface("query", odataReq).Msg("error while sorting 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})
}
// PostUser implements the Service interface.
func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("calling create user")
u := libregraph.NewUser()
err := StrictJSONUnmarshal(r.Body, u)
if err != nil {
logger.Info().Err(err).Msg("could not create user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
return
}
if _, ok := u.GetDisplayNameOk(); !ok {
logger.Info().Err(err).Interface("user", u).Msg("could not create user: missing required Attribute: 'displayName'")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'displayName'")
return
}
if accountName, ok := u.GetOnPremisesSamAccountNameOk(); ok {
if !g.isValidUsername(*accountName) {
logger.Info().Str("username", *accountName).Msg("could not create user: invalid username")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Invalid username")
return
}
} else {
logger.Info().Interface("user", u).Msg("could not create 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.Info().Str("mail", *u.Mail).Msg("could not create user: invalid email address")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid email address")
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.Info().Interface("user", u).Msg("could not create user: user id is a read-only attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user id is a read-only attribute")
return
}
if u.HasUserType() {
if !isValidUserType(*u.UserType) {
logger.Info().Interface("user", u).Msg("invalid userType attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid userType attribute, valid options are 'Member' or 'Guest'")
return
}
} else {
u.SetUserType("Member")
}
logger.Debug().Interface("user", u).Msg("calling create user on backend")
if u, err = g.identityBackend.CreateUser(r.Context(), *u); err != nil {
logger.Error().Err(err).Msg("could not create user: backend error")
errorcode.RenderError(w, r, err)
return
}
// assign roles if possible
if g.roleService != nil && g.config.API.AssignDefaultUserRole {
// 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: ocissettingssvc.BundleUUIDRoleUser,
}); err != nil {
// log as error, admin eventually needs to do something
logger.Error().Err(err).Str("id", *u.Id).Str("role", ocissettingssvc.BundleUUIDRoleUser).Msg("could not create 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(r.Context(), e)
render.Status(r, http.StatusCreated)
render.JSON(w, r, u)
}
// GetUser implements the Service interface.
func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("calling get user")
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
userID := chi.URLParam(r, "userID")
userID, err := url.PathUnescape(userID)
if err != nil {
logger.Debug().Err(err).Str("id", userID).Msg("could not get user: unescaping user id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
return
}
if userID == "" {
logger.Debug().Msg("could not get user: missing user id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
return
}
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 users: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
exp, err := identity.GetExpandValues(odataReq.Query)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: $expand error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
logger.Debug().Str("id", userID).Msg("calling get user from backend")
user, err := g.identityBackend.GetUser(r.Context(), userID, odataReq)
if err != nil {
logger.Debug().Err(err).Msg("could not get user: error fetching user from backend")
errorcode.RenderError(w, r, err)
return
}
listDrives := slices.Contains(exp, "drives")
listDrive := slices.Contains(exp, "drive")
// do we need to list all or only the personal drive
filters := []*storageprovider.ListStorageSpacesRequest_Filter{}
if listDrives || listDrive {
filters = append(filters, listStorageSpacesUserFilter(user.GetId()))
if !listDrives {
// TODO filter by owner when decomposedfs supports the OWNER filter
filters = append(filters, listStorageSpacesTypeFilter("personal"))
}
}
if len(filters) > 0 {
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 filter")
// use the unrestricted flag to get all possible spaces
// users with the canListAllSpaces permission should see all spaces
client, err := g.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("error selecting next gateway client")
render.Status(r, http.StatusInternalServerError)
return
}
opaque := utils.AppendPlainToOpaque(nil, "unrestricted", "T")
lspr, err := client.ListStorageSpaces(r.Context(), &storageprovider.ListStorageSpacesRequest{
Opaque: opaque,
Filters: filters,
})
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 list drives for 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
}
if listDrives {
user.Drives = make([]libregraph.Drive, 0, len(lspr.GetStorageSpaces()))
}
if listDrive {
user.Drive = &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 d.GetDriveType() == "personal" && sp.GetOwner().GetId().GetOpaqueId() == user.GetId() {
if listDrive {
user.Drive = d
}
} else {
if listDrives {
user.Drives = append(user.Drives, *d)
}
}
}
}
// expand appRoleAssignments if requested
if slices.Contains(exp, "appRoleAssignments") {
user.AppRoleAssignments, err = g.fetchAppRoleAssignments(r.Context(), user.GetId())
if err != nil {
logger.Debug().Err(err).Str("userid", user.GetId()).Msg("could not get appRoleAssignments for user")
errorcode.RenderError(w, r, err)
return
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, user)
}
// getUserLanguage returns the language of the user in the context.
func getUserLanguage(ctx context.Context, valueService settingssvc.ValueService, userID string) (string, string, error) {
gvr, err := valueService.GetValueByUniqueIdentifiers(ctx, &settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: userID,
SettingId: defaults.SettingUUIDProfileLanguage,
})
if err != nil {
return "", "", err
}
langVal := gvr.GetValue().GetValue().GetListValue().GetValues()
if len(langVal) > 0 && langVal[0] != nil {
return langVal[0].GetStringValue(), gvr.GetValue().GetValue().GetId(), nil
}
return "", "", errors.New("no language value found")
}
// DeleteUser implements the Service interface.
func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("calling delete user")
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
userID := chi.URLParam(r, "userID")
userID, err := url.PathUnescape(userID)
if err != nil {
logger.Debug().Err(err).Str("id", userID).Msg("could not delete user: unescaping user id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
return
}
if userID == "" {
logger.Debug().Msg("could not delete user: missing user id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
return
}
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 users: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
logger.Debug().Str("id", userID).Msg("calling get user on user backend")
user, err := g.identityBackend.GetUser(r.Context(), userID, odataReq)
if err != nil {
logger.Debug().Err(err).Str("userID", userID).Msg("failed to get user from backend")
errorcode.RenderError(w, r, err)
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 user: self deletion forbidden")
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, "self deletion forbidden")
return
}
e.Executant = currentUser.GetId()
}
if g.gatewaySelector != 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())
client, err := g.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("error selecting next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "error selecting next gateway client, aborting")
return
}
lspr, err := client.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 := client.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 := client.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 user on backend")
err = g.identityBackend.DeleteUser(r.Context(), user.GetId())
if err != nil {
logger.Debug().Err(err).Msg("could not delete user: backend error")
errorcode.RenderError(w, r, err)
return
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// PatchMe implements the Service Interface. Updates the specified attributes of the current user
func (g Graph) PatchMe(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("calling patch me")
userID := revactx.ContextMustGetUser(r.Context()).GetId().GetOpaqueId()
if userID == "" {
logger.Debug().Msg("could not update user: missing user id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
return
}
changes := libregraph.NewUser()
err := StrictJSONUnmarshal(r.Body, changes)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
if _, ok := changes.GetDisplayNameOk(); ok {
logger.Info().Interface("user", changes).Msg("could not update user: user is not allowed to change own displayname")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user is not allowed to change own displayname")
return
}
if _, ok := changes.GetMailOk(); ok {
logger.Info().Interface("user", changes).Msg("could not update user: user is not allowed to change own mail")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user is not allowed to change own mail")
return
}
g.patchUser(w, r, userID, changes)
}
// PatchUser implements the Service Interface. Updates the specified attributes of an
// ExistingUser
func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
nameOrID := chi.URLParam(r, "userID")
nameOrID, err := url.PathUnescape(nameOrID)
if err != nil {
logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update user: unescaping user id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
return
}
changes := libregraph.NewUser()
err = StrictJSONUnmarshal(r.Body, changes)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
if _, ok := changes.GetPreferredLanguageOk(); ok {
logger.Info().Interface("user", changes).Msg("could not update user: user is not allowed to change other users language")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user is not allowed to change other users language")
return
}
g.patchUser(w, r, nameOrID, changes)
}
func (g Graph) patchUser(w http.ResponseWriter, r *http.Request, nameOrID string, changes *libregraph.User) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("calling patch user")
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 users: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
oldUserValues, err := g.identityBackend.GetUser(r.Context(), nameOrID, odataReq)
if err != nil {
logger.Debug().Err(err).Str("id", nameOrID).Msg("could not get user: backend error")
errorcode.RenderError(w, r, err)
return
}
if nameOrID == "" {
logger.Debug().Msg("could not update user: missing user id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
return
}
if reflect.ValueOf(*changes).IsZero() {
logger.Debug().Interface("body", r.Body).Msg("ignoring empty request body")
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
return
}
if accountName, ok := changes.GetOnPremisesSamAccountNameOk(); ok {
if !g.isValidUsername(*accountName) {
logger.Info().Str("username", *accountName).Msg("could not update user: invalid username")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Invalid username")
return
}
}
preferredLanguage, ok := changes.GetPreferredLanguageOk()
if ok {
_, vID, err := getUserLanguage(r.Context(), g.valueService, oldUserValues.GetId())
if err != nil {
logger.Error().Err(err).Msg("could not get user language")
tvID, err := uuid.NewUUID()
if err != nil {
logger.Error().Err(err).Msg("could not create user: error generating uuid")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "error generating uuid")
return
}
vID = tvID.String()
}
_, err = g.valueService.SaveValue(r.Context(), &settings.SaveValueRequest{
Value: &settingsmsg.Value{
Id: vID,
BundleId: defaults.BundleUUIDProfile,
SettingId: defaults.SettingUUIDProfileLanguage,
AccountUuid: oldUserValues.GetId(),
Resource: &settingsmsg.Resource{
Type: settingsmsg.Resource_TYPE_USER,
},
Value: &settingsmsg.Value_ListValue{
ListValue: &settingsmsg.ListValue{Values: []*settingsmsg.ListOptionValue{
{
Option: &settingsmsg.ListOptionValue_StringValue{
StringValue: *preferredLanguage,
},
},
}},
},
},
})
if err != nil {
logger.Error().Err(err).Msg("could not update user: error saving language setting")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "error saving language setting")
return
}
}
var features []events.UserFeature
if mail, ok := changes.GetMailOk(); ok {
if !isValidEmail(*mail) {
logger.Debug().Str("mail", *mail).Msg("could not update 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,
OldValue: oldUserValues.Mail,
})
}
if name, ok := changes.GetDisplayNameOk(); ok {
features = append(features, events.UserFeature{
Name: "displayname",
Value: *name,
OldValue: oldUserValues.DisplayName,
})
}
if userType, ok := changes.GetUserTypeOk(); ok {
if !isValidUserType(*changes.UserType) {
logger.Debug().Interface("user", changes).Msg("invalid userType attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid userType attribute, valid options are 'Member' or 'Guest'")
return
}
features = append(features, events.UserFeature{
Name: "changeUserType",
Value: *userType,
OldValue: oldUserValues.UserType,
})
}
if accEnabled, ok := changes.GetAccountEnabledOk(); ok {
oldAccVal := strconv.FormatBool(oldUserValues.GetAccountEnabled())
features = append(features, events.UserFeature{
Name: "accountEnabled",
Value: strconv.FormatBool(*accEnabled),
OldValue: &oldAccVal,
})
}
if changes.HasPasswordProfile() {
features = append(features, events.UserFeature{
Name: "passwordChanged",
})
}
logger.Debug().Str("nameid", nameOrID).Interface("changes", *changes).Msg("calling update user on backend")
u, err := g.identityBackend.UpdateUser(r.Context(), nameOrID, *changes)
if err != nil {
logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update user: backend error")
errorcode.RenderError(w, r, err)
return
}
u.PreferredLanguage = preferredLanguage
e := events.UserFeatureChanged{
UserID: nameOrID,
Features: features,
Timestamp: utils.TSNow(),
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusOK) // TODO StatusNoContent when prefer=minimal is used
render.JSON(w, r, u)
}
const (
usernameMatchDefault = "default"
usernameMatchNone = "none"
)
var usernameRegexes = map[string]*regexp.Regexp{
// We want to allow email addresses as usernames so they show up when using them in ACLs on storages that allow integration with our glauth LDAP service
// so we are adding a few restrictions from https://stackoverflow.com/questions/6949667/what-are-the-real-rules-for-linux-usernames-on-centos-6-and-rhel-6
// names should not start with numbers
usernameMatchDefault: regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]*(@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)*$"),
// In some cases users will be provisioned from an existing system, which may or may not have strange usernames. Because of this we want to "trust" the
// upstream system and allow weird usernames, so relying on the used identity provider to complain if a username is violating its restrictions.
usernameMatchNone: regexp.MustCompile(".*"),
}
func (g Graph) isValidUsername(e string) bool {
if len(e) < 1 && len(e) > 254 {
return false
}
return usernameRegexes[g.config.API.UsernameMatch].MatchString(e)
}
// regex from https://www.w3.org/TR/2016/REC-html51-20161101/sec-forms.html#valid-e-mail-address
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
func isValidEmail(e string) bool {
if len(e) < 3 && len(e) > 254 {
return false
}
return emailRegex.MatchString(e)
}
func sortUsers(req *godata.GoDataRequest, users []*libregraph.User) ([]*libregraph.User, error) {
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return users, nil
}
var less func(i, j int) bool
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
case displayNameAttr:
less = func(i, j int) bool {
return strings.ToLower(users[i].GetDisplayName()) < strings.ToLower(users[j].GetDisplayName())
}
case "mail":
less = func(i, j int) bool {
return strings.ToLower(users[i].GetMail()) < strings.ToLower(users[j].GetMail())
}
case "onPremisesSamAccountName":
less = func(i, j int) bool {
return strings.ToLower(users[i].GetOnPremisesSamAccountName()) < strings.ToLower(users[j].GetOnPremisesSamAccountName())
}
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 == _sortDescending {
sort.Slice(users, reverse(less))
} else {
sort.Slice(users, less)
}
return users, nil
}
func isValidUserType(userType string) bool {
userType = strings.ToLower(userType)
for _, value := range []string{"member", "guest"} {
if userType == value {
return true
}
}
return false
}