Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,991 @@
package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/publicshare"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/l10n"
l10n_pkg "github.com/qsfera/server/services/graph/pkg/l10n"
"github.com/qsfera/server/services/graph/pkg/odata"
"github.com/qsfera/server/pkg/conversions"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity"
"github.com/qsfera/server/services/graph/pkg/identity/cache"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
"github.com/qsfera/server/services/graph/pkg/validate"
)
const (
invalidIdMsg = "invalid driveID or itemID"
parseDriveIDErrMsg = "could not parse driveID"
federatedRolesODataFilter = "@libre.graph.permissions.roles.allowedValues/rolePermissions/any(p:contains(p/condition, '@Subject.UserType==\"Federated\"'))"
noLinksODataFilter = "grantedToV2 ne ''"
)
// DriveItemPermissionsProvider contains the methods related to handling permissions on drive items
type DriveItemPermissionsProvider interface {
Invite(ctx context.Context, resourceId *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error)
SpaceRootInvite(ctx context.Context, driveID *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error)
ListPermissions(ctx context.Context, itemID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error)
ListSpaceRootPermissions(ctx context.Context, driveID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error)
DeletePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string) error
DeleteSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string) error
UpdatePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error)
UpdateSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error)
CreateLink(ctx context.Context, driveItemID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error)
CreateSpaceRootLink(ctx context.Context, driveID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error)
SetPublicLinkPassword(ctx context.Context, driveItemID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error)
SetPublicLinkPasswordOnSpaceRoot(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error)
}
// DriveItemPermissionsService contains the production business logic for everything that relates to permissions on drive items.
type DriveItemPermissionsService struct {
BaseGraphService
}
type permissionType int
const (
Unknown permissionType = iota
Public
User
Space
OCM
)
type ListPermissionsQueryOptions struct {
Count bool
NoValues bool
NoLinkPermissions bool
FilterFederatedRoles bool
SelectedAttrs []string
}
// NewDriveItemPermissionsService creates a new DriveItemPermissionsService
func NewDriveItemPermissionsService(logger log.Logger, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], identityCache cache.IdentityCache, config *config.Config) (DriveItemPermissionsService, error) {
publicBaseURL, err := url.Parse(config.Spaces.WebDavBase)
if err != nil {
return DriveItemPermissionsService{}, fmt.Errorf("could not parse graph.spaces.webdav_base: %w", err)
}
return DriveItemPermissionsService{
BaseGraphService: BaseGraphService{
logger: &log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemService").Logger()},
gatewaySelector: gatewaySelector,
identityCache: identityCache,
config: config,
availableRoles: unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(config.UnifiedRoles.AvailableRoles...)),
publicBaseURL: publicBaseURL,
},
}, nil
}
// Invite invites a user to a drive item.
func (s DriveItemPermissionsService) Invite(ctx context.Context, resourceId *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error) {
tenantId := revactx.ContextMustGetUser(ctx).GetId().GetTenantId()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
statResponse, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: resourceId}})
if err := errorcode.FromStat(statResponse, err); err != nil {
s.logger.Warn().Err(err).Interface("stat.res", statResponse).Msg("stat failed")
return libregraph.Permission{}, err
}
var condition string
if condition, err = roleConditionForResourceType(statResponse.GetInfo()); err != nil {
return libregraph.Permission{}, err
}
unifiedRolePermissions := []*libregraph.UnifiedRolePermission{{AllowedResourceActions: invite.LibreGraphPermissionsActions}}
for _, roleID := range invite.GetRoles() {
// only allow roles that are enabled in the config
if !slices.Contains(s.config.UnifiedRoles.AvailableRoles, roleID) {
return libregraph.Permission{}, unifiedrole.ErrUnknownRole
}
role, err := unifiedrole.GetRole(unifiedrole.RoleFilterIDs(roleID))
if err != nil {
s.logger.Debug().Err(err).Interface("role", invite.GetRoles()[0]).Msg("unable to convert requested role")
return libregraph.Permission{}, err
}
allowedResourceActions := unifiedrole.GetAllowedResourceActions(role, condition)
if len(allowedResourceActions) == 0 && role.GetId() != unifiedrole.UnifiedRoleDeniedID {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "role not applicable to this resource")
}
unifiedRolePermissions = append(unifiedRolePermissions, conversions.ToPointerSlice(role.GetRolePermissions())...)
}
driveRecipient := invite.GetRecipients()[0]
objectID := driveRecipient.GetObjectId()
cs3ResourcePermissions := unifiedrole.PermissionsToCS3ResourcePermissions(unifiedRolePermissions)
permission := &libregraph.Permission{}
availableRoles := unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(s.config.UnifiedRoles.AvailableRoles...))
if role := unifiedrole.CS3ResourcePermissionsToRole(availableRoles, cs3ResourcePermissions, condition, false); role != nil {
permission.Roles = []string{role.GetId()}
}
if len(permission.GetRoles()) == 0 {
permission.LibreGraphPermissionsActions = unifiedrole.CS3ResourcePermissionsToLibregraphActions(cs3ResourcePermissions)
}
var shareid string
var expiration *types.Timestamp
var cTime *types.Timestamp
switch driveRecipient.GetLibreGraphRecipientType() {
case "group":
group, err := s.identityCache.GetGroup(ctx, objectID)
if err != nil {
s.logger.Debug().Err(err).Interface("groupId", objectID).Msg("failed group lookup")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
Group: &libregraph.Identity{
DisplayName: group.GetDisplayName(),
Id: conversions.ToPointer(group.GetId()),
},
}
createShareRequest := createShareRequestToGroup(group, statResponse.GetInfo(), cs3ResourcePermissions)
if invite.ExpirationDateTime != nil {
createShareRequest.GetGrant().Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
}
createShareResponse, err := gatewayClient.CreateShare(ctx, createShareRequest)
if err := errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
s.logger.Debug().Err(err).Msg("share creation failed")
return libregraph.Permission{}, err
}
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
default:
user, err := s.identityCache.GetCS3User(ctx, tenantId, objectID)
if errors.Is(err, identity.ErrNotFound) && s.config.IncludeOCMSharees {
user, err = s.identityCache.GetAcceptedCS3User(ctx, objectID)
if err == nil && IsSpaceRoot(statResponse.GetInfo().GetId()) {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "federated user can not become a space member")
}
}
if err != nil {
s.logger.Debug().Err(err).Interface("userId", objectID).Msg("failed user lookup")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
User: &libregraph.Identity{
DisplayName: user.GetDisplayName(),
Id: conversions.ToPointer(user.GetId().GetOpaqueId()),
LibreGraphUserType: conversions.ToPointer(identity.CS3UserTypeToGraph(user.GetId().GetType())),
},
}
if user.GetId().GetType() == userpb.UserType_USER_TYPE_FEDERATED {
providerInfoResp, err := gatewayClient.GetInfoByDomain(ctx, &ocmprovider.GetInfoByDomainRequest{
Domain: user.GetId().GetIdp(),
})
if err = errorcode.FromCS3Status(providerInfoResp.GetStatus(), err); err != nil {
s.logger.Error().Err(err).Msg("getting provider info failed")
return libregraph.Permission{}, err
}
createShareRequest := createShareRequestToFederatedUser(user, statResponse.GetInfo().GetId(), providerInfoResp.ProviderInfo, cs3ResourcePermissions)
if invite.ExpirationDateTime != nil {
createShareRequest.Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
}
createShareResponse, err := gatewayClient.CreateOCMShare(ctx, createShareRequest)
if err = errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
s.logger.Error().Err(err).Msg("share creation failed")
return libregraph.Permission{}, err
}
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
} else {
createShareRequest := createShareRequestToUser(user, statResponse.GetInfo(), cs3ResourcePermissions)
if invite.ExpirationDateTime != nil {
createShareRequest.GetGrant().Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
}
createShareResponse, err := gatewayClient.CreateShare(ctx, createShareRequest)
if err = errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
s.logger.Error().Err(err).Msg("share creation failed")
return libregraph.Permission{}, err
}
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
}
}
if shareid != "" {
permission.Id = conversions.ToPointer(shareid)
}
if expiration != nil {
permission.SetExpirationDateTime(utils.TSToTime(expiration))
}
// set cTime
if cTime != nil {
permission.SetCreatedDateTime(cs3TimestampToTime(cTime))
}
if user, ok := revactx.ContextGetUser(ctx); ok {
userIdentity, err := userIdToIdentity(ctx, s.identityCache, tenantId, user.GetId().GetOpaqueId())
if err != nil {
s.logger.Error().Err(err).Msg("identity lookup failed")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
permission.SetInvitation(libregraph.SharingInvitation{
InvitedBy: &libregraph.IdentitySet{
User: &userIdentity,
},
})
}
return *permission, nil
}
func createShareRequestToGroup(group libregraph.Group, info *storageprovider.ResourceInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest {
return &collaboration.CreateShareRequest{
ResourceInfo: info,
Grant: &collaboration.ShareGrant{
Grantee: &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_GROUP,
Id: &storageprovider.Grantee_GroupId{GroupId: &grouppb.GroupId{
OpaqueId: group.GetId(),
}},
},
Permissions: &collaboration.SharePermissions{
Permissions: cs3ResourcePermissions,
},
},
}
}
func createShareRequestToUser(user *userpb.User, info *storageprovider.ResourceInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest {
return &collaboration.CreateShareRequest{
ResourceInfo: info,
Grant: &collaboration.ShareGrant{
Grantee: &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
Id: &storageprovider.Grantee_UserId{
UserId: user.GetId(),
},
},
Permissions: &collaboration.SharePermissions{
Permissions: cs3ResourcePermissions,
},
},
}
}
func createShareRequestToFederatedUser(user *userpb.User, resourceId *storageprovider.ResourceId, providerInfo *ocmprovider.ProviderInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *ocm.CreateOCMShareRequest {
return &ocm.CreateOCMShareRequest{
ResourceId: resourceId,
Grantee: &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
Id: &storageprovider.Grantee_UserId{
UserId: user.GetId(),
},
},
RecipientMeshProvider: providerInfo,
AccessMethods: []*ocm.AccessMethod{
{
Term: &ocm.AccessMethod_WebdavOptions{
WebdavOptions: &ocm.WebDAVAccessMethod{
Permissions: cs3ResourcePermissions,
},
},
},
},
}
}
// SpaceRootInvite handles invitation request on project spaces
func (s DriveItemPermissionsService) SpaceRootInvite(ctx context.Context, driveID *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.Invite(ctx, rootResourceID, invite)
}
// ListPermissions lists the permissions of a driveItem
func (s DriveItemPermissionsService) ListPermissions(ctx context.Context, itemID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error) {
collectionOfPermissions := libregraph.CollectionOfPermissionsWithAllowedValues{}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return collectionOfPermissions, err
}
statResponse, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: itemID}})
if err := errorcode.FromStat(statResponse, err); err != nil {
s.logger.Warn().Err(err).Interface("stat.res", statResponse).Msg("stat failed")
return collectionOfPermissions, err
}
var condition string
if condition, err = roleConditionForResourceType(statResponse.GetInfo()); err != nil {
return collectionOfPermissions, err
}
permissionSet := statResponse.GetInfo().GetPermissionSet()
allowedActions := unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissionSet)
collectionOfPermissions = libregraph.CollectionOfPermissionsWithAllowedValues{}
if len(queryOptions.SelectedAttrs) == 0 || slices.Contains(queryOptions.SelectedAttrs, "@libre.graph.permissions.actions.allowedValues") {
collectionOfPermissions.LibreGraphPermissionsActionsAllowedValues = allowedActions
}
if len(queryOptions.SelectedAttrs) == 0 || slices.Contains(queryOptions.SelectedAttrs, "@libre.graph.permissions.roles.allowedValues") {
collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues = conversions.ToValueSlice(
unifiedrole.GetRolesByPermissions(
unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(s.config.UnifiedRoles.AvailableRoles...)),
allowedActions,
condition,
queryOptions.FilterFederatedRoles,
false,
),
)
}
for i, definition := range collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues {
// the openapi spec defines that the rolePermissions should not be part of the response
definition.RolePermissions = nil
collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues[i] = definition
}
if len(queryOptions.SelectedAttrs) > 0 {
// no need to fetch shares, we are only interested allowedActions and/or allowedRoles
return collectionOfPermissions, nil
}
driveItems := make(driveItemsByResourceID, 1)
// we can use the statResponse to build the drive item before fetching the shares
item, err := cs3ResourceToDriveItem(s.logger, s.publicBaseURL, statResponse.GetInfo())
if err != nil {
return collectionOfPermissions, err
}
driveItems[storagespace.FormatResourceID(statResponse.GetInfo().GetId())] = *item
var permissionsCount int
if IsSpaceRoot(statResponse.GetInfo().GetId()) {
driveItems, err = s.listSpaceRootUserShares(ctx, []*collaboration.Filter{
share.ResourceIDFilter(itemID),
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
} else {
// "normal" driveItem, populate user permissions via share providers
driveItems, err = s.listUserShares(ctx, []*collaboration.Filter{
share.ResourceIDFilter(itemID),
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
if s.config.IncludeOCMSharees {
driveItems, err = s.listOCMShares(ctx, []*ocm.ListOCMSharesRequest_Filter{
{
Type: ocm.ListOCMSharesRequest_Filter_TYPE_RESOURCE_ID,
Term: &ocm.ListOCMSharesRequest_Filter_ResourceId{ResourceId: itemID},
},
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
}
}
if !queryOptions.NoLinkPermissions {
// finally get public shares, which are possible for spaceroots and "normal" resources
driveItems, err = s.listPublicShares(ctx, []*link.ListPublicSharesRequest_Filter{
publicshare.ResourceIDFilter(itemID),
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
}
for _, driveItem := range driveItems {
permissionsCount += len(driveItem.Permissions)
if !queryOptions.NoValues {
collectionOfPermissions.Value = append(collectionOfPermissions.Value, driveItem.Permissions...)
}
}
if queryOptions.Count {
collectionOfPermissions.SetOdataCount(int32(permissionsCount))
}
return collectionOfPermissions, nil
}
// ListSpaceRootPermissions handles ListPermissions request on project spaces
func (s DriveItemPermissionsService) ListSpaceRootPermissions(ctx context.Context, driveID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error) {
collectionOfPermissions := libregraph.CollectionOfPermissionsWithAllowedValues{}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return collectionOfPermissions, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return collectionOfPermissions, errorcode.FromUtilsStatusCodeError(err)
}
isSupportedSpaceType := slices.Contains([]string{_spaceTypeProject, _spaceTypePersonal, _spaceTypeVirtual}, space.GetSpaceType())
if !isSupportedSpaceType {
return collectionOfPermissions, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.ListPermissions(ctx, rootResourceID, queryOptions) // federated roles are not supported for spaces
}
// DeletePermission deletes a permission from a drive item
func (s DriveItemPermissionsService) DeletePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string) error {
var permissionType permissionType
sharedResourceID, err := s.getLinkPermissionResourceID(ctx, permissionID)
switch {
// Check if the ID is referring to a public share
case err == nil:
permissionType = Public
// If the item id is referring to a space root and this is not a public share
// we have to deal with space permissions
case IsSpaceRoot(itemID):
permissionType = Space
sharedResourceID = itemID
err = nil
// If this is neither a public share nor a space permission, check if this is a
// user share
default:
sharedResourceID, err = s.getUserPermissionResourceID(ctx, permissionID)
if err == nil {
permissionType = User
}
}
if sharedResourceID == nil && s.config.IncludeOCMSharees {
sharedResourceID, err = s.getOCMPermissionResourceID(ctx, permissionID)
if err == nil {
permissionType = OCM
}
}
switch {
case err != nil:
return err
case permissionType == Unknown:
return errorcode.New(errorcode.ItemNotFound, "permission not found")
case sharedResourceID == nil:
return errorcode.New(errorcode.ItemNotFound, "failed to resolve resource id for shared resource")
}
// The resourceID of the shared resource need to match the item ID from the Request Path
// otherwise this is an invalid Request.
if !utils.ResourceIDEqual(sharedResourceID, itemID) {
s.logger.Debug().Msg("resourceID of shared does not match itemID")
return errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
}
switch permissionType {
case User, Space:
return s.removeUserShare(ctx, permissionID)
case Public:
return s.removePublicShare(ctx, permissionID)
case OCM:
return s.removeOCMPermission(ctx, permissionID)
default:
// This should never be reached
return errorcode.New(errorcode.GeneralException, "failed to delete permission")
}
}
// DeleteSpaceRootPermission deletes a permission on the root item of a project space
func (s DriveItemPermissionsService) DeleteSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string) error {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.DeletePermission(ctx, rootResourceID, permissionID)
}
// UpdatePermission updates a permission on a drive item
func (s DriveItemPermissionsService) UpdatePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error) {
oldPermission, sharedResourceID, err := s.getPermissionByID(ctx, permissionID, itemID)
// try to get the permission from ocm if the permission was not found first place
if err != nil && s.config.IncludeOCMSharees {
oldPermission, sharedResourceID, err = s.getOCMPermissionByID(ctx, permissionID, itemID)
}
// if we still can't find the permission, return an error
if err != nil {
return libregraph.Permission{}, err
}
// The resourceID of the shared resource need to match the item ID from the Request Path
// otherwise this is an invalid Request.
if !utils.ResourceIDEqual(sharedResourceID, itemID) {
s.logger.Debug().Msg("resourceID of shared does not match itemID")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
}
// This is a public link
if _, ok := oldPermission.GetLinkOk(); ok {
updatedPermission, err := s.updatePublicLinkPermission(ctx, permissionID, itemID, &newPermission)
if err != nil {
return libregraph.Permission{}, err
}
return *updatedPermission, nil
}
// This is a user share
updatedPermission, err := s.updateUserShare(ctx, permissionID, sharedResourceID, &newPermission)
if err == nil && updatedPermission != nil {
return *updatedPermission, nil
}
// This is an ocm share
if s.config.IncludeOCMSharees {
updatePermission, err := s.updateOCMPermission(ctx, permissionID, itemID, &newPermission)
if err == nil {
return *updatePermission, nil
}
}
return libregraph.Permission{}, err
}
// UpdateSpaceRootPermission updates a permission on the root item of a project space
func (s DriveItemPermissionsService) UpdateSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.UpdatePermission(ctx, rootResourceID, permissionID, newPermission)
}
// DriveItemPermissionsApi is the api that registers the http endpoints which expose needed operation to the graph api.
// the business logic is delegated to the permissions service and further down to the cs3 client.
type DriveItemPermissionsApi struct {
logger log.Logger
driveItemPermissionsService DriveItemPermissionsProvider
config *config.Config
}
// NewDriveItemPermissionsApi creates a new DriveItemPermissionsApi
func NewDriveItemPermissionsApi(driveItemPermissionService DriveItemPermissionsProvider, logger log.Logger, c *config.Config) (DriveItemPermissionsApi, error) {
return DriveItemPermissionsApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
driveItemPermissionsService: driveItemPermissionService,
config: c,
}, nil
}
// Invite handles DriveItemInvite requests
func (api DriveItemPermissionsApi) Invite(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, invalidIdMsg)
return
}
driveItemInvite := &libregraph.DriveItemInvite{}
if err = StrictJSONUnmarshal(r.Body, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := validate.ContextWithAllowedRoleIDs(r.Context(), api.config.UnifiedRoles.AvailableRoles)
if err = validate.StructCtx(ctx, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
permission, err := api.driveItemPermissionsService.Invite(ctx, itemID, *driveItemInvite)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: []any{permission}})
}
// SpaceRootInvite handles DriveItemInvite requests on a space root
func (api DriveItemPermissionsApi) SpaceRootInvite(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
driveItemInvite := &libregraph.DriveItemInvite{}
if err = StrictJSONUnmarshal(r.Body, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := validate.ContextWithAllowedRoleIDs(r.Context(), api.config.UnifiedRoles.AvailableRoles)
if err = validate.StructCtx(ctx, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
permission, err := api.driveItemPermissionsService.SpaceRootInvite(ctx, &driveID, *driveItemInvite)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: []any{permission}})
}
// ListPermissions handles ListPermissions requests
func (api DriveItemPermissionsApi) ListPermissions(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.RenderError(w, r, err)
return
}
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
var queryOptions ListPermissionsQueryOptions
queryOptions, err = api.getListPermissionsQueryOptions(odataReq)
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest query options")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
permissions, err := api.driveItemPermissionsService.ListPermissions(ctx, itemID, queryOptions)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
loc := r.Header.Get(l10n.HeaderAcceptLanguage)
w.Header().Add("Content-Language", loc)
if loc != "" && loc != "en" {
err := l10n_pkg.TranslateEntity(loc, "en", permissions,
l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
l10n.TranslateField("Description"),
l10n.TranslateField("DisplayName"),
),
)
if err != nil {
api.logger.Error().Err(err).Msg("tranlation error")
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, permissions)
}
// ListSpaceRootPermissions handles ListPermissions requests on a space root
func (api DriveItemPermissionsApi) ListSpaceRootPermissions(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
var queryOptions ListPermissionsQueryOptions
queryOptions, err = api.getListPermissionsQueryOptions(odataReq)
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest query options")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
permissions, err := api.driveItemPermissionsService.ListSpaceRootPermissions(ctx, &driveID, queryOptions)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
loc := r.Header.Get(l10n.HeaderAcceptLanguage)
w.Header().Add("Content-Language", loc)
if loc != "" && loc != "en" {
err := l10n_pkg.TranslateEntity(loc, "en", permissions,
l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
l10n.TranslateField("Description"),
l10n.TranslateField("DisplayName"),
),
)
if err != nil {
api.logger.Error().Err(err).Msg("tranlation error")
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, permissions)
}
func (api DriveItemPermissionsApi) getListPermissionsQueryOptions(odataReq *godata.GoDataRequest) (ListPermissionsQueryOptions, error) {
queryOptions := ListPermissionsQueryOptions{}
if odataReq.Query.Filter != nil {
switch odataReq.Query.Filter.RawValue {
case federatedRolesODataFilter:
queryOptions.FilterFederatedRoles = true
case noLinksODataFilter:
queryOptions.NoLinkPermissions = true
default:
return ListPermissionsQueryOptions{}, errorcode.New(errorcode.InvalidRequest, "invalid filter value")
}
}
selectAttrs, err := odata.GetSelectValues(odataReq.Query)
if err != nil {
return ListPermissionsQueryOptions{}, err
}
queryOptions.SelectedAttrs = selectAttrs
if odataReq.Query.Count != nil {
queryOptions.Count = bool(*odataReq.Query.Count)
}
if odataReq.Query.Top != nil {
top := int(*odataReq.Query.Top)
switch {
case top != 0:
return ListPermissionsQueryOptions{}, err
case top == 0 && !queryOptions.Count:
return ListPermissionsQueryOptions{}, err
default:
queryOptions.NoValues = true
}
}
return queryOptions, nil
}
// DeletePermission handles DeletePermission requests
func (api DriveItemPermissionsApi) DeletePermission(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.RenderError(w, r, err)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
ctx := r.Context()
err = api.driveItemPermissionsService.DeletePermission(ctx, itemID, permissionID)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteSpaceRootPermission handles DeletePermission requests on a space root
func (api DriveItemPermissionsApi) DeleteSpaceRootPermission(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
ctx := r.Context()
err = api.driveItemPermissionsService.DeleteSpaceRootPermission(ctx, &driveID, permissionID)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// UpdatePermission handles UpdatePermission requests
func (api DriveItemPermissionsApi) UpdatePermission(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.RenderError(w, r, err)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
permission := libregraph.Permission{}
if err = StrictJSONUnmarshal(r.Body, &permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
if err = validate.StructCtx(ctx, permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
updatedPermission, err := api.driveItemPermissionsService.UpdatePermission(ctx, itemID, permissionID, permission)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &updatedPermission)
}
// UpdateSpaceRootPermission handles UpdatePermission requests on a space root
func (api DriveItemPermissionsApi) UpdateSpaceRootPermission(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
permission := libregraph.Permission{}
if err = StrictJSONUnmarshal(r.Body, &permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
if err = validate.StructCtx(ctx, permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
updatedPermission, err := api.driveItemPermissionsService.UpdateSpaceRootPermission(ctx, &driveID, permissionID, permission)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &updatedPermission)
}
@@ -0,0 +1,426 @@
package svc
import (
"context"
"net/http"
"net/url"
"strconv"
"time"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/linktype"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
func (s DriveItemPermissionsService) CreateLink(ctx context.Context, driveItemID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
s.logger.Error().Err(err).Msg("could not select next gateway client")
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
statResp, err := gatewayClient.Stat(
ctx,
&storageprovider.StatRequest{
Ref: &storageprovider.Reference{
ResourceId: driveItemID,
Path: ".",
},
})
if err != nil {
s.logger.Error().Err(err).Msg("transport error, could not stat resource")
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
if code := statResp.GetStatus().GetCode(); code != rpc.Code_CODE_OK {
s.logger.Debug().Interface("itemID", driveItemID).Msg(statResp.GetStatus().GetMessage())
return libregraph.Permission{}, errorcode.New(cs3StatusToErrCode(code), statResp.GetStatus().GetMessage())
}
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(createLink, statResp.GetInfo().GetType())
if err != nil {
s.logger.Debug().Interface("createLink", createLink).Msg(err.Error())
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid link type")
}
if createLink.GetType() == libregraph.INTERNAL && len(createLink.GetPassword()) > 0 {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "password is redundant for the internal link")
}
req := link.CreatePublicShareRequest{
ResourceInfo: statResp.GetInfo(),
Grant: &link.Grant{
Permissions: &link.PublicSharePermissions{
Permissions: permissions,
},
Password: createLink.GetPassword(),
},
}
expirationDate, isSet := createLink.GetExpirationDateTimeOk()
if isSet {
expireTime := parseAndFillUpTime(expirationDate)
if expireTime == nil {
s.logger.Debug().Interface("createLink", createLink).Send()
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid expiration date")
}
req.GetGrant().Expiration = expireTime
}
// set displayname and password protected as arbitrary metadata
req.ResourceInfo.ArbitraryMetadata = &storageprovider.ArbitraryMetadata{
Metadata: map[string]string{
"name": createLink.GetDisplayName(),
"quicklink": strconv.FormatBool(createLink.GetLibreGraphQuickLink()),
},
}
createResp, err := gatewayClient.CreatePublicShare(ctx, &req)
if err != nil {
s.logger.Error().Err(err).Msg("transport error, could not create link")
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
if statusCode := createResp.GetStatus().GetCode(); statusCode != rpc.Code_CODE_OK {
return libregraph.Permission{}, errorcode.New(cs3StatusToErrCode(statusCode), createResp.Status.Message)
}
link := createResp.GetShare()
perm, err := s.libreGraphPermissionFromCS3PublicShare(link)
if err != nil {
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
return *perm, nil
}
func (s DriveItemPermissionsService) CreateSpaceRootLink(ctx context.Context, driveID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.CreateLink(ctx, rootResourceID, createLink)
}
func (s DriveItemPermissionsService) SetPublicLinkPassword(ctx context.Context, driveItemId *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error) {
publicShare, err := s.getCS3PublicShareByID(ctx, permissionID)
if err != nil {
return libregraph.Permission{}, err
}
// The resourceID of the shared resource need to match the item ID from the Request Path
// otherwise this is an invalid Request.
if !utils.ResourceIDEqual(publicShare.GetResourceId(), driveItemId) {
s.logger.Debug().Msg("resourceID of shared does not match itemID")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
}
permission, err := s.updatePublicLinkPassword(ctx, permissionID, password)
if err != nil {
return libregraph.Permission{}, err
}
return *permission, nil
}
func (s DriveItemPermissionsService) SetPublicLinkPasswordOnSpaceRoot(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.SetPublicLinkPassword(ctx, rootResourceID, permissionID, password)
}
// CreateLink creates a public link on the cs3 api
func (api DriveItemPermissionsApi) CreateLink(w http.ResponseWriter, r *http.Request) {
logger := api.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling create link")
_, driveItemID, err := GetDriveAndItemIDParam(r, &logger)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
var createLink libregraph.DriveItemCreateLink
if err = StrictJSONUnmarshal(r.Body, &createLink); err != nil {
logger.Error().Err(err).Interface("body", r.Body).Msg("could not create link: invalid body schema definition")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
return
}
perm, err := api.driveItemPermissionsService.CreateLink(r.Context(), driveItemID, createLink)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, perm)
}
func (api DriveItemPermissionsApi) CreateSpaceRootLink(w http.ResponseWriter, r *http.Request) {
logger := api.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling create link")
driveID, err := parseIDParam(r, "driveID")
if err != nil {
msg := "could not parse driveID"
api.logger.Debug().Err(err).Msg(msg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, msg)
return
}
var createLink libregraph.DriveItemCreateLink
if err = StrictJSONUnmarshal(r.Body, &createLink); err != nil {
logger.Error().Err(err).Interface("body", r.Body).Msg("could not create link: invalid body schema definition")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
return
}
perm, err := api.driveItemPermissionsService.CreateSpaceRootLink(r.Context(), &driveID, createLink)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, perm)
}
// SetLinkPassword sets public link password on the cs3 api
func (api DriveItemPermissionsApi) SetLinkPassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
password := &libregraph.SharingLinkPassword{}
if err = StrictJSONUnmarshal(r.Body, password); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
newPermission, err := api.driveItemPermissionsService.SetPublicLinkPassword(ctx, itemID, permissionID, password.GetPassword())
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, newPermission)
}
func (api DriveItemPermissionsApi) SetSpaceRootLinkPassword(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
msg := "could not parse driveID"
api.logger.Debug().Err(err).Msg(msg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, msg)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
password := &libregraph.SharingLinkPassword{}
if err = StrictJSONUnmarshal(r.Body, password); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
newPermission, err := api.driveItemPermissionsService.SetPublicLinkPasswordOnSpaceRoot(ctx, &driveID, permissionID, password.GetPassword())
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, newPermission)
}
func (s DriveItemPermissionsService) updatePublicLinkPermission(ctx context.Context, permissionID string, itemID *storageprovider.ResourceId, newPermission *libregraph.Permission) (perm *libregraph.Permission, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
s.logger.Error().Err(err).Msg("could not select next gateway client")
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
statResp, err := gatewayClient.Stat(
ctx,
&storageprovider.StatRequest{
Ref: &storageprovider.Reference{
ResourceId: itemID,
Path: ".",
},
})
if err := errorcode.FromCS3Status(statResp.GetStatus(), err); err != nil {
return nil, err
}
if newPermission.HasExpirationDateTime() {
expirationDate := newPermission.GetExpirationDateTime()
update := &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_EXPIRATION,
Grant: &link.Grant{Expiration: parseAndFillUpTime(&expirationDate)},
}
perm, err = s.updatePublicLink(ctx, permissionID, update)
if err != nil {
return nil, err
}
}
if newPermission.HasLink() && newPermission.Link.HasLibreGraphDisplayName() {
changedLink := newPermission.GetLink()
update := &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_DISPLAYNAME,
DisplayName: changedLink.GetLibreGraphDisplayName(),
}
perm, err = s.updatePublicLink(ctx, permissionID, update)
if err != nil {
return nil, err
}
}
if newPermission.HasLink() && newPermission.Link.HasType() {
changedLink := newPermission.Link.GetType()
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(
libregraph.DriveItemCreateLink{
Type: &changedLink,
},
statResp.GetInfo().GetType(),
)
if err != nil {
return nil, err
}
update := &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_PERMISSIONS,
Grant: &link.Grant{
Permissions: &link.PublicSharePermissions{Permissions: permissions},
},
}
perm, err = s.updatePublicLink(ctx, permissionID, update)
if err != nil {
return nil, err
}
// reset the password for the internal link
if changedLink == libregraph.INTERNAL {
perm, err = s.updatePublicLinkPassword(ctx, permissionID, "")
if err != nil {
return nil, err
}
}
}
return perm, err
}
func (s DriveItemPermissionsService) updatePublicLinkPassword(ctx context.Context, permissionID string, password string) (*libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
changeLinkRes, err := gatewayClient.UpdatePublicShare(ctx, &link.UpdatePublicShareRequest{
Update: &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_PASSWORD,
Grant: &link.Grant{
Password: password,
},
},
Ref: &link.PublicShareReference{
Spec: &link.PublicShareReference_Id{
Id: &link.PublicShareId{
OpaqueId: permissionID,
},
},
},
})
if err := errorcode.FromCS3Status(changeLinkRes.GetStatus(), err); err != nil {
return nil, err
}
permission, err := s.libreGraphPermissionFromCS3PublicShare(changeLinkRes.GetShare())
if err != nil {
return nil, err
}
return permission, nil
}
func (s DriveItemPermissionsService) updatePublicLink(ctx context.Context, permissionID string, update *link.UpdatePublicShareRequest_Update) (*libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
changeLinkRes, err := gatewayClient.UpdatePublicShare(ctx, &link.UpdatePublicShareRequest{
Update: update,
Ref: &link.PublicShareReference{
Spec: &link.PublicShareReference_Id{
Id: &link.PublicShareId{
OpaqueId: permissionID,
},
},
},
})
if err := errorcode.FromCS3Status(changeLinkRes.GetStatus(), err); err != nil {
return nil, err
}
permission, err := s.libreGraphPermissionFromCS3PublicShare(changeLinkRes.GetShare())
if err != nil {
return nil, err
}
return permission, nil
}
func parseAndFillUpTime(t *time.Time) *types.Timestamp {
if t == nil || t.IsZero() {
return nil
}
// the link needs to be valid for the whole day
tLink := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
tLink = tLink.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
final := tLink.UnixNano()
return &types.Timestamp{
Seconds: uint64(final / 1000000000),
Nanos: uint32(final % 1000000000),
}
}
@@ -0,0 +1,258 @@
package svc_test
import (
"context"
"errors"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity/cache"
"github.com/qsfera/server/services/graph/pkg/linktype"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
)
var _ = Describe("createLinkTests", func() {
var (
svc service.DriveItemPermissionsService
driveItemId *provider.ResourceId
ctx context.Context
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector *mocks.Selectable[gateway.GatewayAPIClient]
currentUser = &userpb.User{
Id: &userpb.UserId{
OpaqueId: "user",
},
}
)
const (
ViewerLinkString = "Viewer Link"
)
BeforeEach(func() {
var err error
logger := log.NewLogger()
gatewayClient = cs3mocks.NewGatewayAPIClient(GinkgoT())
gatewaySelector = mocks.NewSelectable[gateway.GatewayAPIClient](GinkgoT())
gatewaySelector.On("Next").Return(gatewayClient, nil)
cache := cache.NewIdentityCache(cache.IdentityCacheWithGatewaySelector(gatewaySelector))
cfg := defaults.FullDefaultConfig()
svc, err = service.NewDriveItemPermissionsService(logger, gatewaySelector, cache, cfg)
Expect(err).ToNot(HaveOccurred())
driveItemId = &provider.ResourceId{
StorageId: "1",
SpaceId: "2",
OpaqueId: "3",
}
ctx = revactx.ContextSetUser(context.Background(), currentUser)
})
Describe("CreateLink", func() {
var (
driveItemCreateLink libregraph.DriveItemCreateLink
statResponse *provider.StatResponse
createLinkResponse *link.CreatePublicShareResponse
)
BeforeEach(func() {
driveItemCreateLink = libregraph.DriveItemCreateLink{
Type: nil,
ExpirationDateTime: nil,
Password: nil,
DisplayName: nil,
LibreGraphQuickLink: nil,
}
statResponse = &provider.StatResponse{
Status: status.NewOK(ctx),
Info: &provider.ResourceInfo{Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER},
}
createLinkResponse = &link.CreatePublicShareResponse{
Status: status.NewOK(ctx),
}
linkType, err := libregraph.NewSharingLinkTypeFromValue("view")
Expect(err).ToNot(HaveOccurred())
driveItemCreateLink.Type = linkType
driveItemCreateLink.ExpirationDateTime = libregraph.PtrTime(time.Now().Add(time.Hour))
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
Expect(err).ToNot(HaveOccurred())
createLinkResponse.Share = &link.PublicShare{
Id: &link.PublicShareId{OpaqueId: "123"},
Expiration: utils.TimeToTS(*driveItemCreateLink.ExpirationDateTime),
PasswordProtected: false,
DisplayName: ViewerLinkString,
Token: "SomeGOODCoffee",
Permissions: &link.PublicSharePermissions{Permissions: permissions},
}
})
// Public Shares / "links" in graph terms
It("creates a public link as expected (happy path)", func() {
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).ToNot(HaveOccurred())
Expect(perm.GetId()).To(Equal("123"))
Expect(perm.GetExpirationDateTime().Unix()).To(Equal(driveItemCreateLink.ExpirationDateTime.Unix()))
Expect(perm.GetHasPassword()).To(Equal(false))
Expect(perm.GetLink().LibreGraphDisplayName).To(Equal(libregraph.PtrString(ViewerLinkString)))
link := perm.GetLink()
respLinkType := link.GetType()
expected, err := libregraph.NewSharingLinkTypeFromValue("view")
Expect(err).ToNot(HaveOccurred())
Expect(&respLinkType).To(Equal(expected))
})
It("handles a failing CreateLink", func() {
statResponse.Info = &provider.ResourceInfo{Type: provider.ResourceType_RESOURCE_TYPE_FILE}
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
linkType, err := libregraph.NewSharingLinkTypeFromValue("edit")
Expect(err).ToNot(HaveOccurred())
driveItemCreateLink.Type = linkType
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
Expect(err).ToNot(HaveOccurred())
createLinkResponse.Status = status.NewInternal(ctx, "transport error")
createLinkResponse.Share = &link.PublicShare{
Id: &link.PublicShareId{OpaqueId: "123"},
Permissions: &link.PublicSharePermissions{Permissions: permissions},
}
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).To(MatchError(errorcode.New(errorcode.GeneralException, "transport error")))
Expect(perm).To(BeZero())
})
It("fails when the stat returns access denied", func() {
err := errors.New("no permission to stat the file")
statResponse.Status = status.NewPermissionDenied(ctx, err, err.Error())
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).To(MatchError(errorcode.New(errorcode.AccessDenied, "no permission to stat the file")))
Expect(perm).To(BeZero())
})
It("fails when the stat returns resource is locked", func() {
err := errors.New("the resource is locked")
statResponse.Status = status.NewLocked(ctx, err.Error())
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).To(MatchError(errorcode.New(errorcode.ItemIsLocked, "the resource is locked")))
Expect(perm).To(BeZero())
})
It("succeeds when the link type mapping is not successful", func() {
// we need to send a valid link type
linkType, err := libregraph.NewSharingLinkTypeFromValue("edit")
Expect(err).ToNot(HaveOccurred())
driveItemCreateLink.Type = linkType
permissions := &provider.ResourcePermissions{
CreateContainer: true,
InitiateFileUpload: true,
Move: true,
}
// return different permissions which do not match a link type
createLinkResponse.Share = &link.PublicShare{
Id: &link.PublicShareId{OpaqueId: "123"},
Expiration: utils.TimeToTS(*driveItemCreateLink.ExpirationDateTime),
PasswordProtected: false,
DisplayName: ViewerLinkString,
Token: "SomeGOODCoffee",
Permissions: &link.PublicSharePermissions{Permissions: permissions},
}
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).ToNot(HaveOccurred())
Expect(perm.GetId()).To(Equal("123"))
Expect(perm.GetExpirationDateTime().Unix()).To(Equal(driveItemCreateLink.ExpirationDateTime.Unix()))
Expect(perm.GetHasPassword()).To(Equal(false))
Expect(perm.GetLink().LibreGraphDisplayName).To(Equal(libregraph.PtrString(ViewerLinkString)))
respLink := perm.GetLink()
// some conversion gymnastics
respLinkType := respLink.GetType()
Expect(err).ToNot(HaveOccurred())
mockLink := libregraph.SharingLink{}
lt, _ := linktype.SharingLinkTypeFromCS3Permissions(&link.PublicSharePermissions{Permissions: permissions})
mockLink.Type = lt
expectedType := mockLink.GetType()
Expect(&respLinkType).To(Equal(&expectedType))
libreGraphActions := perm.LibreGraphPermissionsActions
Expect(libreGraphActions[0]).To(Equal("libre.graph/driveItem/children/create"))
Expect(libreGraphActions[1]).To(Equal("libre.graph/driveItem/upload/create"))
Expect(libreGraphActions[2]).To(Equal("libre.graph/driveItem/path/update"))
})
})
Describe("SetLinPassword", func() {
var (
updatePublicShareMockResponse link.UpdatePublicShareResponse
getPublicShareResponse link.GetPublicShareResponse
)
const TestLinkName = "Test Link"
BeforeEach(func() {
updatePublicShareMockResponse = link.UpdatePublicShareResponse{
Status: status.NewOK(ctx),
Share: &link.PublicShare{DisplayName: TestLinkName},
}
getPublicShareResponse = link.GetPublicShareResponse{
Status: status.NewOK(ctx),
Share: &link.PublicShare{
Id: &link.PublicShareId{
OpaqueId: "permissionid",
},
ResourceId: driveItemId,
Permissions: &link.PublicSharePermissions{
Permissions: linktype.NewViewLinkPermissionSet().GetPermissions(),
},
Token: "token",
},
}
})
It("updates the password on a public share", func() {
gatewayClient.On("GetPublicShare", mock.Anything, mock.Anything).Return(&getPublicShareResponse, nil)
updatePublicShareMockResponse.Share.Permissions = &link.PublicSharePermissions{
Permissions: linktype.NewViewLinkPermissionSet().Permissions,
}
updatePublicShareMockResponse.Share.PasswordProtected = true
gatewayClient.On("UpdatePublicShare",
mock.Anything,
mock.MatchedBy(func(req *link.UpdatePublicShareRequest) bool {
return req.GetRef().GetId().GetOpaqueId() == "permissionid"
}),
).Return(&updatePublicShareMockResponse, nil)
perm, err := svc.SetPublicLinkPassword(context.Background(), driveItemId, "permissionid", "OC123!")
Expect(err).ToNot(HaveOccurred())
linkType := perm.Link.GetType()
Expect(string(linkType)).To(Equal("view"))
Expect(perm.GetHasPassword()).To(BeTrue())
})
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,575 @@
package svc
import (
"context"
"errors"
"net/http"
"path/filepath"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
const (
_fieldMaskPathState = "state"
_fieldMaskPathMountPoint = "mount_point"
_fieldMaskPathHidden = "hidden"
)
var (
// ErrNoUpdates is returned when no updates are provided
ErrNoUpdates = errors.New("no updates")
// ErrNoUpdater is returned when no updater is provided
ErrNoUpdater = errors.New("no updater")
// ErrAbsoluteNamePath is returned when the name is an absolute path
ErrAbsoluteNamePath = errors.New("name cannot be an absolute path")
// ErrCode errors
// ErrNotAShareJail is returned when the driveID does not belong to a share jail
ErrNotAShareJail = errorcode.New(errorcode.InvalidRequest, "id does not belong to a share jail")
// ErrInvalidDriveIDOrItemID is returned when the driveID or itemID is invalid
ErrInvalidDriveIDOrItemID = errorcode.New(errorcode.InvalidRequest, "invalid driveID or itemID")
// ErrInvalidRequestBody is returned when the request body is invalid
ErrInvalidRequestBody = errorcode.New(errorcode.InvalidRequest, "invalid request body")
// ErrUnmountShare is returned when unmounting a share fails
ErrUnmountShare = errorcode.New(errorcode.InvalidRequest, "unmounting share failed")
// ErrMountShare is returned when mounting a share fails
ErrMountShare = errorcode.New(errorcode.InvalidRequest, "mounting share failed")
// ErrUpdateShares is returned when updating shares fails
ErrUpdateShares = errorcode.New(errorcode.InvalidRequest, "failed to update share")
// ErrInvalidID is returned when the id is invalid
ErrInvalidID = errorcode.New(errorcode.InvalidRequest, "invalid id")
// ErrDriveItemConversion is returned when converting to drive items fails
ErrDriveItemConversion = errorcode.New(errorcode.InvalidRequest, "converting to drive items failed")
// ErrNoShares is returned when no shares are found
ErrNoShares = errorcode.New(errorcode.ItemNotFound, "no shares found")
// ErrAlreadyMounted is returned when all shares are already mounted
ErrAlreadyMounted = errorcode.New(errorcode.NameAlreadyExists, "shares already mounted")
// ErrAlreadyUnmounted is returned when all shares are already unmounted
ErrAlreadyUnmounted = errorcode.New(errorcode.NameAlreadyExists, "shares already unmounted")
)
type (
// UpdateShareClosure is a closure that injects required updates into the update request
UpdateShareClosure func(share *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest)
// DrivesDriveItemProvider is the interface that needs to be implemented by the individual space service
DrivesDriveItemProvider interface {
// MountShare mounts a share
MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error)
// MountOCMShare mounts an OCM share
MountOCMShare(ctx context.Context, resourceID *storageprovider.ResourceId /*, name string*/) ([]*ocm.ReceivedShare, error)
// UnmountShare unmounts a share
UnmountShare(ctx context.Context, shareID *collaboration.ShareId) error
// UpdateShares updates multiple shares
UpdateShares(ctx context.Context, shares []*collaboration.ReceivedShare, updater UpdateShareClosure) ([]*collaboration.ReceivedShare, error)
// GetShare returns the share
GetShare(ctx context.Context, shareID *collaboration.ShareId) (*collaboration.ReceivedShare, error)
// GetSharesForResource returns all shares for a given resourceID
GetSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId, filters []*collaboration.Filter) ([]*collaboration.ReceivedShare, error)
}
)
// DrivesDriveItemService contains the production business logic for everything that relates to drives
type DrivesDriveItemService struct {
logger log.Logger
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
// NewDrivesDriveItemService creates a new DrivesDriveItemService
func NewDrivesDriveItemService(logger log.Logger, gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) (DrivesDriveItemService, error) {
return DrivesDriveItemService{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemService").Logger()},
gatewaySelector: gatewaySelector,
}, nil
}
func (s DrivesDriveItemService) GetShare(ctx context.Context, shareID *collaboration.ShareId) (*collaboration.ReceivedShare, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
// Now, find out the resourceID of the shared resource
getReceivedShareResponse, err := gatewayClient.GetReceivedShare(ctx,
&collaboration.GetReceivedShareRequest{
Ref: &collaboration.ShareReference{
Spec: &collaboration.ShareReference_Id{
Id: shareID,
},
},
},
)
return getReceivedShareResponse.GetShare(), errorcode.FromCS3Status(getReceivedShareResponse.GetStatus(), err)
}
// GetSharesForResource returns all shares for a given resourceID
func (s DrivesDriveItemService) GetSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId, filters []*collaboration.Filter) ([]*collaboration.ReceivedShare, error) {
// Find all accepted shares for this resource
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
receivedSharesResponse, err := gatewayClient.ListReceivedShares(ctx, &collaboration.ListReceivedSharesRequest{
Filters: append([]*collaboration.Filter{
{
Type: collaboration.Filter_TYPE_RESOURCE_ID,
Term: &collaboration.Filter_ResourceId{
ResourceId: resourceID,
},
},
}, filters...),
})
switch {
case err != nil:
return nil, err
case len(receivedSharesResponse.GetShares()) == 0:
return nil, ErrNoShares
default:
return receivedSharesResponse.GetShares(), errorcode.FromCS3Status(receivedSharesResponse.GetStatus(), err)
}
}
// UpdateShares updates multiple shares;
// it could happen that some shares are updated and some are not,
// this will return a list of updated shares and a list of errors;
// there is no guarantee that all updates are successful
func (s DrivesDriveItemService) UpdateShares(ctx context.Context, shares []*collaboration.ReceivedShare, updater UpdateShareClosure) ([]*collaboration.ReceivedShare, error) {
errs := make([]error, 0, len(shares))
updatedShares := make([]*collaboration.ReceivedShare, 0, len(shares))
for _, share := range shares {
updatedShare, err := s.UpdateShare(
ctx,
share,
updater,
)
if err != nil {
errs = append(errs, err)
continue
}
updatedShares = append(updatedShares, updatedShare)
}
return updatedShares, errors.Join(errs...)
}
// UpdateShare updates a single share
func (s DrivesDriveItemService) UpdateShare(ctx context.Context, share *collaboration.ReceivedShare, updater UpdateShareClosure) (*collaboration.ReceivedShare, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
updateReceivedShareRequest := &collaboration.UpdateReceivedShareRequest{
Share: &collaboration.ReceivedShare{
Share: &collaboration.Share{
Id: share.GetShare().GetId(),
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{}},
}
switch updater {
case nil:
return nil, ErrNoUpdater
default:
updater(share, updateReceivedShareRequest)
}
if len(updateReceivedShareRequest.GetUpdateMask().GetPaths()) == 0 {
return nil, ErrNoUpdates
}
updateReceivedShareResponse, err := gatewayClient.UpdateReceivedShare(ctx, updateReceivedShareRequest)
return updateReceivedShareResponse.GetShare(), errorcode.FromCS3Status(updateReceivedShareResponse.GetStatus(), err)
}
// UnmountShare unmounts a share
func (s DrivesDriveItemService) UnmountShare(ctx context.Context, shareID *collaboration.ShareId) error {
share, err := s.GetShare(ctx, shareID)
if err != nil {
return err
}
shares, err := s.GetSharesForResource(ctx, share.GetShare().GetResourceId(), []*collaboration.Filter{
{
Type: collaboration.Filter_TYPE_STATE,
Term: &collaboration.Filter_State{
State: collaboration.ShareState_SHARE_STATE_ACCEPTED,
},
},
{
Type: collaboration.Filter_TYPE_STATE,
Term: &collaboration.Filter_State{
State: collaboration.ShareState_SHARE_STATE_REJECTED,
},
},
})
if err != nil {
return err
}
availableShares := make([]*collaboration.ReceivedShare, 0, 1)
rejectedShares := make([]*collaboration.ReceivedShare, 0, 1)
for _, v := range shares {
switch v.GetState() {
case collaboration.ShareState_SHARE_STATE_ACCEPTED:
availableShares = append(availableShares, v)
case collaboration.ShareState_SHARE_STATE_REJECTED:
rejectedShares = append(rejectedShares, v)
}
}
if len(availableShares) == 0 {
if len(rejectedShares) > 0 {
return ErrAlreadyUnmounted
}
return ErrNoShares
}
_, err = s.UpdateShares(ctx, availableShares, func(_ *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
request.Share.State = collaboration.ShareState_SHARE_STATE_REJECTED
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
})
return err
}
// MountShare mounts a share, there is no guarantee that all siblings will be mounted
// in some rare cases it could happen that none of the siblings could be mounted,
// then the error will be returned
func (s DrivesDriveItemService) MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error) {
if filepath.IsAbs(name) {
return nil, ErrAbsoluteNamePath
}
if name != "" {
name = filepath.Clean(name)
}
shares, err := s.GetSharesForResource(ctx, resourceID, nil)
if err != nil {
return nil, err
}
availableShares := make([]*collaboration.ReceivedShare, 0, len(shares))
mountedShares := make([]*collaboration.ReceivedShare, 0, 1)
for _, v := range shares {
switch v.GetState() {
case collaboration.ShareState_SHARE_STATE_ACCEPTED:
mountedShares = append(mountedShares, v)
case collaboration.ShareState_SHARE_STATE_PENDING, collaboration.ShareState_SHARE_STATE_REJECTED:
availableShares = append(availableShares, v)
}
}
if len(availableShares) == 0 {
if len(mountedShares) > 0 {
return nil, ErrAlreadyMounted
}
return nil, ErrNoShares
}
updatedShares, err := s.UpdateShares(ctx, availableShares, func(share *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
request.Share.State = collaboration.ShareState_SHARE_STATE_ACCEPTED
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
// only update if mountPoint name is not empty and the path has changed
if name != "" {
mountPoint := share.GetMountPoint()
if mountPoint == nil {
mountPoint = &storageprovider.Reference{}
}
if filepath.Clean(mountPoint.GetPath()) != name {
mountPoint.Path = name
request.Share.MountPoint = mountPoint
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathMountPoint)
}
}
})
errs, ok := err.(interface{ Unwrap() []error })
if ok && len(errs.Unwrap()) == len(availableShares) {
// none of the received shares could be accepted.
// this is an error, return it.
return nil, err
}
return updatedShares, nil
}
// DrivesDriveItemApi is the api that registers the http endpoints which expose needed operation to the graph api.
// the business logic is delegated to the space service and further down to the cs3 client.
type DrivesDriveItemApi struct {
logger log.Logger
drivesDriveItemService DrivesDriveItemProvider
baseGraphService BaseGraphProvider
}
// NewDrivesDriveItemApi creates a new DrivesDriveItemApi
func NewDrivesDriveItemApi(drivesDriveItemService DrivesDriveItemProvider, baseGraphService BaseGraphProvider, logger log.Logger) (DrivesDriveItemApi, error) {
return DrivesDriveItemApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
drivesDriveItemService: drivesDriveItemService,
baseGraphService: baseGraphService,
}, nil
}
// DeleteDriveItem deletes a drive item
func (api DrivesDriveItemApi) DeleteDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
shareID := ExtractShareIdFromResourceId(itemID)
if err := api.drivesDriveItemService.UnmountShare(ctx, shareID); err != nil {
api.logger.Debug().Err(err).Msg(ErrUnmountShare.Error())
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetDriveItem get a drive item
func (api DrivesDriveItemApi) GetDriveItem(w http.ResponseWriter, r *http.Request) {
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
shareID := ExtractShareIdFromResourceId(itemID)
share, err := api.drivesDriveItemService.GetShare(r.Context(), shareID)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
availableShares, err := api.drivesDriveItemService.GetSharesForResource(r.Context(), share.GetShare().GetResourceId(), nil)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
driveItems, err := api.baseGraphService.CS3ReceivedSharesToDriveItems(r.Context(), availableShares)
switch {
case err != nil:
break
case len(driveItems) != 1:
err = ErrDriveItemConversion
}
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, driveItems[0])
}
// UpdateDriveItem updates a drive item, currently only the visibility of the share is updated
func (api DrivesDriveItemApi) UpdateDriveItem(w http.ResponseWriter, r *http.Request) {
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
shareID := ExtractShareIdFromResourceId(itemID)
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
share, err := api.drivesDriveItemService.GetShare(r.Context(), shareID)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
availableShares, err := api.drivesDriveItemService.GetSharesForResource(r.Context(), share.GetShare().GetResourceId(), nil)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
updatedShares, err := api.drivesDriveItemService.UpdateShares(
r.Context(),
availableShares,
func(_ *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
request.GetShare().Hidden = requestDriveItem.GetUIHidden()
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathHidden)
},
)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrUpdateShares.Error())
ErrUpdateShares.Render(w, r)
return
}
driveItems, err := api.baseGraphService.CS3ReceivedSharesToDriveItems(r.Context(), updatedShares)
switch {
case err != nil:
break
case len(driveItems) != 1:
err = ErrDriveItemConversion
}
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, driveItems[0])
}
// CreateDriveItem creates a drive item
func (api DrivesDriveItemApi) CreateDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(&driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
remoteItem := requestDriveItem.GetRemoteItem()
resourceId, err := storagespace.ParseID(remoteItem.GetId())
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidID.Error())
ErrInvalidID.Render(w, r)
return
}
var driveItems []libregraph.DriveItem
switch {
case resourceId.GetStorageId() == utils.OCMStorageProviderID:
var mountedOcmShares []*ocm.ReceivedShare
mountedOcmShares, err = api.drivesDriveItemService.MountOCMShare(ctx, &resourceId /*, requestDriveItem.GetName()*/)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrMountShare.Error())
switch e, ok := errorcode.ToError(err); {
case ok && e.GetOrigin() == errorcode.ErrorOriginCS3 && e.GetCode() == errorcode.ItemNotFound:
ErrDriveItemConversion.Render(w, r)
default:
errorcode.RenderError(w, r, err)
}
return
}
driveItems, err = api.baseGraphService.CS3ReceivedOCMSharesToDriveItems(ctx, mountedOcmShares)
default:
var mountedShares []*collaboration.ReceivedShare
// Get all shares that the user has received for this resource. There might be multiple
mountedShares, err = api.drivesDriveItemService.MountShare(ctx, &resourceId, requestDriveItem.GetName())
if err != nil {
api.logger.Debug().Err(err).Msg(ErrMountShare.Error())
switch e, ok := errorcode.ToError(err); {
case ok && e.GetOrigin() == errorcode.ErrorOriginCS3 && e.GetCode() == errorcode.ItemNotFound:
ErrDriveItemConversion.Render(w, r)
default:
errorcode.RenderError(w, r, err)
}
return
}
driveItems, err = api.baseGraphService.CS3ReceivedSharesToDriveItems(ctx, mountedShares)
}
switch {
case err != nil:
break
case len(driveItems) != 1:
err = ErrDriveItemConversion
}
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusCreated)
render.JSON(w, r, driveItems[0])
}
@@ -0,0 +1,173 @@
package svc
import (
"context"
"errors"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
var (
// ErrUnmountOCMShare is returned when unmounting a share fails
ErrUnmountOCMShare = errorcode.New(errorcode.InvalidRequest, "unmounting ocm share failed")
// ErrMountOCMShare is returned when mounting a share fails
ErrMountOCMShare = errorcode.New(errorcode.InvalidRequest, "mounting ocm share failed")
)
type (
// UpdateOCMShareClosure is a closure that injects required updates into the update request
UpdateOCMShareClosure func(share *ocm.ReceivedShare, request *ocm.UpdateReceivedOCMShareRequest)
)
// GetOCMSharesForResource returns all federated shares for a given resourceID
func (s DrivesDriveItemService) GetOCMSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId) ([]*ocm.ReceivedShare, error) {
// Find all accepted shares for this resource
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
receivedOCMSharesResponse, err := gatewayClient.ListReceivedOCMShares(ctx, &ocm.ListReceivedOCMSharesRequest{
/* ocm has no filters, yet
Filters: append([]*collaboration.Filter{
{
Type: collaboration.Filter_TYPE_RESOURCE_ID,
Term: &collaboration.Filter_ResourceId{
ResourceId: resourceID,
},
},
}, filters...),
*/
})
switch {
case err != nil:
return nil, err
case len(receivedOCMSharesResponse.GetShares()) == 0:
return nil, ErrNoShares
default:
return receivedOCMSharesResponse.GetShares(), errorcode.FromCS3Status(receivedOCMSharesResponse.GetStatus(), err)
}
}
// UpdateShares updates multiple shares;
// it could happen that some shares are updated and some are not,
// this will return a list of updated shares and a list of errors;
// there is no guarantee that all updates are successful
func (s DrivesDriveItemService) UpdateOCMShares(ctx context.Context, shares []*ocm.ReceivedShare, updater UpdateOCMShareClosure) ([]*ocm.ReceivedShare, error) {
errs := make([]error, 0, len(shares))
updatedShares := make([]*ocm.ReceivedShare, 0, len(shares))
for _, share := range shares {
err := s.UpdateOCMShare(
ctx,
share,
updater,
)
if err != nil {
errs = append(errs, err)
continue
}
updatedShares = append(updatedShares, share)
}
return updatedShares, errors.Join(errs...)
}
// UpdateOCMShare updates a single share
func (s DrivesDriveItemService) UpdateOCMShare(ctx context.Context, share *ocm.ReceivedShare, updater UpdateOCMShareClosure) error {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return err
}
updateReceivedOCMShareRequest := &ocm.UpdateReceivedOCMShareRequest{
Share: &ocm.ReceivedShare{
Id: share.GetId(),
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{}},
}
switch updater {
case nil:
return ErrNoUpdater
default:
updater(share, updateReceivedOCMShareRequest)
}
if len(updateReceivedOCMShareRequest.GetUpdateMask().GetPaths()) == 0 {
return ErrNoUpdates
}
updateReceivedOCMShareResponse, err := gatewayClient.UpdateReceivedOCMShare(ctx, updateReceivedOCMShareRequest)
return errorcode.FromCS3Status(updateReceivedOCMShareResponse.GetStatus(), err)
}
func (s DrivesDriveItemService) MountOCMShare(ctx context.Context, resourceID *storageprovider.ResourceId /*, name string*/) ([]*ocm.ReceivedShare, error) {
/*
if filepath.IsAbs(name) {
return nil, ErrAbsoluteNamePath
}
if name != "" {
name = filepath.Clean(name)
}
*/
shares, err := s.GetOCMSharesForResource(ctx, resourceID)
if err != nil {
return nil, err
}
availableShares := make([]*ocm.ReceivedShare, 0, len(shares))
mountedShares := make([]*ocm.ReceivedShare, 0, 1)
for _, v := range shares {
switch v.GetState() {
case ocm.ShareState_SHARE_STATE_ACCEPTED:
mountedShares = append(mountedShares, v)
case ocm.ShareState_SHARE_STATE_PENDING, ocm.ShareState_SHARE_STATE_REJECTED:
availableShares = append(availableShares, v)
}
}
if len(availableShares) == 0 {
if len(mountedShares) > 0 {
return nil, ErrAlreadyMounted
}
return nil, ErrNoShares
}
updatedShares, err := s.UpdateOCMShares(ctx, availableShares, func(share *ocm.ReceivedShare, request *ocm.UpdateReceivedOCMShareRequest) {
request.Share.State = ocm.ShareState_SHARE_STATE_ACCEPTED
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
// only update if mountPoint name is not empty and the path has changed
/* ocm shares have no mount point???
if name != "" {
mountPoint := share.GetMountPoint()
if mountPoint == nil {
mountPoint = &storageprovider.Reference{}
}
if filepath.Clean(mountPoint.GetPath()) != name {
mountPoint.Path = name
request.Share.MountPoint = mountPoint
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathMountPoint)
}
}
*/
})
errs, ok := err.(interface{ Unwrap() []error })
if ok && len(errs.Unwrap()) == len(availableShares) {
// none of the received ocm shares could be accepted.
// this is an error, return it.
return nil, err
}
return updatedShares, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
package svc
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/render"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type (
// UsersUserProfilePhotoProvider is the interface that defines the methods for the user profile photo service
UsersUserProfilePhotoProvider interface {
// GetPhoto retrieves the requested photo
GetPhoto(ctx context.Context, id string) ([]byte, error)
// UpdatePhoto retrieves the requested photo
UpdatePhoto(ctx context.Context, id string, r io.Reader) error
// DeletePhoto deletes the requested photo
DeletePhoto(ctx context.Context, id string) error
}
)
var (
// ErrNoBytes is returned when no bytes are found
ErrNoBytes = errors.New("no bytes")
// ErrInvalidContentType is returned when the content type is invalid
ErrInvalidContentType = errors.New("invalid content type")
// ErrMissingArgument is returned when a required argument is missing
ErrMissingArgument = errors.New("required argument is missing")
)
// UsersUserProfilePhotoService is the implementation of the UsersUserProfilePhotoProvider interface
type UsersUserProfilePhotoService struct {
storage metadata.Storage
}
// NewUsersUserProfilePhotoService creates a new UsersUserProfilePhotoService
func NewUsersUserProfilePhotoService(storage metadata.Storage) (UsersUserProfilePhotoService, error) {
return UsersUserProfilePhotoService{
storage: storage,
}, nil
}
// GetPhoto retrieves the requested photo
func (s UsersUserProfilePhotoService) GetPhoto(ctx context.Context, id string) ([]byte, error) {
return s.storage.SimpleDownload(ctx, id)
}
// DeletePhoto deletes the requested photo
func (s UsersUserProfilePhotoService) DeletePhoto(ctx context.Context, id string) error {
return s.storage.Delete(ctx, id)
}
// UpdatePhoto updates the requested photo
func (s UsersUserProfilePhotoService) UpdatePhoto(ctx context.Context, id string, r io.Reader) error {
if id == "" {
return fmt.Errorf("%w: %s", ErrMissingArgument, "id")
}
photo, err := io.ReadAll(r)
if err != nil {
return err
}
if len(photo) == 0 {
return ErrNoBytes
}
contentType := http.DetectContentType(photo)
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("%w: %s", ErrInvalidContentType, contentType)
}
return s.storage.SimpleUpload(ctx, id, photo)
}
// UsersUserProfilePhotoApi contains all photo related api endpoints
type UsersUserProfilePhotoApi struct {
logger log.Logger
usersUserProfilePhotoService UsersUserProfilePhotoProvider
}
// NewUsersUserProfilePhotoApi creates a new UsersUserProfilePhotoApi
func NewUsersUserProfilePhotoApi(usersUserProfilePhotoService UsersUserProfilePhotoProvider, logger log.Logger) (UsersUserProfilePhotoApi, error) {
return UsersUserProfilePhotoApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "UsersUserProfilePhotoApi").Logger()},
usersUserProfilePhotoService: usersUserProfilePhotoService,
}, nil
}
// GetProfilePhoto creates a handler which renders the corresponding photo
func (api UsersUserProfilePhotoApi) GetProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v, ok := h(w, r)
if !ok {
return
}
photo, err := api.usersUserProfilePhotoService.GetPhoto(r.Context(), v)
if err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusNotFound, "failed to get photo")
return
}
render.Status(r, http.StatusOK)
_, _ = w.Write(photo)
}
}
// UpsertProfilePhoto creates a handler which updates or creates the corresponding photo
func (api UsersUserProfilePhotoApi) UpsertProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v, ok := h(w, r)
if !ok {
return
}
if err := api.usersUserProfilePhotoService.UpdatePhoto(r.Context(), v, r.Body); err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "failed to update photo")
return
}
defer func() {
_ = r.Body.Close()
}()
render.Status(r, http.StatusOK)
}
}
// DeleteProfilePhoto creates a handler which deletes the corresponding photo
func (api UsersUserProfilePhotoApi) DeleteProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v, ok := h(w, r)
if !ok {
return
}
if err := api.usersUserProfilePhotoService.DeletePhoto(r.Context(), v); err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "failed to delete photo")
return
}
render.Status(r, http.StatusOK)
}
}
@@ -0,0 +1,141 @@
package svc_test
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/mocks"
svc "github.com/qsfera/server/services/graph/pkg/service/v0"
)
func TestNewUsersUserProfilePhotoService(t *testing.T) {
service, err := svc.NewUsersUserProfilePhotoService(mocks.NewStorage(t))
assert.NoError(t, err)
t.Run("UpdatePhoto", func(t *testing.T) {
t.Run("reports an error if id is empty", func(t *testing.T) {
err := service.UpdatePhoto(context.Background(), "", bytes.NewReader([]byte{}))
assert.ErrorIs(t, err, svc.ErrMissingArgument)
})
t.Run("reports an error if the reader does not contain any bytes", func(t *testing.T) {
err := service.UpdatePhoto(context.Background(), "123", bytes.NewReader([]byte{}))
assert.ErrorIs(t, err, svc.ErrNoBytes)
})
t.Run("reports an error if data is not an image", func(t *testing.T) {
err := service.UpdatePhoto(context.Background(), "234", bytes.NewReader([]byte("not an image")))
assert.ErrorIs(t, err, svc.ErrInvalidContentType)
})
})
}
func TestUsersUserProfilePhotoApi(t *testing.T) {
var (
serviceProvider = mocks.NewUsersUserProfilePhotoProvider(t)
dataProvider = func(w http.ResponseWriter, r *http.Request) (string, bool) {
return "123", true
}
)
api, err := svc.NewUsersUserProfilePhotoApi(serviceProvider, log.NopLogger())
assert.NoError(t, err)
t.Run("GetProfilePhoto", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
ep := api.GetProfilePhoto(dataProvider)
t.Run("fails if photo provider errors", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().GetPhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) ([]byte, error) {
return nil, errors.New("any")
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("successfully returns the requested photo", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().GetPhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) ([]byte, error) {
return []byte("photo"), nil
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "photo", w.Body.String())
})
})
t.Run("DeleteProfilePhoto", func(t *testing.T) {
r := httptest.NewRequest(http.MethodDelete, "/", nil)
ep := api.DeleteProfilePhoto(dataProvider)
t.Run("fails if photo provider errors", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().DeletePhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) error {
return errors.New("any")
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusInternalServerError, w.Code)
})
t.Run("successfully deletes the requested photo", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().DeletePhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) error {
return nil
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
})
})
t.Run("UpsertProfilePhoto", func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "/", strings.NewReader("body"))
ep := api.UpsertProfilePhoto(dataProvider)
t.Run("fails if photo provider errors", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().UpdatePhoto(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string, r io.Reader) error {
return errors.New("any")
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusInternalServerError, w.Code)
})
t.Run("successfully upserts the photo", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().UpdatePhoto(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string, r io.Reader) error {
return nil
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
})
})
}
@@ -0,0 +1,77 @@
package svc
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/pkg/errorcode"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
// ListApplications implements the Service interface.
func (g Graph) ListApplications(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling list applications")
lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
if err != nil {
logger.Error().Err(err).Msg("could not list roles: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
for _, bundle := range lbr.GetBundles() {
role := libregraph.NewAppRole(bundle.GetId())
role.SetDisplayName(bundle.GetDisplayName())
roles = append(roles, *role)
}
application := libregraph.NewApplication(g.config.Application.ID)
application.SetDisplayName(g.config.Application.DisplayName)
application.SetAppRoles(roles)
applications := []*libregraph.Application{
application,
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: applications})
}
// GetApplication implements the Service interface.
func (g Graph) GetApplication(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling get application")
applicationID := chi.URLParam(r, "applicationID")
if applicationID != g.config.Application.ID {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("requested id %s does not match expected application id %v", applicationID, g.config.Application.ID))
return
}
lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
if err != nil {
logger.Error().Err(err).Msg("could not list roles: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
for _, bundle := range lbr.GetBundles() {
role := libregraph.NewAppRole(bundle.GetId())
role.SetDisplayName(bundle.GetDisplayName())
roles = append(roles, *role)
}
application := libregraph.NewApplication(applicationID)
application.SetDisplayName(g.config.Application.DisplayName)
application.SetAppRoles(roles)
render.Status(r, http.StatusOK)
render.JSON(w, r, application)
}
@@ -0,0 +1,149 @@
package svc_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type applicationList struct {
Value []*libregraph.Application
}
var _ = Describe("Applications", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
roleService *mocks.RoleService
identityBackend *identitymocks.Backend
rr *httptest.ResponseRecorder
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityBackend = &identitymocks.Backend{}
roleService = &mocks.RoleService{}
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return 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{}
cfg.Application.ID = "some-application-ID"
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithRoleService(roleService),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("ListApplications", func() {
It("lists the configured application with appRoles", func() {
roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
Bundles: []*settingsmsg.Bundle{
{
Id: "some-appRole-ID",
Type: settingsmsg.Bundle_TYPE_ROLE,
DisplayName: "A human readable name for a role",
},
},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications", nil)
svc.ListApplications(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
responseList := applicationList{}
err = json.Unmarshal(data, &responseList)
Expect(err).ToNot(HaveOccurred())
Expect(len(responseList.Value)).To(Equal(1))
Expect(responseList.Value[0].Id).To(Equal(cfg.Application.ID))
Expect(len(responseList.Value[0].GetAppRoles())).To(Equal(1))
Expect(responseList.Value[0].GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
Expect(responseList.Value[0].GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
})
})
Describe("GetApplication", func() {
It("gets the application with appRoles", func() {
roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
Bundles: []*settingsmsg.Bundle{
{
Id: "some-appRole-ID",
Type: settingsmsg.Bundle_TYPE_ROLE,
DisplayName: "A human readable name for a role",
},
},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications/some-application-ID", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("applicationID", cfg.Application.ID)
r = r.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
svc.GetApplication(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
application := libregraph.Application{}
err = json.Unmarshal(data, &application)
Expect(err).ToNot(HaveOccurred())
Expect(application.Id).To(Equal(cfg.Application.ID))
Expect(len(application.GetAppRoles())).To(Equal(1))
Expect(application.GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
Expect(application.GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
})
})
})
@@ -0,0 +1,166 @@
package svc
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
merrors "go-micro.dev/v4/errors"
)
const principalTypeUser = "User"
// ListAppRoleAssignments implements the Service interface.
func (g Graph) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling list appRoleAssignments")
userID := chi.URLParam(r, "userID")
lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
AccountUuid: userID,
})
if err != nil {
// TODO check the error type and return proper error code
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
values := make([]libregraph.AppRoleAssignment, 0, len(lrar.GetAssignments()))
for _, assignment := range lrar.GetAssignments() {
values = append(values, g.assignmentToAppRoleAssignment(assignment))
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: values})
}
// CreateAppRoleAssignment implements the Service interface.
func (g Graph) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling create appRoleAssignment")
appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
err := StrictJSONUnmarshal(r.Body, appRoleAssignment)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
return
}
userID := chi.URLParam(r, "userID")
// We can ignore the error, in the worst case the old role will be empty
oldRoles, _ := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
AccountUuid: userID,
})
if appRoleAssignment.GetPrincipalId() != userID {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("user id %s does not match principal id %v", userID, appRoleAssignment.GetPrincipalId()))
return
}
if appRoleAssignment.GetResourceId() != g.config.Application.ID {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("resource id %s does not match expected application id %v", userID, g.config.Application.ID))
return
}
artur, err := g.roleService.AssignRoleToUser(r.Context(), &settingssvc.AssignRoleToUserRequest{
AccountUuid: userID,
RoleId: appRoleAssignment.AppRoleId,
})
if err != nil {
if merr, ok := merrors.As(err); ok && merr.Code == http.StatusForbidden {
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, err.Error())
return
}
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
var role string
roles := oldRoles.GetAssignments()
if len(roles) > 0 {
role = roles[0].GetRoleId()
}
e := events.UserFeatureChanged{
UserID: userID,
Features: []events.UserFeature{
{
Name: "roleChanged",
Value: appRoleAssignment.AppRoleId,
OldValue: &role,
},
},
Timestamp: utils.TSNow(),
}
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, g.assignmentToAppRoleAssignment(artur.GetAssignment()))
}
// DeleteAppRoleAssignment implements the Service interface.
func (g Graph) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("body", r.Body).Msg("calling delete appRoleAssignment")
userID := chi.URLParam(r, "userID")
// check assignment belongs to the user
lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
AccountUuid: userID,
})
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
appRoleAssignmentID := chi.URLParam(r, "appRoleAssignmentID")
assignmentFound := false
for _, roleAssignment := range lrar.GetAssignments() {
if roleAssignment.Id == appRoleAssignmentID {
assignmentFound = true
}
}
if !assignmentFound {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("appRoleAssignment %v not found for user %v", appRoleAssignmentID, userID))
return
}
_, err = g.roleService.RemoveRoleFromUser(r.Context(), &settingssvc.RemoveRoleFromUserRequest{
Id: appRoleAssignmentID,
})
if err != nil {
if merr, ok := merrors.As(err); ok && merr.Code == http.StatusForbidden {
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, err.Error())
return
}
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.NoContent(w, r)
}
func (g Graph) assignmentToAppRoleAssignment(assignment *settingsmsg.UserRoleAssignment) libregraph.AppRoleAssignment {
appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
appRoleAssignment.SetId(assignment.Id)
appRoleAssignment.SetAppRoleId(assignment.RoleId)
appRoleAssignment.SetPrincipalType(principalTypeUser) // currently always assigned to the user
appRoleAssignment.SetResourceId(g.config.Application.ID)
appRoleAssignment.SetResourceDisplayName(g.config.Application.DisplayName)
appRoleAssignment.SetPrincipalId(assignment.AccountUuid)
// appRoleAssignment.SetPrincipalDisplayName() // TODO fetch and cache
return *appRoleAssignment
}
@@ -0,0 +1,218 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/golang/protobuf/ptypes/empty"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type assignmentList struct {
Value []*libregraph.AppRoleAssignment
}
var _ = Describe("AppRoleAssignments", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
roleService *mocks.RoleService
identityBackend *identitymocks.Backend
rr *httptest.ResponseRecorder
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityBackend = &identitymocks.Backend{}
roleService = &mocks.RoleService{}
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return 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{}
cfg.Application.ID = "some-application-ID"
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithRoleService(roleService),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("ListAppRoleAssignments", func() {
It("lists the appRoleAssignments", func() {
user := &libregraph.User{
Id: libregraph.PtrString("user1"),
}
assignments := []*settingsmsg.UserRoleAssignment{
{
Id: "some-appRoleAssignment-ID",
AccountUuid: user.GetId(),
RoleId: "some-appRole-ID",
},
}
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users/user1/appRoleAssignments", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.ListAppRoleAssignments(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
responseList := assignmentList{}
err = json.Unmarshal(data, &responseList)
Expect(err).ToNot(HaveOccurred())
Expect(len(responseList.Value)).To(Equal(1))
Expect(responseList.Value[0].GetId()).ToNot(BeEmpty())
Expect(responseList.Value[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
Expect(responseList.Value[0].GetPrincipalId()).To(Equal(user.GetId()))
Expect(responseList.Value[0].GetResourceId()).To(Equal(cfg.Application.ID))
})
})
Describe("CreateAppRoleAssignment", func() {
It("creates an appRoleAssignment", func() {
user := &libregraph.User{
Id: libregraph.PtrString("user1"),
}
userRoleAssignment := &settingsmsg.UserRoleAssignment{
Id: "some-appRoleAssignment-ID",
AccountUuid: user.GetId(),
RoleId: "some-appRole-ID",
}
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{
Assignments: []*settingsmsg.UserRoleAssignment{
userRoleAssignment,
},
}, nil)
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything, mock.Anything).Return(&settings.AssignRoleToUserResponse{Assignment: userRoleAssignment}, nil)
ara := libregraph.NewAppRoleAssignmentWithDefaults()
ara.SetAppRoleId("some-appRole-ID")
ara.SetPrincipalId(user.GetId())
ara.SetResourceId(cfg.Application.ID)
araJson, err := json.Marshal(ara)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments", bytes.NewBuffer(araJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.CreateAppRoleAssignment(rr, r)
Expect(rr.Code).To(Equal(http.StatusCreated))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
assignment := libregraph.AppRoleAssignment{}
err = json.Unmarshal(data, &assignment)
Expect(err).ToNot(HaveOccurred())
Expect(assignment.GetId()).ToNot(BeEmpty())
Expect(assignment.GetAppRoleId()).To(Equal("some-appRole-ID"))
Expect(assignment.GetPrincipalId()).To(Equal("user1"))
Expect(assignment.GetResourceId()).To(Equal(cfg.Application.ID))
})
})
Describe("DeleteAppRoleAssignment", func() {
It("deletes an appRoleAssignment", func() {
user := &libregraph.User{
Id: libregraph.PtrString("user1"),
}
assignments := []*settingsmsg.UserRoleAssignment{
{
Id: "some-appRoleAssignment-ID",
AccountUuid: user.GetId(),
RoleId: "some-appRole-ID",
},
}
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
roleService.On("RemoveRoleFromUser", mock.Anything, mock.Anything, mock.Anything).Return(&empty.Empty{}, nil)
ara := libregraph.NewAppRoleAssignmentWithDefaults()
ara.SetAppRoleId("some-appRole-ID")
ara.SetPrincipalId(user.GetId())
ara.SetResourceId(cfg.Application.ID)
araJson, err := json.Marshal(ara)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments/some-appRoleAssignment-ID", bytes.NewBuffer(araJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
rctx.URLParams.Add("appRoleAssignmentID", "some-appRoleAssignment-ID")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteAppRoleAssignment(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
})
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
package svc
import (
"errors"
"fmt"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
// HTTPDataHandler returns data from the request, it should exit early and return false in the case of any error
type HTTPDataHandler[T any] func(w http.ResponseWriter, r *http.Request) (T, bool)
var (
// ErrNoUser is returned when no user is found
ErrNoUser = errors.New("no user found")
)
// GetUserIDFromCTX extracts the user from the request
func GetUserIDFromCTX(w http.ResponseWriter, r *http.Request) (string, bool) {
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
errorcode.GeneralException.Render(w, r, http.StatusMethodNotAllowed, ErrNoUser.Error())
}
return u.GetId().GetOpaqueId(), ok
}
func GetSlugValue(key string) HTTPDataHandler[string] {
return func(w http.ResponseWriter, r *http.Request) (string, bool) {
v, err := url.PathUnescape(chi.URLParam(r, key))
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, fmt.Sprintf(`failed to get slug: "%s"`, key))
}
return v, err == nil
}
}
@@ -0,0 +1,757 @@
package svc
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"reflect"
"strconv"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"golang.org/x/crypto/sha3"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
// CreateUploadSession create an upload session to allow your app to upload files up to the maximum file size.
// An upload session allows your app to upload ranges of the file in sequential API requests, which allows the
// transfer to be resumed if a connection is dropped while the upload is in progress.
// ```json
//
// {
// "@microsoft.graph.conflictBehavior": "fail (default) | replace | rename",
// "description": "description",
// "fileSize": 1234,
// "name": "filename.txt"
// }
//
// ```
// From https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession?view=graph-rest-1.0
func (g Graph) CreateUploadSession(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling CreateUploadSession")
driveID, err := parseIDParam(r, "driveID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
driveItemID, err := parseIDParam(r, "driveItemID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
var cusr createUploadSessionRequest
err = json.NewDecoder(r.Body).Decode(&cusr)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
return
}
ref := &storageprovider.Reference{
ResourceId: &driveItemID,
}
if cusr.Item.Name != "" {
ref.Path = utils.MakeRelativePath(cusr.Item.Name)
}
req := &storageprovider.InitiateFileUploadRequest{
Ref: ref,
Opaque: utils.AppendPlainToOpaque(nil, "Upload-Length", strconv.FormatUint(uint64(cusr.Item.FileSize), 10)),
}
ctx := r.Context()
res, err := gatewayClient.InitiateFileUpload(ctx, req)
switch {
case err != nil:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
// ok
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
uploadSession := uploadSession{
CS3Protocols: res.GetProtocols(),
}
for _, p := range res.GetProtocols() {
if p.GetProtocol() == "simple" {
uploadSession.UploadURL = p.GetUploadEndpoint() + "/" + p.GetToken()
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &uploadSession)
}
type createUploadSessionRequest struct {
DeferCommit bool `json:"deferCommit"`
Item driveItemUploadableProperties `json:"item"`
}
type driveItemUploadableProperties struct {
// ConflictBehavior "@microsoft.graph.conflictBehavior"
//Description string
FileSize int64 `json:"fileSize"`
// fileSystemInfo
Name string `json:"name"`
}
type uploadSession struct {
UploadURL string
//"expirationDateTime": "2015-01-29T09:21:55.523Z",
//"nextExpectedRanges": ["0-"]
CS3Protocols []*gateway.FileUploadProtocol
}
// GetRootDriveChildren implements the Service interface.
func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling GetRootDriveChildren")
ctx := r.Context()
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
return
}
currentUser := revactx.ContextMustGetUser(r.Context())
// do we need to list all or only the personal drive
filters := []*storageprovider.ListStorageSpacesRequest_Filter{}
filters = append(filters, listStorageSpacesUserFilter(currentUser.GetId().GetOpaqueId()))
filters = append(filters, listStorageSpacesTypeFilter("personal"))
res, err := gatewayClient.ListStorageSpaces(ctx, &storageprovider.ListStorageSpacesRequest{
Filters: filters,
})
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error making ListStorageSpaces grpc call")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
}
g.logger.Error().Err(err).Msg("error sending ListStorageSpaces grpc request")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
var space *storageprovider.StorageSpace
for _, s := range res.GetStorageSpaces() {
if utils.UserIDEqual(currentUser.GetId(), s.GetOwner().GetId()) {
space = s
}
}
lRes, err := gatewayClient.ListContainer(ctx, &storageprovider.ListContainerRequest{
Ref: &storageprovider.Reference{ResourceId: space.GetRoot()},
})
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error making ListContainer grpc call")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case lRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, lRes.GetStatus().GetMessage())
return
}
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED {
// TODO check if we should return 404 to not disclose existing items
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, lRes.GetStatus().GetMessage())
return
}
g.logger.Error().Err(err).Msg("error sending list container grpc request")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
files, err := formatDriveItems(g.logger, g.publicBaseURL, lRes.GetInfos())
if err != nil {
g.logger.Error().Err(err).Msg("error encoding response as json")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: files})
}
// GetDriveItem returns a driveItem
func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling GetDriveItem")
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
driveItemID, err := parseIDParam(r, "driveItemID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
/*
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
// Parse the request with odata parser
odataReq, err := godata.ParseRequest(ctx, sanitizedPath, r.URL.Query())
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
*/
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
res, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: &driveItemID}})
switch {
case err != nil:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
// ok
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, res.GetInfo())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &driveItem)
}
// GetDriveItemChildren lists the children of a driveItem
func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling GetDriveItemChildren")
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
driveItemID, err := parseIDParam(r, "driveItemID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
/*
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
// Parse the request with odata parser
odataReq, err := godata.ParseRequest(ctx, sanitizedPath, r.URL.Query())
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
*/
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
res, err := gatewayClient.ListContainer(ctx, &storageprovider.ListContainerRequest{
Ref: &storageprovider.Reference{ResourceId: &driveItemID},
})
switch {
case err != nil:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
// ok
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
files, err := formatDriveItems(g.logger, g.publicBaseURL, res.GetInfos())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: files})
}
func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.ResourceId, baseURL *url.URL) (*libregraph.RemoteItem, error) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
return nil, err
}
ref := &storageprovider.Reference{
ResourceId: root,
}
res, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
if err != nil {
return nil, err
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
// Only log this, there could be mountpoints which have no grant
g.logger.Debug().Msg(res.GetStatus().GetMessage())
return nil, errors.New("could not fetch grant resource for the mountpoint")
}
item, err := cs3ResourceToRemoteItem(res.GetInfo())
if err != nil {
return nil, err
}
if baseURL != nil && res.GetInfo() != nil && res.GetInfo().GetSpace() != nil {
// TODO read from StorageSpace ... needs Opaque for now
// TODO how do we build the url?
// for now: read from request
item.Name = libregraph.PtrString(res.GetInfo().GetName())
if res.GetInfo().GetSpace().GetRoot() != nil {
webDavURL := *baseURL
relativePath := res.GetInfo().GetPath()
webDavURL.Path = path.Join(webDavURL.Path, storagespace.FormatResourceID(res.GetInfo().GetSpace().GetRoot()), relativePath)
item.WebDavUrl = libregraph.PtrString(webDavURL.String())
}
}
return item, nil
}
func formatDriveItems(logger *log.Logger, publicBaseURL *url.URL, mds []*storageprovider.ResourceInfo) ([]*libregraph.DriveItem, error) {
responses := make([]*libregraph.DriveItem, 0, len(mds))
for i := range mds {
res, err := cs3ResourceToDriveItem(logger, publicBaseURL, mds[i])
if err != nil {
return nil, err
}
responses = append(responses, res)
}
return responses, nil
}
func cs3TimestampToTime(t *types.Timestamp) time.Time {
return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos()))
}
func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
size := new(int64)
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
driveItem := &libregraph.DriveItem{
Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())),
Size: size,
}
webURL := *publicBaseURL
webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId()))
driveItem.WebUrl = libregraph.PtrString(webURL.String())
if name := path.Base(res.GetPath()); name != "" {
driveItem.Name = &name
}
if res.GetEtag() != "" {
driveItem.ETag = &res.Etag
}
if res.GetMtime() != nil {
lastModified := cs3TimestampToTime(res.GetMtime())
driveItem.LastModifiedDateTime = &lastModified
}
if res.GetParentId() != nil {
parentRef := libregraph.NewItemReference()
parentRef.SetDriveType(res.GetSpace().GetSpaceType())
parentRef.SetDriveId(storagespace.FormatStorageID(res.GetParentId().GetStorageId(), res.GetParentId().GetSpaceId()))
parentRef.SetId(storagespace.FormatResourceID(res.GetParentId()))
parentRef.SetName(path.Base(path.Dir(res.GetPath())))
parentRef.SetPath(path.Dir(res.GetPath()))
driveItem.ParentReference = parentRef
}
switch res.GetType() {
case storageprovider.ResourceType_RESOURCE_TYPE_FILE:
mimeType := res.GetMimeType()
if mimeType == "" {
mimeType = "application/octet-stream"
}
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
driveItem.File = &libregraph.OpenGraphFile{
MimeType: &mimeType,
}
case storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
if IsSpaceRoot(res.GetId()) {
driveItem.SetRoot(map[string]any{})
} else {
driveItem.SetFolder(libregraph.Folder{})
}
}
if res.GetArbitraryMetadata() != nil {
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(logger, res)
driveItem.Image = cs3ResourceToDriveItemImageFacet(logger, res)
driveItem.Location = cs3ResourceToDriveItemLocationFacet(logger, res)
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(logger, res)
}
return driveItem, nil
}
func cs3ResourceToDriveItemAudioFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Audio {
if !strings.HasPrefix(res.GetMimeType(), "audio/") {
return nil
}
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var audio = &libregraph.Audio{}
if ok := unmarshalStringMap(logger, audio, k, "libre.graph.audio."); ok {
return audio
}
return nil
}
func cs3ResourceToDriveItemImageFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Image {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var image = &libregraph.Image{}
if ok := unmarshalStringMap(logger, image, k, "libre.graph.image."); ok {
return image
}
return nil
}
func cs3ResourceToDriveItemLocationFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.GeoCoordinates {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var location = &libregraph.GeoCoordinates{}
if ok := unmarshalStringMap(logger, location, k, "libre.graph.location."); ok {
return location
}
return nil
}
func cs3ResourceToDriveItemPhotoFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Photo {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var photo = &libregraph.Photo{}
if ok := unmarshalStringMap(logger, photo, k, "libre.graph.photo."); ok {
return photo
}
return nil
}
func getFieldName(structField reflect.StructField) string {
tag := structField.Tag.Get("json")
if tag == "" {
return structField.Name
}
return strings.Split(tag, ",")[0]
}
func unmarshalStringMap(logger *log.Logger, out any, flatMap map[string]string, prefix string) bool {
nonEmpty := false
obj := reflect.ValueOf(out).Elem()
timeKind := reflect.TypeOf(&time.Time{}).Elem().Kind()
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
structField := obj.Type().Field(i)
mapKey := prefix + getFieldName(structField)
if value, ok := flatMap[mapKey]; ok {
if field.Kind() == reflect.Ptr {
newValue := reflect.New(field.Type().Elem())
var tmp any
var err error
switch t := newValue.Type().Elem().Kind(); t {
case reflect.String:
tmp = value
case reflect.Int32:
tmp, err = strconv.ParseInt(value, 10, 32)
case reflect.Int64:
tmp, err = strconv.ParseInt(value, 10, 64)
case reflect.Float32:
tmp, err = strconv.ParseFloat(value, 32)
case reflect.Float64:
tmp, err = strconv.ParseFloat(value, 64)
case reflect.Bool:
tmp, err = strconv.ParseBool(value)
case timeKind:
tmp, err = time.Parse(time.RFC3339, value)
default:
err = errors.New("unsupported type")
logger.Error().Err(err).Str("type", t.String()).Str("mapKey", mapKey).Msg("target field type for value of mapKey is not supported")
}
if err != nil {
logger.Error().Err(err).Str("mapKey", mapKey).Msg("unmarshalling failed")
continue
}
newValue.Elem().Set(reflect.ValueOf(tmp).Convert(field.Type().Elem()))
field.Set(newValue)
nonEmpty = true
}
}
}
return nonEmpty
}
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
size := new(int64)
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
remoteItem := &libregraph.RemoteItem{
Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())),
Size: size,
}
if res.GetPath() != "" {
remoteItem.Path = libregraph.PtrString(path.Clean(res.GetPath()))
}
if res.GetEtag() != "" {
remoteItem.ETag = &res.Etag
}
if res.GetMtime() != nil {
lastModified := cs3TimestampToTime(res.GetMtime())
remoteItem.LastModifiedDateTime = &lastModified
}
if res.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE && res.GetMimeType() != "" {
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
remoteItem.File = &libregraph.OpenGraphFile{
MimeType: &res.MimeType,
}
}
if res.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER {
remoteItem.Folder = &libregraph.Folder{}
}
if res.GetSpace() != nil && res.GetSpace().GetRoot() != nil {
remoteItem.RootId = libregraph.PtrString(storagespace.FormatResourceID(res.GetSpace().GetRoot()))
grantSpaceAlias := utils.ReadPlainFromOpaque(res.GetSpace().GetOpaque(), "spaceAlias")
if grantSpaceAlias != "" {
remoteItem.DriveAlias = libregraph.PtrString(grantSpaceAlias)
}
}
return remoteItem, nil
}
func (g Graph) getPathForResource(ctx context.Context, id storageprovider.ResourceId) (string, error) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
return "", err
}
res, err := gatewayClient.GetPath(ctx, &storageprovider.GetPathRequest{ResourceId: &id})
if err != nil {
return "", err
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return "", fmt.Errorf("could not stat %v: %s", id, res.GetStatus().GetMessage())
}
return res.GetPath(), err
}
// getSpecialDriveItems reads properties from the opaque and transforms them into driveItems
func (g Graph) getSpecialDriveItems(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace) []libregraph.DriveItem {
if space.GetRoot().GetStorageId() == utils.ShareStorageProviderID {
return nil // no point in stating the ShareStorageProvider
}
if space.GetOpaque() == nil {
return nil
}
imageNode := utils.ReadPlainFromOpaque(space.GetOpaque(), SpaceImageSpecialFolderName)
readmeNode := utils.ReadPlainFromOpaque(space.GetOpaque(), ReadmeSpecialFolderName)
cachekey := spaceRootStatKey(space.GetRoot(), imageNode, readmeNode)
// if the root is older or equal to our cache we can reuse the cached extended spaces properties
if entry := g.specialDriveItemsCache.Get(cachekey); entry != nil {
if cached, ok := entry.Value().(specialDriveItemEntry); ok {
if cached.rootMtime != nil && space.GetMtime() != nil {
// beware, LaterTS does not handle equalness. it returns t1 if t1 > t2, else t2, so a >= check looks like this
if utils.LaterTS(space.GetMtime(), cached.rootMtime) == cached.rootMtime {
return cached.specialDriveItems
}
}
}
}
var spaceItems []libregraph.DriveItem
var err error
doCache := true
spaceItems, err = g.fetchSpecialDriveItem(ctx, spaceItems, SpaceImageSpecialFolderName, imageNode, space, baseURL)
if err != nil {
doCache = false
g.logger.Debug().Err(err).Str("ID", imageNode).Msg("Could not get space image")
}
spaceItems, err = g.fetchSpecialDriveItem(ctx, spaceItems, ReadmeSpecialFolderName, readmeNode, space, baseURL)
if err != nil {
doCache = false
g.logger.Debug().Err(err).Str("ID", imageNode).Msg("Could not get space readme")
}
// cache properties
spacePropertiesEntry := specialDriveItemEntry{
specialDriveItems: spaceItems,
rootMtime: space.GetMtime(),
}
if doCache {
g.specialDriveItemsCache.Set(cachekey, spacePropertiesEntry, time.Duration(g.config.Spaces.ExtendedSpacePropertiesCacheTTL))
}
return spaceItems
}
func (g Graph) fetchSpecialDriveItem(ctx context.Context, spaceItems []libregraph.DriveItem, itemName string, itemNode string, space *storageprovider.StorageSpace, baseURL *url.URL) ([]libregraph.DriveItem, error) {
var ref *storageprovider.Reference
if itemNode != "" {
rid, _ := storagespace.ParseID(itemNode)
rid.StorageId = space.GetRoot().GetStorageId()
ref = &storageprovider.Reference{
ResourceId: &rid,
}
spaceItem, err := g.getSpecialDriveItem(ctx, ref, itemName, baseURL, space)
if err != nil {
return spaceItems, err
}
if spaceItem != nil {
spaceItems = append(spaceItems, *spaceItem)
}
}
return spaceItems, nil
}
// generates a space root stat cache key used to detect changes in a space
// takes into account the special nodes because changing metadata does not affect the etag / mtime
func spaceRootStatKey(id *storageprovider.ResourceId, imagenode, readmeNode string) string {
if id == nil {
return ""
}
shakeHash := sha3.NewShake256()
_, _ = shakeHash.Write([]byte(id.GetStorageId()))
_, _ = shakeHash.Write([]byte(id.GetSpaceId()))
_, _ = shakeHash.Write([]byte(id.GetOpaqueId()))
_, _ = shakeHash.Write([]byte(imagenode))
_, _ = shakeHash.Write([]byte(readmeNode))
h := make([]byte, 64)
_, _ = shakeHash.Read(h)
return hex.EncodeToString(h)
}
type specialDriveItemEntry struct {
specialDriveItems []libregraph.DriveItem
rootMtime *types.Timestamp
}
func (g Graph) getSpecialDriveItem(ctx context.Context, ref *storageprovider.Reference, itemName string, baseURL *url.URL, space *storageprovider.StorageSpace) (*libregraph.DriveItem, error) {
var spaceItem *libregraph.DriveItem
if ref.GetResourceId().GetSpaceId() == "" && ref.GetResourceId().GetOpaqueId() == "" {
return nil, nil
}
// FIXME we should send a fieldmask 'path' and return it as the Path property to save an additional call to the storage.
// To do that we need to align the useg of ResourceInfo.Name vs ResourceInfo.Path. By default, only the name should be set
// and Path should always be relative to the space root OR the resource the current user can access ...
spaceItem, err := g.getDriveItem(ctx, ref)
if err != nil {
return nil, err
}
itemPath := ref.GetPath()
if itemPath == "" {
// lookup by id
itemPath, err = g.getPathForResource(ctx, *ref.GetResourceId())
if err != nil {
return nil, err
}
}
spaceItem.SpecialFolder = &libregraph.SpecialFolder{Name: libregraph.PtrString(itemName)}
webdavURL := *baseURL
webdavURL.Path = path.Join(webdavURL.Path, space.GetId().GetOpaqueId(), itemPath)
spaceItem.WebDavUrl = libregraph.PtrString(webdavURL.String())
return spaceItem, nil
}
@@ -0,0 +1,455 @@
package svc_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type itemsList struct {
Value []*libregraph.DriveItem
}
var _ = Describe("Driveitems", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
identityBackend *identitymocks.Backend
rr *httptest.ResponseRecorder
newGroup *libregraph.Group
currentUser = &userpb.User{
Id: &userpb.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityBackend = &identitymocks.Backend{}
newGroup = libregraph.NewGroup()
newGroup.SetMembersodataBind([]string{"/users/user1"})
newGroup.SetId("group1")
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{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetRootDriveChildren", func() {
It("handles ListStorageSpaces not found", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListStorageSpaces error", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewInternal(ctx, "internal error"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("handles ListContainer not found", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListContainer permission denied", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewPermissionDenied(ctx, errors.New("denied"), "denied"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("handles ListContainer error", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewInternal(ctx, "internal"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("succeeds", func() {
mtime := time.Now()
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
},
},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetLastModifiedDateTime().Equal(mtime)).To(BeTrue())
Expect(res.Value[0].GetETag()).To(Equal("etag"))
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
})
})
Describe("GetDriveItemChildren", func() {
It("handles ListContainer not found", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListContainer permission denied as not found", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewPermissionDenied(ctx, errors.New("denied"), "denied"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListContainer error", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewInternal(ctx, "internal"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
Context("it succeeds", func() {
var (
r *http.Request
mtime = time.Now()
)
BeforeEach(func() {
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
})
assertItemsList := func(length int) itemsList {
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(length))
Expect(res.Value[0].GetLastModifiedDateTime().Equal(mtime)).To(BeTrue())
Expect(res.Value[0].GetETag()).To(Equal("etag"))
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
return res
}
It("returns a generic file", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
ArbitraryMetadata: nil,
},
},
}, nil)
res := assertItemsList(1)
Expect(res.Value[0].Audio).To(BeNil())
Expect(res.Value[0].Location).To(BeNil())
})
It("returns the audio facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "audio/mpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.audio.album": "Some Album",
"libre.graph.audio.albumArtist": "Some AlbumArtist",
"libre.graph.audio.artist": "Some Artist",
"libre.graph.audio.bitrate": "192",
"libre.graph.audio.composers": "Some Composers",
"libre.graph.audio.copyright": "Some Copyright",
"libre.graph.audio.disc": "2",
"libre.graph.audio.discCount": "5",
"libre.graph.audio.duration": "225000",
"libre.graph.audio.genre": "Some Genre",
"libre.graph.audio.hasDrm": "false",
"libre.graph.audio.isVariableBitrate": "true",
"libre.graph.audio.title": "Some Title",
"libre.graph.audio.track": "6",
"libre.graph.audio.trackCount": "9",
"libre.graph.audio.year": "1994",
},
},
},
},
}, nil)
res := assertItemsList(1)
audio := res.Value[0].Audio
Expect(audio).ToNot(BeNil())
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(2)))
Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225000)))
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
Expect(audio.Track).To(Equal(libregraph.PtrInt32(6)))
Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
Expect(audio.Year).To(Equal(libregraph.PtrInt32(1994)))
})
It("returns the location facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "image/jpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.location.altitude": "1047.7",
"libre.graph.location.latitude": "49.48675890884328",
"libre.graph.location.longitude": "11.103870357204285",
},
},
},
},
}, nil)
res := assertItemsList(1)
location := res.Value[0].Location
Expect(location).ToNot(BeNil())
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(1047.7)))
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
})
It("returns the image facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "image/jpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.image.width": "1234",
"libre.graph.image.height": "987",
},
},
},
},
}, nil)
res := assertItemsList(1)
image := res.Value[0].Image
Expect(image).ToNot(BeNil())
Expect(image.Width).To(Equal(libregraph.PtrInt32(1234)))
Expect(image.Height).To(Equal(libregraph.PtrInt32(987)))
})
It("returns the photo facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "image/jpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.photo.cameraMake": "Canon",
"libre.graph.photo.cameraModel": "Cannon EOS 5D Mark III",
"libre.graph.photo.exposureDenominator": "100",
"libre.graph.photo.exposureNumerator": "1",
"libre.graph.photo.fNumber": "1.8",
"libre.graph.photo.focalLength": "50",
"libre.graph.photo.iso": "400",
"libre.graph.photo.orientation": "1",
"libre.graph.photo.takenDateTime": "2018-01-01T12:34:56Z",
},
},
},
},
}, nil)
res := assertItemsList(1)
photo := res.Value[0].Photo
Expect(photo).ToNot(BeNil())
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Cannon EOS 5D Mark III")))
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(100)))
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(400)))
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
})
})
})
})
@@ -0,0 +1,44 @@
package svc
import (
"net/url"
"testing"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/pkg/log"
)
func TestCS3ResourceToDriveItemPopulatesWebUrl(t *testing.T) {
logger := log.NewLogger()
res := &provider.ResourceInfo{
Id: &provider.ResourceId{
StorageId: "storage-1",
SpaceId: "space-1",
OpaqueId: "item-1",
},
Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER,
}
t.Run("public base URL without path", func(t *testing.T) {
base, err := url.Parse("https://example.com")
require.NoError(t, err)
item, err := cs3ResourceToDriveItem(&logger, base, res)
require.NoError(t, err)
require.NotNil(t, item.WebUrl)
assert.Equal(t, "https://example.com/f/storage-1$space-1%21item-1", *item.WebUrl)
})
t.Run("public base URL with path prefix", func(t *testing.T) {
base, err := url.Parse("https://example.com/cloud")
require.NoError(t, err)
item, err := cs3ResourceToDriveItem(&logger, base, res)
require.NoError(t, err)
require.NotNil(t, item.WebUrl)
assert.Equal(t, "https://example.com/cloud/f/storage-1$space-1%21item-1", *item.WebUrl)
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
package svc
import (
"testing"
"time"
"github.com/CiscoM31/godata"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type sortTest struct {
Drives []*libregraph.Drive
Query godata.GoDataRequest
DrivesSorted []*libregraph.Drive
}
var time1 = time.Date(2022, 02, 02, 15, 00, 00, 00, time.UTC)
var time2 = time.Date(2022, 02, 03, 15, 00, 00, 00, time.UTC)
var time3, time5, time6 *time.Time
var time4 = time.Date(2022, 02, 05, 15, 00, 00, 00, time.UTC)
var drives = []*libregraph.Drive{
drive("3", "project", "Admin", time3),
drive("1", "project", "Alan", &time1),
drive("2", "project", "Mary", &time2),
drive("4", "project", "Margaret", &time4),
}
var drivesLong = append(drives, []*libregraph.Drive{
drive("5", "project", "bob", time5),
drive("6", "project", "alice", time6),
}...)
var sortTests = []sortTest{
{
Drives: drives,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "name"}, Order: "asc"},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("3", "project", "Admin", time3),
drive("1", "project", "Alan", &time1),
drive("4", "project", "Margaret", &time4),
drive("2", "project", "Mary", &time2),
},
},
{
Drives: drives,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "name"}, Order: _sortDescending},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("2", "project", "Mary", &time2),
drive("4", "project", "Margaret", &time4),
drive("1", "project", "Alan", &time1),
drive("3", "project", "Admin", time3),
},
},
{
Drives: drivesLong,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: "asc"},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("3", "project", "Admin", time3),
drive("6", "project", "alice", time6),
drive("5", "project", "bob", time5),
drive("1", "project", "Alan", &time1),
drive("2", "project", "Mary", &time2),
drive("4", "project", "Margaret", &time4),
},
},
{
Drives: drivesLong,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: _sortDescending},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("4", "project", "Margaret", &time4),
drive("2", "project", "Mary", &time2),
drive("1", "project", "Alan", &time1),
drive("5", "project", "bob", time5),
drive("6", "project", "alice", time6),
drive("3", "project", "Admin", time3),
},
},
}
func drive(ID string, dType string, name string, lastModified *time.Time) *libregraph.Drive {
return &libregraph.Drive{Id: libregraph.PtrString(ID), DriveType: libregraph.PtrString(dType), Name: name, LastModifiedDateTime: lastModified, Quota: &libregraph.Quota{}}
}
// TestSort tests the available orderby queries
func TestSort(t *testing.T) {
for _, test := range sortTests {
sorted, err := sortSpaces(&test.Query, test.Drives)
assert.NoError(t, err)
assert.Equal(t, test.DrivesSorted, sorted)
}
}
// TestSortNameNatural verifies that sorting drives by name uses a natural
// (numeric-aware) order, so "Project 10" sorts after "Project 2".
func TestSortNameNatural(t *testing.T) {
input := []*libregraph.Drive{
drive("a", "project", "Project 10", nil),
drive("b", "project", "Project 2", nil),
drive("c", "project", "Project 1", nil),
drive("d", "project", "Project 10a", nil),
}
want := []string{"Project 1", "Project 2", "Project 10", "Project 10a"}
query := godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "name"}, Order: "asc"},
},
},
},
}
sorted, err := sortSpaces(&query, input)
require.NoError(t, err)
got := make([]string, 0, len(sorted))
for _, d := range sorted {
got = append(got, d.GetName())
}
assert.Equal(t, want, got)
// Descending must invert the natural order.
query.Query.OrderBy.OrderByItems[0].Order = _sortDescending
sortedDesc, err := sortSpaces(&query, input)
require.NoError(t, err)
gotDesc := make([]string, 0, len(sortedDesc))
for _, d := range sortedDesc {
gotDesc = append(gotDesc, d.GetName())
}
assert.Equal(t, []string{"Project 10a", "Project 10", "Project 2", "Project 1"}, gotDesc)
}
func TestSpaceNameValidation(t *testing.T) {
// set max length
_maxSpaceNameLength = 10
testCases := []struct {
Alias string
SpaceName string
ExpectedError error
}{
{"Happy Path", "Space", nil},
{"Just not too Long", "abcdefghij", nil},
{"Too Long", "abcdefghijk", ErrNameTooLong},
{"Empty", "", ErrNameEmpty},
{"Contains /", "Space/", ErrForbiddenCharacter},
{`Contains \`, `Space\`, ErrForbiddenCharacter},
{`Contains \\`, `Space\\`, ErrForbiddenCharacter},
{"Contains .", "Space.", ErrForbiddenCharacter},
{"Contains :", "Space:", ErrForbiddenCharacter},
{"Contains ?", "Sp?ace", ErrForbiddenCharacter},
{"Contains *", "Spa*ce", ErrForbiddenCharacter},
{`Contains "`, `"Space"`, ErrForbiddenCharacter},
{`Contains >`, `Sp>ce`, ErrForbiddenCharacter},
{`Contains <`, `S<pce`, ErrForbiddenCharacter},
{`Contains |`, `S|p|e`, ErrForbiddenCharacter},
}
for _, tc := range testCases {
err := validateSpaceName(tc.SpaceName)
require.Equal(t, tc.ExpectedError, err, tc.Alias)
}
// set max length back to protect other tests
_maxSpaceNameLength = 255
}
@@ -0,0 +1,578 @@
package svc
import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/CiscoM31/godata"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// GetEducationClasses implements the Service interface.
func (g Graph) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling GetEducationClasses")
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 educationClasses: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
classes, err := g.identityEducationBackend.GetEducationClasses(r.Context())
if err != nil {
logger.Debug().Err(err).Msg("could not get classes: backend error")
errorcode.RenderError(w, r, err)
return
}
classes, err = sortClasses(odataReq, classes)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get classes: could not sort classes according to query")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: classes})
}
// PostEducationClass implements the Service interface.
func (g Graph) PostEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling post EducationClass")
class := libregraph.NewEducationClassWithDefaults()
err := StrictJSONUnmarshal(r.Body, class)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education class: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
if _, ok := class.GetDisplayNameOk(); !ok {
logger.Debug().Err(err).Interface("class", class).Msg("could not create class: 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.
if _, ok := class.GetIdOk(); ok {
logger.Debug().Msg("could not create class: id is a read-only attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "class id is a read-only attribute")
return
}
if class, err = g.identityEducationBackend.CreateEducationClass(r.Context(), *class); err != nil {
logger.Debug().Interface("class", class).Msg("could not create class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
if class != nil && class.Id != nil {
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassCreated{Executant: currentUser.Id, EducationClassID: *class.Id})
}
*/
render.Status(r, http.StatusCreated)
render.JSON(w, r, class)
}
// PatchEducationClass implements the Service interface.
func (g Graph) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling patch education class")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not change class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not change class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
changes := libregraph.NewEducationClassWithDefaults()
err = StrictJSONUnmarshal(r.Body, changes)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change class: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
var features []events.GroupFeature
if displayName, ok := changes.GetDisplayNameOk(); ok {
features = append(features, events.GroupFeature{Name: "displayname", Value: *displayName})
}
if externalID, ok := changes.GetExternalIdOk(); ok {
features = append(features, events.GroupFeature{Name: "externalid", Value: *externalID})
}
_, err = g.identityEducationBackend.UpdateEducationClass(r.Context(), classID, *changes)
if err != nil {
logger.Error().
Err(err).
Str("classID", classID).
Msg("could not update class")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
// The spec defines a limit of 20 members maxium per Request
if len(memberRefs) > g.config.API.GroupMembersPatchLimit {
logger.Debug().
Int("number", len(memberRefs)).
Int("limit", g.config.API.GroupMembersPatchLimit).
Msg("could not add group members, exceeded members limit")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
return
}
memberIDs := make([]string, 0, len(memberRefs))
for _, memberRef := range memberRefs {
memberType, id, err := g.parseMemberRef(memberRef)
if err != nil {
logger.Debug().
Str("memberref", memberRef).
Msg("could not change class: Error parsing member@odata.bind values")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values")
return
}
logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add class member")
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
// we restrict this to users for now. Might add Classes as members later
if memberType != memberTypeUsers {
logger.Debug().
Str("type", memberType).
Msg("could not change class: could not add member, only user type is allowed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as class members")
return
}
memberIDs = append(memberIDs, id)
}
err = g.identityBackend.AddMembersToGroup(r.Context(), classID, memberIDs)
}
if err != nil {
logger.Debug().Err(err).Msg("could not change class: backend could not add members")
errorcode.RenderError(w, r, err)
return
}
if len(features) > 0 {
e := events.GroupFeatureChanged{
GroupID: classID,
Features: features,
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
}
render.Status(r, http.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body
render.NoContent(w, r)
}
// GetEducationClass implements the Service interface.
func (g Graph) GetEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling get education class")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not get class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
}
if classID == "" {
logger.Debug().Msg("could not get class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().
Str("id", classID).
Interface("query", r.URL.Query()).
Msg("calling get class on backend")
class, err := g.identityEducationBackend.GetEducationClass(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, class)
}
// DeleteEducationClass implements the Service interface.
func (g Graph) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling delete class")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("id", classID).Msg("calling delete class on backend")
err = g.identityEducationBackend.DeleteEducationClass(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.ClassDeleted{Executant: currentUser.Id, ClassID: classID})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationClassMembers implements the Service interface.
func (g Graph) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling get class members")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not get class members: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not get class members: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("id", classID).Msg("calling get class members on backend")
members, err := g.identityEducationBackend.GetEducationClassMembers(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not get class members: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, members)
}
// PostEducationClassMember implements the Service interface.
func (g Graph) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("Calling post class member")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().
Err(err).
Str("id", classID).
Msg("could not add member to class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not add class member: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add class member: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add class member: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add class member: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
// we restrict this to users for now. Might add EducationClass as members later
if memberType != memberTypeUsers {
logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class members")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class members")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
err = g.identityBackend.AddMembersToGroup(r.Context(), classID, []string{id})
if err != nil {
logger.Debug().Err(err).Msg("could not add class member: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassMemberAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationClassMember implements the Service interface.
func (g Graph) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
memberID := chi.URLParam(r, "memberID")
logger.Info().Str("classID", classID).Str("memberID", memberID).Msg("calling delete class member")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class member: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete class member: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
memberID, err = url.PathUnescape(memberID)
if err != nil {
logger.Debug().Err(err).Str("id", memberID).Msg("could not delete class member: unescaping member id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
return
}
if memberID == "" {
logger.Debug().Msg("could not delete class member: missing member id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
return
}
logger.Debug().Str("classID", classID).Str("memberID", memberID).Msg("calling delete member on backend")
err = g.identityBackend.RemoveMemberFromGroup(r.Context(), classID, memberID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete class member: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassMemberRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: memberID})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationClassTeachers implements the Service interface.
func (g Graph) GetEducationClassTeachers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling get class teachers")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not get class teachers: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not get class teachers: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("id", classID).Msg("calling get class teachers on backend")
teachers, err := g.identityEducationBackend.GetEducationClassTeachers(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not get class teachers: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, teachers)
}
// PostEducationClassTeacher implements the Service interface.
func (g Graph) PostEducationClassTeacher(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("Calling post class teacher")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().
Err(err).
Str("id", classID).
Msg("could not add teacher to class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not add class teacher: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add class teacher: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add class teacher: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add class teacher: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
// we restrict this to users for now. Might add EducationClass as members later
if memberType != memberTypeUsers {
logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class teachers")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class teachers")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add teacher on backend")
err = g.identityEducationBackend.AddTeacherToEducationClass(r.Context(), classID, id)
if err != nil {
logger.Debug().Err(err).Msg("could not add class teacher: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassTeacherAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationClassTeacher implements the Service interface.
func (g Graph) DeleteEducationClassTeacher(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
teacherID := chi.URLParam(r, "teacherID")
logger.Info().Str("classID", classID).Str("teacherID", teacherID).Msg("calling delete class teacher")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class teacher: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete class teacher: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
teacherID, err = url.PathUnescape(teacherID)
if err != nil {
logger.Debug().Err(err).Str("id", teacherID).Msg("could not delete class teacher: unescaping teacher id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping teacher id failed")
return
}
if teacherID == "" {
logger.Debug().Msg("could not delete class teacher: missing teacher id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing teacher id")
return
}
logger.Debug().Str("classID", classID).Str("teacherID", teacherID).Msg("calling delete teacher on backend")
err = g.identityEducationBackend.RemoveTeacherFromEducationClass(r.Context(), classID, teacherID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete class teacher: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassTeacherRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: teacherID})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
func sortClasses(req *godata.GoDataRequest, classes []*libregraph.EducationClass) ([]*libregraph.EducationClass, error) {
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return classes, 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(classes[i].GetDisplayName()) < strings.ToLower(classes[j].GetDisplayName())
}
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(classes, reverse(less))
} else {
sort.Slice(classes, less)
}
return classes, nil
}
@@ -0,0 +1,658 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
var _ = Describe("EducationClass", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
identityBackend *identitymocks.Backend
identityEducationBackend *identitymocks.EducationBackend
rr *httptest.ResponseRecorder
newClass *libregraph.EducationClass
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityEducationBackend = &identitymocks.EducationBackend{}
identityBackend = &identitymocks.Backend{}
newClass = libregraph.NewEducationClass("math", "course")
newClass.SetMembersodataBind([]string{"/users/user1"})
newClass.SetId("math")
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{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithIdentityEducationBackend(identityEducationBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetEducationClasses", func() {
It("handles invalid ODATA parameters", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?§foo=bar", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid sorting queries", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?$orderby=invalid", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
})
It("handles unknown backend errors", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errors.New("failed"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("generalException"))
})
It("handles backend errors", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
})
It("renders an empty list of classes", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := service.ListResponse{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(res.Value).To(Equal([]any{}))
})
It("renders a list of classes", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := groupList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetId()).To(Equal("math"))
})
})
Describe("GetEducationClass", func() {
It("handles missing or empty class id", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", "")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing class", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
})
It("gets the class", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/"+*newClass.Id, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("PostEducationClass", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing display name", func() {
newClass = libregraph.NewEducationClassWithDefaults()
newClass.SetId("disallowed")
newClass.SetMembersodataBind([]string{"/non-users/user"})
newClassJson, err := json.Marshal(newClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("disallows group create ids", func() {
newClass = libregraph.NewEducationClassWithDefaults()
newClass.SetId("disallowed")
newClass.SetDisplayName("New Class")
newClass.SetMembersodataBind([]string{"/non-users/user"})
newClassJson, err := json.Marshal(newClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("creates the Class", func() {
newClass = libregraph.NewEducationClassWithDefaults()
newClass.SetDisplayName("New Class")
newClassJson, err := json.Marshal(newClass)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("CreateEducationClass", mock.Anything, mock.Anything).Return(newClass, nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/class/", bytes.NewBuffer(newClassJson))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusCreated))
})
})
Describe("PatchEducationClass", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty group id", func() {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", "")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing group", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
identityEducationBackend.On("UpdateEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
})
It("fails when the number of users is exceeded - spec says 20 max", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("class updated")
updatedClass.SetMembersodataBind([]string{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
"19", "20", "21",
})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
resp, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(resp)).To(ContainSubstring("Request is limited to 20"))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("succeeds when the number of users is over 20 but the limit is raised to 21", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("group1 updated")
updatedClass.SetMembersodataBind([]string{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
"19", "20", "21",
})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
cfg.API.GroupMembersPatchLimit = 21
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithIdentityEducationBackend(identityEducationBackend),
)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
resp, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values"))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid user refs", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("class updated")
updatedClass.SetMembersodataBind([]string{"invalid"})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails when the adding non-users users", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("group1 updated")
updatedClass.SetMembersodataBind([]string{"/non-users/user1"})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds members to the class", func() {
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("Class updated")
updatedClass.SetMembersodataBind([]string{"/users/user1"})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
})
})
})
Describe("DeleteEducationClass", func() {
Context("with an existing EducationClass", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
})
})
It("deletes the EducationClass", func() {
identityEducationBackend.On("DeleteEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationClass", 1)
// eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1)
})
})
Describe("GetEducationClassMembers", func() {
It("gets the list of members", func() {
user := libregraph.NewEducationUser()
user.SetId("user")
identityEducationBackend.On("GetEducationClassMembers", mock.Anything, mock.Anything, mock.Anything).
Return([]*libregraph.EducationUser{user}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/members", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationClassMembers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var members []*libregraph.EducationUser
err = json.Unmarshal(data, &members)
Expect(err).ToNot(HaveOccurred())
Expect(len(members)).To(Equal(1))
Expect(members[0].GetId()).To(Equal("user"))
})
})
Describe("PostEducationClassMember", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing member refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid member refs", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/invalidtype/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new member", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/users/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
})
})
Describe("DeleteEducationClassMembers", func() {
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("memberID", "/users/user")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes members", func() {
identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
rctx.URLParams.Add("memberID", "/users/user1")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1)
})
})
Describe("GetEducationClassTeachers", func() {
It("gets the list of teachers", func() {
user := libregraph.NewEducationUser()
user.SetId("user")
identityEducationBackend.On("GetEducationClassTeachers", mock.Anything, mock.Anything, mock.Anything).
Return([]*libregraph.EducationUser{user}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/teachers", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationClassTeachers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var teachers []*libregraph.EducationUser
err = json.Unmarshal(data, &teachers)
Expect(err).ToNot(HaveOccurred())
Expect(len(teachers)).To(Equal(1))
Expect(teachers[0].GetId()).To(Equal("user"))
})
})
Describe("PostEducationClassTeacher", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing teacher refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid teacher refs", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/invalidtype/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new teacher", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/users/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("AddTeacherToEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddTeacherToEducationClass", 1)
})
})
Describe("DeleteEducationClassTeacher", func() {
It("handles missing or empty teacher id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty teacher id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("teacherID", "/users/user")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes teacher", func() {
identityEducationBackend.On("RemoveTeacherFromEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
rctx.URLParams.Add("teacherID", "/users/user1")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveTeacherFromEducationClass", 1)
})
})
})
@@ -0,0 +1,631 @@
package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/CiscoM31/godata"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// GetEducationSchools implements the Service interface.
func (g Graph) GetEducationSchools(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling get schools")
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 schools: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
schools, err := g.getEducationSchoolsFromBackend(r.Context(), odataReq)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools")
renderEqualityFilterError(w, r, err)
return
}
schools, err = sortEducationSchools(odataReq, schools)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get schools: could not sort schools according to query")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: schools})
}
// PostEducationSchool implements the Service interface.
func (g Graph) PostEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling post school")
school := libregraph.NewEducationSchool()
err := StrictJSONUnmarshal(r.Body, school)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create school: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", 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 := school.GetIdOk(); ok {
logger.Debug().Msg("could not create school: id is a read-only attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "school id is a read-only attribute")
return
}
if _, ok := school.GetDisplayNameOk(); !ok {
logger.Debug().Interface("school", school).Msg("could not create school: missing required attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
return
}
// validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means
// termination date is to be deleted
if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil {
err = g.validateTerminationDate(*terminationDate)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
}
return
}
if school, err = g.identityEducationBackend.CreateEducationSchool(r.Context(), *school); err != nil {
logger.Debug().Err(err).Interface("school", school).Msg("could not create school: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
if school != nil && school.Id != nil {
e := events.SchoolCreated{SchoolID: *school.Id}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
}
*/
render.Status(r, http.StatusCreated)
render.JSON(w, r, school)
}
// PatchEducationSchool implements the Service interface.
func (g Graph) PatchEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling patch school")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not update school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not update school: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
school := libregraph.NewEducationSchool()
err = StrictJSONUnmarshal(r.Body, school)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update school: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
// validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means
// termination date is to be deleted
if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil {
err = g.validateTerminationDate(*terminationDate)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
}
if school, err = g.identityEducationBackend.UpdateEducationSchool(r.Context(), schoolID, *school); err != nil {
logger.Debug().Err(err).Interface("school", school).Msg("could not update school: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolFeatureChanged{SchoolID: schoolID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusOK)
render.JSON(w, r, school)
}
// GetEducationSchool implements the Service interface.
func (g Graph) GetEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling get school")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not get school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
}
if schoolID == "" {
logger.Debug().Msg("could not get school: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
logger.Debug().
Str("id", schoolID).
Interface("query", r.URL.Query()).
Msg("calling get school on backend")
school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, school)
}
// DeleteEducationSchool implements the Service interface.
func (g Graph) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling delete school")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not delete school: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
// Read school and check if termination date is set
school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school: backend error")
errorcode.RenderError(w, r, err)
return
}
termination, ok := school.GetTerminationDateOk()
if !ok {
logger.Debug().Msg("cannot delete school: not termination date set")
errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "no termination date set")
return
}
if time.Now().Before(*termination) {
logger.Debug().Time("terminationDate", *termination).Msg("cannot delete school: termination date not reached")
errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "can't delete school before termination date")
return
}
logger.Debug().Str("schoolID", schoolID).Msg("Getting users of school")
users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school users: backend error")
errorcode.RenderError(w, r, err)
return
}
for _, user := range users {
logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("calling delete member on backend")
if err := g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, *user.Id); err != nil {
if errors.Is(err, identity.ErrNotFound) {
logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("user not found")
continue
}
logger.Debug().Err(err).Msg("could not delete school member: backend error")
errorcode.RenderError(w, r, err)
// TODO Do we need return right hear?
}
}
logger.Debug().Str("id", schoolID).Msg("calling delete school on backend")
err = g.identityEducationBackend.DeleteEducationSchool(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete school: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolDeleted{SchoolID: schoolID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationSchoolUsers implements the Service interface.
func (g Graph) GetEducationSchoolUsers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling get school users")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not get school users: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
logger.Debug().Str("id", schoolID).Msg("calling get school users on backend")
users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school users: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, users)
}
// PostEducationSchoolUser implements the Service interface.
func (g Graph) PostEducationSchoolUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("Calling post school user")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().
Err(err).
Str("id", schoolID).
Msg("could not add user to school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not add school user: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add school user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add school user: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add school user: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact"
// we restrict this to users for now. Might add Schools as members later
if memberType != "users" {
logger.Debug().Str("type", memberType).Msg("could not add school user: Only users are allowed as school members")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as school members")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add user on backend")
err = g.identityEducationBackend.AddUsersToEducationSchool(r.Context(), schoolID, []string{id})
if err != nil {
logger.Debug().Err(err).Msg("could not add school user: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationSchoolUser implements the Service interface.
func (g Graph) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
userID := chi.URLParam(r, "userID")
logger.Info().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete school member")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school member: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not delete school member: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
userID, err = url.PathUnescape(userID)
if err != nil {
logger.Debug().Err(err).Str("id", userID).Msg("could not delete school member: unescaping member id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
return
}
if userID == "" {
logger.Debug().Msg("could not delete school member: missing member id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
return
}
logger.Debug().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete member on backend")
err = g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, userID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete school member: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationSchoolClasses implements the Service interface.
func (g Graph) GetEducationSchoolClasses(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling get school classes")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not get school users: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
logger.Debug().Str("id", schoolID).Msg("calling get school classes on backend")
classes, err := g.identityEducationBackend.GetEducationSchoolClasses(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school classes: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, classes)
}
// PostEducationSchoolClass implements the Service interface.
func (g Graph) PostEducationSchoolClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("Calling post school class")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().
Err(err).
Str("id", schoolID).
Msg("could not add class to school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not add school class: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add school class: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add school class: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add school class: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact"
// we restrict this to users for now. Might add Schools as members later
if memberType != "classes" {
logger.Debug().Str("type", memberType).Msg("could not add school class: Only classes are allowed as school members")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only classes are allowed as school members")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add class on backend")
err = g.identityEducationBackend.AddClassesToEducationSchool(r.Context(), schoolID, []string{id})
if err != nil {
logger.Debug().Err(err).Msg("could not add school class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationSchoolClass implements the Service interface.
func (g Graph) DeleteEducationSchoolClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
classID := chi.URLParam(r, "classID")
logger.Info().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete school class")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school class: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not delete school class: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
classID, err = url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete school class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete school class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete class on backend")
err = g.identityEducationBackend.RemoveClassFromEducationSchool(r.Context(), schoolID, classID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete school class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
func (g Graph) validateTerminationDate(terminationDate time.Time) error {
if terminationDate.Before(time.Now()) {
return fmt.Errorf("can not set a termination date in the past")
}
graceDays := g.config.Identity.LDAP.EducationConfig.SchoolTerminationGraceDays
if graceDays != 0 {
if terminationDate.Before(time.Now().Add(time.Duration(graceDays) * 24 * time.Hour)) {
return fmt.Errorf("termination needs to be at least %d day(s) in the future", graceDays)
}
}
return nil
}
func sortEducationSchools(req *godata.GoDataRequest, schools []*libregraph.EducationSchool) ([]*libregraph.EducationSchool, error) {
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return schools, 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(schools[i].GetDisplayName()) < strings.ToLower(schools[j].GetDisplayName())
}
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(schools, reverse(less))
} else {
sort.Slice(schools, less)
}
return schools, nil
}
// getEducationSchoolsFromBackend fetches schools from the backend, applying an OData $filter if present.
func (g Graph) getEducationSchoolsFromBackend(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.EducationSchool, error) {
if odataReq.Query.Filter != nil {
attr, value, err := g.getEqualityFilter(ctx, odataReq)
if err != nil {
return nil, err
}
return g.identityEducationBackend.FilterEducationSchoolsByAttribute(ctx, attr, value)
}
return g.identityEducationBackend.GetEducationSchools(ctx)
}
@@ -0,0 +1,618 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type schoolList struct {
Value []*libregraph.EducationSchool
}
var _ = Describe("Schools", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
identityEducationBackend *identitymocks.EducationBackend
rr *httptest.ResponseRecorder
newSchool *libregraph.EducationSchool
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityEducationBackend = &identitymocks.EducationBackend{}
newSchool = libregraph.NewEducationSchool()
newSchool.SetId("school1")
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.Identity.LDAP.EducationConfig.SchoolTerminationGraceDays = 30
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.WithIdentityEducationBackend(identityEducationBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetEducationSchools", func() {
It("handles invalid ODATA parameters", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?§foo=bar", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid sorting queries", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$orderby=invalid", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
})
It("handles unknown backend errors", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errors.New("failed"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("generalException"))
})
It("handles backend errors", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
})
It("renders an empty list of schools", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := service.ListResponse{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(res.Value).To(Equal([]any{}))
})
It("renders a list of schools", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := schoolList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetId()).To(Equal("school1"))
})
When("used with a filter", func() {
It("fails with an unsupported filter", func() {
svc.GetEducationSchools(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$filter="+url.QueryEscape("displayName ne 'test'"), nil))
Expect(rr.Code).To(Equal(http.StatusNotImplemented))
})
It("calls the backend with the externalId filter", func() {
identityEducationBackend.On("FilterEducationSchoolsByAttribute", mock.Anything, "externalId", "ext1234").Return([]*libregraph.EducationSchool{newSchool}, nil)
svc.GetEducationSchools(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$filter="+url.QueryEscape("externalId eq 'ext1234'"), nil))
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("GetEducationSchool", func() {
It("handles missing or empty school id", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", "")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing school", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(newSchool, nil)
})
It("gets the school", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/"+*newSchool.Id, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("PostEducationSchool", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBufferString("{invalid"))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing display name", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("disallows school create ids", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetId("disallowed")
newSchool.SetDisplayName("New School")
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles backend errors", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetDisplayName("New School")
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("creates the school", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetDisplayName("New School")
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(newSchool, nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusCreated))
})
})
Describe("updating a School", func() {
schoolUpdate := libregraph.NewEducationSchool()
schoolUpdate.SetDisplayName("New School Name")
schoolUpdateJson, _ := json.Marshal(schoolUpdate)
schoolUpdatePast := libregraph.NewEducationSchool()
schoolUpdatePast.SetTerminationDate(time.Now().Add(-time.Hour * 1))
schoolUpdatePastJson, _ := json.Marshal(schoolUpdatePast)
schoolUpdateBeforeGrace := libregraph.NewEducationSchool()
schoolUpdateBeforeGrace.SetTerminationDate(time.Now().Add(24 * 10 * time.Hour))
schoolUpdateBeforeGraceJson, _ := json.Marshal(schoolUpdateBeforeGrace)
schoolUpdatePastGrace := libregraph.NewEducationSchool()
schoolUpdatePastGrace.SetTerminationDate(time.Now().Add(24 * 31 * time.Hour))
schoolUpdatePastGraceJson, _ := json.Marshal(schoolUpdatePastGrace)
BeforeEach(func() {
identityEducationBackend.On("UpdateEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(schoolUpdate, nil)
})
DescribeTable("PatchEducationSchool",
func(schoolId string, body io.Reader, statusCode int) {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools/", body)
rctx := chi.NewRouteContext()
if schoolId != "" {
rctx.URLParams.Add("schoolID", schoolId)
}
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationSchool(rr, r)
Expect(rr.Code).To(Equal(statusCode))
},
Entry("handles invalid body", "school-id", bytes.NewBufferString("{invalid"), http.StatusBadRequest),
Entry("handles missing or empty school id", "", bytes.NewBufferString(""), http.StatusBadRequest),
Entry("handles malformed school id", "school%id", bytes.NewBuffer(schoolUpdateJson), http.StatusBadRequest),
Entry("updates the school", "school-id", bytes.NewBuffer(schoolUpdateJson), http.StatusOK),
Entry("fails to set a termination date in the past", "school-id", bytes.NewBuffer(schoolUpdatePastJson), http.StatusBadRequest),
Entry("fails to set a termination date before grace period", "school-id", bytes.NewBuffer(schoolUpdateBeforeGraceJson), http.StatusBadRequest),
Entry("succeeds to set a termination date past the grace period", "school-id", bytes.NewBuffer(schoolUpdatePastGraceJson), http.StatusOK),
)
})
Describe("DeleteEducationSchool", func() {
schoolWithFutureTermination := libregraph.NewEducationSchool()
schoolWithFutureTermination.SetId("schoolWithFutureTermination")
schoolWithFutureTermination.SetTerminationDate(time.Now().Add(time.Hour * 24))
schoolWithPastTermination := libregraph.NewEducationSchool()
schoolWithPastTermination.SetId("schoolWithPastTermination")
schoolWithPastTermination.SetTerminationDate(time.Now().Add(-time.Hour * 24))
Context("with an existing school", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationSchool", mock.Anything, "school1").Return(newSchool, nil)
identityEducationBackend.On("GetEducationSchool", mock.Anything, "schoolWithFutureTermination", mock.Anything).Return(schoolWithFutureTermination, nil)
identityEducationBackend.On("GetEducationSchool", mock.Anything, "schoolWithPastTermination", mock.Anything).Return(schoolWithPastTermination, nil)
})
DescribeTable("checks terminnation date",
func(schoolId string, statusCode int) {
identityEducationBackend.On("DeleteEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{}, nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", schoolId)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchool(rr, r)
Expect(rr.Code).To(Equal(statusCode))
if rr.Code == http.StatusNoContent {
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 1)
} else {
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 0)
}
},
Entry("fails when school has no termination date", "school1", http.StatusMethodNotAllowed),
Entry("fails when school has a termination date in the future", "schoolWithFutureTermination", http.StatusMethodNotAllowed),
Entry("succeeds when school has a termination date in the past", "schoolWithPastTermination", http.StatusNoContent),
)
It("removes the users from the school", func() {
user1 := libregraph.NewEducationUser()
user1.SetId("user1")
user2 := libregraph.NewEducationUser()
user2.SetId("user2")
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user1, user2}, nil)
identityEducationBackend.On("DeleteEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, *user1.Id).Return(nil)
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, *user2.Id).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", "schoolWithPastTermination")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 1)
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveUserFromEducationSchool", 2)
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "GetEducationSchoolUsers", 1)
})
})
})
Describe("GetEducationSchoolUsers", func() {
It("gets the list of members", func() {
user := libregraph.NewEducationUser()
user.SetOnPremisesSamAccountName("user")
user.SetDisplayName("display name")
user.SetMail("mail")
user.SetId("user")
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/users", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationSchoolUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var members []*libregraph.User
err = json.Unmarshal(data, &members)
Expect(err).ToNot(HaveOccurred())
Expect(len(members)).To(Equal(1))
Expect(members[0].GetId()).To(Equal("user"))
})
})
Describe("PostEducationSchoolUsers", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing member refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid member refs", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/invalidtype/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new member", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/users/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("AddUsersToEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddUsersToEducationSchool", 1)
})
})
Describe("DeleteEducationSchoolUsers", func() {
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", "/users/user")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes members", func() {
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
rctx.URLParams.Add("userID", "/users/user1")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveUserFromEducationSchool", 1)
})
})
Describe("GetEducationSchoolClasses", func() {
It("gets the list of classes", func() {
class := libregraph.NewEducationClassWithDefaults()
class.SetId("class")
identityEducationBackend.On("GetEducationSchoolClasses", mock.Anything, *newSchool.Id).Return([]*libregraph.EducationClass{class}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationSchoolClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var classes []*libregraph.EducationClass
err = json.Unmarshal(data, &classes)
Expect(err).ToNot(HaveOccurred())
Expect(len(classes)).To(Equal(1))
Expect(classes[0].GetId()).To(Equal("class"))
})
})
Describe("PostEducationSchoolClasses", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classes", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing member refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classes", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid class refs", func() {
class := libregraph.NewMemberReference()
class.SetOdataId("/invalidtype/class")
data, err := json.Marshal(class)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classesa", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new class", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/classes/class")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("AddClassesToEducationSchool", mock.Anything, *newSchool.Id, []string{"class"}).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddClassesToEducationSchool", 1)
})
})
Describe("DeleteEducationSchoolClass", func() {
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/classes/{classID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes members", func() {
identityEducationBackend.On("RemoveClassFromEducationSchool", mock.Anything, *newSchool.Id, "/classes/class1").Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/classes/{classID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
rctx.URLParams.Add("classID", "/classes/class1")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveClassFromEducationSchool", 1)
})
})
})
@@ -0,0 +1,441 @@
package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/CiscoM31/godata"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// GetEducationUsers implements the Service interface.
func (g Graph) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context()).With().Str("func", "GetEducationUsers").Logger()
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
}
users, err := g.getEducationUsersFromBackend(r.Context(), odataReq)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users")
renderEqualityFilterError(w, r, err)
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 := StrictJSONUnmarshal(r.Body, 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
}
for i, identity := range u.GetIdentities() {
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 !g.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
}
}
if u.HasUserType() {
if !isValidUserType(*u.UserType) {
logger.Debug().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 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")
errorcode.RenderError(w, r, err)
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.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())
userID := chi.URLParam(r, "userID")
logger.Info().Str("userID", userID).Msg("calling get education user")
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)
if err != nil {
logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
errorcode.RenderError(w, r, err)
return
}
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())
userID := chi.URLParam(r, "userID")
logger.Info().Str("userID", userID).Msg("calling delete education user")
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)
if err != nil {
logger.Debug().Err(err).Str("userID", userID).Msg("failed to get education 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 education 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("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select 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 == _spaceTypePersonal && 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[_spaceStateTrashed]; !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 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")
errorcode.RenderError(w, r, err)
return
}
g.publishEvent(r.Context(), 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())
nameOrID := chi.URLParam(r, "userID")
logger.Info().Str("userID", nameOrID).Msg("calling patch education user")
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 = StrictJSONUnmarshal(r.Body, 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
}
if accountName, ok := changes.GetOnPremisesSamAccountNameOk(); ok {
if !g.isValidUsername(*accountName) {
logger.Debug().Str("username", *accountName).Msg("could not update 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", *changes.OnPremisesSamAccountName))
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})
}
if changes.HasUserType() {
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
}
}
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")
errorcode.RenderError(w, r, err)
return
}
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)
}
func sortEducationUsers(req *godata.GoDataRequest, users []*libregraph.EducationUser) ([]*libregraph.EducationUser, 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
}
// getEducationUsersFromBackend fetches users from the backend, applying an OData $filter if present.
func (g Graph) getEducationUsersFromBackend(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.EducationUser, error) {
if odataReq.Query.Filter != nil {
attr, value, err := g.getEqualityFilter(ctx, odataReq)
if err != nil {
return nil, err
}
return g.identityEducationBackend.FilterEducationUsersByAttribute(ctx, attr, value)
}
return g.identityEducationBackend.GetEducationUsers(ctx)
}
func (g Graph) getEqualityFilter(ctx context.Context, req *godata.GoDataRequest) (string, string, error) {
logger := g.logger.SubloggerWithRequestID(ctx)
root := req.Query.Filter.Tree
if root.Token.Type != godata.ExpressionTokenLogical {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
if root.Token.Value != "eq" {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
if len(root.Children) != 2 {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
if root.Children[0].Token.Type != godata.ExpressionTokenLiteral || root.Children[1].Token.Type != godata.ExpressionTokenString {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
// unquote
value := strings.Trim(root.Children[1].Token.Value, "'")
return root.Children[0].Token.Value, value, nil
}
// renderEqualityFilterError writes the appropriate HTTP error response for errors returned by getEqualityFilter.
func renderEqualityFilterError(w http.ResponseWriter, r *http.Request, err error) {
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())
}
}
@@ -0,0 +1,553 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
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"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/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 *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
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)
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityEducationBackend = &identitymocks.EducationBackend{}
roleService = &mocks.RoleService{}
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{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityEducationBackend(identityEducationBackend),
service.WithRoleService(roleService),
)
Expect(err).ToNot(HaveOccurred())
})
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))
})
When("used with a filter", func() {
It("fails with an unsupported filter ", func() {
svc.GetEducationUsers(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$filter="+url.QueryEscape("displayName ne 'test'"), nil))
Expect(rr.Code).To(Equal(http.StatusNotImplemented))
})
It("calls the backend with the filter", func() {
identityEducationBackend.On("FilterEducationUsersByAttribute", mock.Anything, "externalId", "id1234").Return([]*libregraph.EducationUser{}, nil)
svc.GetEducationUsers(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$filter="+url.QueryEscape("externalId eq 'id1234'"), nil))
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
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"))
})
})
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.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))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetUserType()).To(Equal("Member"))
})
It("creates a guest 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)
user.SetUserType("Guest")
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))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetUserType()).To(Equal("Guest"))
})
It("creates a member 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)
user.SetUserType("Member")
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))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetUserType()).To(Equal("Member"))
})
It("creates a user without email", 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)
user.Mail = 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))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetMail()).To(Equal(""))
})
})
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")
user.SetAccountEnabled(true)
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("handles invalid userType", func() {
user.SetUserType("Clown")
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")
user.SetAccountEnabled(false)
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"))
Expect(updatedUser.GetAccountEnabled()).To(Equal(false))
})
})
})
@@ -0,0 +1,5 @@
package svc
var (
CS3ReceivedShareToLibreGraphPermissions = cs3ReceivedShareToLibreGraphPermissions
)
@@ -0,0 +1,173 @@
package svc
import (
"net/http"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/render"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
)
const _favoriteLabel = "favorite"
// FollowDriveItem marks a drive item as favorite.
func (g Graph) FollowDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
itemID, err := parseIDParam(r, "itemID")
if err != nil {
g.logger.Debug().Err(err).Msg("could not parse itemID")
return
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusServiceUnavailable, "could not select next gateway client")
return
}
ref := &provider.Reference{
ResourceId: &itemID,
}
u, ok := revactx.ContextGetUser(ctx)
if !ok {
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "User not found in context")
return
}
statReq := &provider.StatRequest{
Ref: ref,
}
statRes, err := gatewayClient.Stat(ctx, statReq)
if err != nil {
g.logger.Error().Err(err).Msg("could not stat resource")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not stat resource")
return
}
switch statRes.GetStatus().GetCode() {
case rpc.Code_CODE_OK:
// continue
case rpc.Code_CODE_NOT_FOUND:
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "resource not found")
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not stat resource")
return
}
req := &provider.AddLabelRequest{
Ref: ref,
UserId: u.Id,
Label: _favoriteLabel,
}
res, err := gatewayClient.AddLabel(ctx, req)
if err != nil {
g.logger.Error().Err(err).Msg("could not add label")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not add label")
return
}
if res.Status.Code != rpc.Code_CODE_OK {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not add label")
return
}
if g.eventsPublisher != nil {
ev := events.LabelAdded{
Ref: &provider.Reference{
ResourceId: &itemID,
Path: ".",
},
Label: _favoriteLabel,
UserID: u.Id,
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
}
if err := events.Publish(r.Context(), g.eventsPublisher, ev); err != nil {
g.logger.Error().Err(err).Msg("Failed to publish LabelAdded event")
}
}
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, statRes.GetInfo())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusCreated)
render.JSON(w, r, &driveItem)
}
// UnfollowDriveItem unmarks a drive item as favorite.
func (g Graph) UnfollowDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
itemID, err := parseIDParam(r, "itemID")
if err != nil {
g.logger.Debug().Err(err).Msg("could not parse itemID")
return
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusServiceUnavailable, "could not select next gateway client")
return
}
ref := &provider.Reference{
ResourceId: &itemID,
}
u, ok := revactx.ContextGetUser(ctx)
if !ok {
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "User not found in context")
return
}
req := &provider.RemoveLabelRequest{
Ref: ref,
UserId: u.Id,
Label: _favoriteLabel,
}
res, err := gatewayClient.RemoveLabel(ctx, req)
if err != nil {
g.logger.Error().Err(err).Msg("could not remove label")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not remove label")
return
}
switch res.Status.Code {
case rpc.Code_CODE_OK:
// continue
case rpc.Code_CODE_NOT_FOUND:
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "favorite not found")
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not remove favorite")
return
}
if g.eventsPublisher != nil {
ev := events.LabelRemoved{
Ref: &provider.Reference{
ResourceId: &itemID,
Path: ".",
},
Label: _favoriteLabel,
UserID: u.Id,
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
}
if err := events.Publish(r.Context(), g.eventsPublisher, ev); err != nil {
g.logger.Error().Err(err).Msg("Failed to publish LabelRemoved event")
}
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,156 @@
package svc
import (
"context"
"errors"
"net/http"
"net/url"
"path"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/jellydator/ttlcache/v3"
"github.com/nats-io/nats.go/jetstream"
"go-micro.dev/v4/client"
"go.opentelemetry.io/otel/trace"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/qsfera/server/pkg/keycloak"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity"
)
// Permissions is the interface used to access the permissions service
type Permissions interface {
ListPermissions(ctx context.Context, req *settingssvc.ListPermissionsRequest, opts ...client.CallOption) (*settingssvc.ListPermissionsResponse, error)
GetPermissionByID(ctx context.Context, request *settingssvc.GetPermissionByIDRequest, opts ...client.CallOption) (*settingssvc.GetPermissionByIDResponse, error)
ListPermissionsByResource(ctx context.Context, in *settingssvc.ListPermissionsByResourceRequest, opts ...client.CallOption) (*settingssvc.ListPermissionsByResourceResponse, error)
}
// HTTPClient is the subset of the http.Client that is being used to interact with the download gateway
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// GetGatewayServiceClientFunc is a callback used to pass in a mock during testing
type GetGatewayServiceClientFunc func() (gateway.GatewayAPIClient, error)
// RoleService is the interface used to access the role service
type RoleService interface {
ListRoles(ctx context.Context, in *settingssvc.ListBundlesRequest, opts ...client.CallOption) (*settingssvc.ListBundlesResponse, error)
ListRoleAssignments(ctx context.Context, in *settingssvc.ListRoleAssignmentsRequest, opts ...client.CallOption) (*settingssvc.ListRoleAssignmentsResponse, error)
ListRoleAssignmentsFiltered(ctx context.Context, in *settingssvc.ListRoleAssignmentsFilteredRequest, opts ...client.CallOption) (*settingssvc.ListRoleAssignmentsResponse, error)
AssignRoleToUser(ctx context.Context, in *settingssvc.AssignRoleToUserRequest, opts ...client.CallOption) (*settingssvc.AssignRoleToUserResponse, error)
RemoveRoleFromUser(ctx context.Context, in *settingssvc.RemoveRoleFromUserRequest, opts ...client.CallOption) (*emptypb.Empty, error)
}
// Graph defines implements the business logic for Service.
type Graph struct {
BaseGraphService
mux *chi.Mux
identityBackend identity.Backend
identityEducationBackend identity.EducationBackend
roleService RoleService
permissionsService Permissions
valueService settingssvc.ValueService
specialDriveItemsCache *ttlcache.Cache[string, any]
eventsPublisher events.Publisher
eventsConsumer events.Consumer
searchService searchsvc.SearchProviderService
keycloakClient keycloak.Client
historyClient ehsvc.EventHistoryService
traceProvider trace.TracerProvider
natskv jetstream.KeyValue
}
// ServeHTTP implements the Service interface.
func (g Graph) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// There was a number of issues with the chi router and parameters with
// slashes/percentage/other characters that didn't get properly escaped.
// This is a workaround to fix this. Also, we're not the only ones who have
// tried to fix this, as seen in this issue:
// https://github.com/go-chi/chi/issues/641#issuecomment-883156692
r.URL.RawPath = r.URL.EscapedPath()
g.mux.ServeHTTP(w, r)
}
func (g Graph) publishEvent(ctx context.Context, ev any) {
if g.eventsPublisher != nil {
if err := events.Publish(ctx, g.eventsPublisher, ev); err != nil {
g.logger.Error().
Err(err).
Msg("could not publish user created event")
}
}
}
func (g Graph) getWebDavBaseURL() (*url.URL, error) {
webDavBaseURL, err := url.Parse(g.config.Spaces.WebDavBase)
if err != nil {
return nil, err
}
webDavBaseURL.Path = path.Join(webDavBaseURL.Path, g.config.Spaces.WebDavPath)
return webDavBaseURL, nil
}
// ListResponse is used for proper marshalling of Graph list responses
type ListResponse struct {
Value any `json:"value,omitempty"`
}
const (
// ReadmeSpecialFolderName for the drive specialFolder property
ReadmeSpecialFolderName = "readme"
// SpaceImageSpecialFolderName for the drive specialFolder property
SpaceImageSpecialFolderName = "image"
)
type APIVersion int
const (
// APIVersion_1 represents the first version of the API.
APIVersion_1 APIVersion = iota + 1
// APIVersion_1_Beta_1 refers to the beta version of the API.
// It is typically used for testing purposes and may have more
// inconsistencies and bugs than the stable version as it is
// still in the testing phase, use it with caution.
APIVersion_1_Beta_1
)
// TODO might be different for /education/users vs /users
func (g Graph) parseMemberRef(ref string) (string, string, error) {
memberURL, err := url.ParseRequestURI(ref)
if err != nil {
return "", "", err
}
segments := strings.Split(memberURL.Path, "/")
if len(segments) < 2 {
return "", "", errors.New("invalid member reference")
}
id := segments[len(segments)-1]
memberType := segments[len(segments)-2]
return memberType, id, nil
}
func parseIDParam(r *http.Request, param string) (storageprovider.ResourceId, error) {
driveID, err := url.PathUnescape(chi.URLParam(r, param))
if err != nil {
return storageprovider.ResourceId{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
id, err := storagespace.ParseID(driveID)
if err != nil {
return storageprovider.ResourceId{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
return id, nil
}
@@ -0,0 +1,26 @@
package svc_test
import (
"testing"
"github.com/qsfera/server/pkg/registry"
mRegistry "go-micro.dev/v4/registry"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func init() {
r := registry.GetRegistry(registry.Inmemory())
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
service.Nodes = []*mRegistry.Node{{
Address: "any",
}}
_ = r.Register(service)
}
func TestGraph(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Graph Suite")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,489 @@
package svc
import (
"fmt"
"net/http"
"net/url"
"reflect"
"sort"
"strings"
"github.com/CiscoM31/godata"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
)
const memberTypeUsers = "users"
// GetGroups implements the Service interface.
func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling get groups")
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 groups: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context())
searchHasAcceptableLength := false
if odataReq.Query != nil && odataReq.Query.Search != nil {
minSearchLength := g.config.API.IdentitySearchMinLength
if strings.HasPrefix(odataReq.Query.Search.RawValue, "\"") {
// if search starts with double quotes then it must finish with double quotes
// add +2 to the minimum search length in this case
minSearchLength += 2
}
searchHasAcceptableLength = len(odataReq.Query.Search.RawValue) >= minSearchLength
}
if !ctxHasFullPerms && !searchHasAcceptableLength {
// for regular user the search term must have a minimum length
logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength)
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short")
return
}
if !ctxHasFullPerms && (odataReq.Query.Filter != nil || odataReq.Query.Apply != nil || odataReq.Query.Expand != nil || odataReq.Query.Compute != nil) {
// regular users can't use filter, apply, expand and compute
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user")
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users")
return
}
groups, err := g.identityBackend.GetGroups(r.Context(), odataReq)
if err != nil {
logger.Debug().Err(err).Msg("could not get groups: backend error")
errorcode.RenderError(w, r, err)
return
}
// If the user isn't admin, we'll show just the minimum group attibutes
if !ctxHasFullPerms {
finalGroups := make([]*libregraph.Group, len(groups))
for i, grp := range groups {
finalGroups[i] = &libregraph.Group{
Id: grp.Id,
DisplayName: grp.DisplayName,
GroupTypes: grp.GroupTypes,
}
}
groups = finalGroups
}
groups, err = sortGroups(odataReq, groups)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get groups: could not sort groups according to query")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: groups})
}
// PostGroup implements the Service interface.
func (g Graph) PostGroup(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling post group")
grp := libregraph.NewGroup()
err := StrictJSONUnmarshal(r.Body, grp)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create group: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
if displayName, ok := grp.GetDisplayNameOk(); ok {
if !isValidGroupName(*displayName) {
logger.Info().Str("group", *displayName).Msg("could not create group: invalid displayName")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Invalid displayName")
return
}
} else {
logger.Debug().Err(err).Interface("group", grp).Msg("could not create group: 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.
if _, ok := grp.GetIdOk(); ok {
logger.Debug().Msg("could not create group: id is a read-only attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "group id is a read-only attribute")
return
}
if grp, err = g.identityBackend.CreateGroup(r.Context(), *grp); err != nil {
logger.Debug().Err(err).Interface("group", grp).Msg("could not create group: backend error")
errorcode.RenderError(w, r, err)
return
}
if grp != nil && grp.Id != nil {
e := events.GroupCreated{
GroupID: grp.GetId(),
}
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, grp)
}
// PatchGroup implements the Service interface.
func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling patch group")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
logger.Debug().Str("id", groupID).Msg("could not change group: unescaping group id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
logger.Debug().Msg("could not change group: missing group id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
changes := libregraph.NewGroup()
err = StrictJSONUnmarshal(r.Body, changes)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change group: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
if reflect.ValueOf(*changes).IsZero() {
logger.Debug().Interface("body", r.Body).Msg("ignoring empyt request body")
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
return
}
if changes.HasDisplayName() {
displayName := changes.GetDisplayName()
if !isValidGroupName(displayName) {
logger.Info().Str("group", displayName).Msg("could not update group: invalid displayName")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Invalid displayName")
return
}
if err = g.identityBackend.UpdateGroupName(r.Context(), groupID, displayName); err != nil {
logger.Debug().Err(err).Msg("could not update group displayName")
errorcode.RenderError(w, r, err)
return
}
}
if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
// The spec defines a limit of 20 members maxium per Request
if len(memberRefs) > g.config.API.GroupMembersPatchLimit {
logger.Debug().
Int("number", len(memberRefs)).
Int("limit", g.config.API.GroupMembersPatchLimit).
Msg("could not add group members, exceeded members limit")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
return
}
memberIDs := make([]string, 0, len(memberRefs))
for _, memberRef := range memberRefs {
memberType, id, err := g.parseMemberRef(memberRef)
if err != nil {
logger.Debug().
Str("memberref", memberRef).
Msg("could not change group: Error parsing member@odata.bind values")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values")
return
}
logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add group member")
// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
// we restrict this to users for now. Might add Groups as members later
if memberType != memberTypeUsers {
logger.Debug().
Str("type", memberType).
Msg("could not change group: could not add member, only user type is allowed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as group members")
return
}
memberIDs = append(memberIDs, id)
}
if err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, memberIDs); err != nil {
logger.Debug().Err(err).Msg("could not change group: backend could not add members")
errorcode.RenderError(w, r, err)
return
}
}
render.Status(r, http.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body
render.NoContent(w, r)
}
// GetGroup implements the Service interface.
func (g Graph) GetGroup(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling get group")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
logger.Debug().Str("id", groupID).Msg("could not get group: unescaping group id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
}
if groupID == "" {
logger.Debug().Msg("could not get group: missing group id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
logger.Debug().
Str("id", groupID).
Interface("query", r.URL.Query()).
Msg("calling get group on backend")
group, err := g.identityBackend.GetGroup(r.Context(), groupID, r.URL.Query())
if err != nil {
logger.Error().Err(err).Msg("could not get group: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, group)
}
// DeleteGroup implements the Service interface.
func (g Graph) DeleteGroup(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling delete group")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
logger.Debug().Err(err).Str("id", groupID).Msg("could not delete group: unescaping group id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
logger.Debug().Msg("could not delete group: missing group id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
logger.Debug().Str("id", groupID).Msg("calling delete group on backend")
err = g.identityBackend.DeleteGroup(r.Context(), groupID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete group: backend error")
errorcode.RenderError(w, r, err)
return
}
e := events.GroupDeleted{
GroupID: groupID,
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusNoContent)
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")
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
logger.Debug().Str("id", groupID).Msg("could not get group members: unescaping group id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
logger.Debug().Msg("could not get group members: missing group id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group 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", groupID).Msg("calling get group members on backend")
members, err := g.identityBackend.GetGroupMembers(r.Context(), groupID, odataReq)
if err != nil {
logger.Debug().Err(err).Msg("could not get group members: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, members)
}
// PostGroupMember implements the Service interface.
func (g Graph) PostGroupMember(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("Calling post group member")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
logger.Debug().
Err(err).
Str("id", groupID).
Msg("could not add member to group: unescaping group id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
logger.Debug().Msg("could not add group member: missing group id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add group member: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add group member: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add group member: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
// we restrict this to users for now. Might add Groups as members later
if memberType != memberTypeUsers {
logger.Debug().Str("type", memberType).Msg("could not add group member: Only users are allowed as group members")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as group members")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, []string{id})
if err != nil {
logger.Debug().Err(err).Msg("could not add group member: backend error")
errorcode.RenderError(w, r, err)
return
}
e := events.GroupMemberAdded{
GroupID: groupID,
UserID: id,
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteGroupMember implements the Service interface.
func (g Graph) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling delete group member")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
logger.Debug().Err(err).Str("id", groupID).Msg("could not delete group member: unescaping group id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
logger.Debug().Msg("could not delete group member: missing group id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
memberID := chi.URLParam(r, "memberID")
memberID, err = url.PathUnescape(memberID)
if err != nil {
logger.Debug().Err(err).Str("id", memberID).Msg("could not delete group member: unescaping member id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
return
}
if memberID == "" {
logger.Debug().Msg("could not delete group member: missing member id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
return
}
logger.Debug().Str("groupID", groupID).Str("memberID", memberID).Msg("calling delete member on backend")
err = g.identityBackend.RemoveMemberFromGroup(r.Context(), groupID, memberID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete group member: backend error")
errorcode.RenderError(w, r, err)
return
}
e := events.GroupMemberRemoved{
GroupID: groupID,
UserID: memberID,
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
func sortGroups(req *godata.GoDataRequest, groups []*libregraph.Group) ([]*libregraph.Group, error) {
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return groups, 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(groups[i].GetDisplayName()) < strings.ToLower(groups[j].GetDisplayName())
}
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(groups, reverse(less))
} else {
sort.Slice(groups, less)
}
return groups, nil
}
func isValidGroupName(e string) bool {
str := strings.TrimSpace(e)
return len(str) > 0 && len(str) <= 256
}
@@ -0,0 +1,640 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type groupList struct {
Value []*libregraph.Group
}
var _ = Describe("Groups", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
identityBackend *identitymocks.Backend
permissionService *mocks.Permissions
rr *httptest.ResponseRecorder
newGroup *libregraph.Group
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
permissionService = &mocks.Permissions{}
identityBackend = &identitymocks.Backend{}
newGroup = libregraph.NewGroup()
newGroup.SetMembersodataBind([]string{"/users/user1"})
newGroup.SetId("group1")
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{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.PermissionService(permissionService),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetGroups", func() {
It("handles invalid ODATA parameters", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?§foo=bar", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid sorting queries", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
Permission: &settingsmsg.Permission{
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
},
}, nil)
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?$orderby=invalid", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
})
It("handles unknown backend errors", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
Permission: &settingsmsg.Permission{
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
},
}, nil)
identityBackend.On("GetGroups", ctx, mock.Anything).Return(nil, errors.New("failed"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("generalException"))
})
It("handles backend errors", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
Permission: &settingsmsg.Permission{
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
},
}, nil)
identityBackend.On("GetGroups", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
})
It("renders an empty list of groups", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
Permission: &settingsmsg.Permission{
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
},
}, nil)
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := service.ListResponse{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(res.Value).To(Equal([]any{}))
})
It("renders a list of groups", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
Permission: &settingsmsg.Permission{
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
},
}, nil)
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := groupList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetId()).To(Equal("group1"))
})
It("denies listing for unprivileged users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("denies using to short search terms for unprivileged users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$search=a", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("denies using to short quoted search terms for unprivileged users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?$search=%22ab%22", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("only returns a restricted set of attributes for unprivileged users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
group := &libregraph.Group{}
group.SetId("group1")
group.SetDisplayName("Group Name")
group.SetMembers(
[]libregraph.User{
{
Id: libregraph.PtrString("userid"),
},
},
)
groups := []*libregraph.Group{group}
identityBackend.On("GetGroups", mock.Anything, mock.Anything, mock.Anything).Return(groups, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?$search=abc", nil)
svc.GetGroups(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := groupList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
groupMap, err := res.Value[0].ToMap()
Expect(err).ToNot(HaveOccurred())
for k := range groupMap {
Expect(k).Should(BeElementOf([]string{"displayName", "id", "groupTypes"}))
}
})
})
Describe("GetGroup", func() {
It("handles missing or empty group id", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
svc.GetGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", "")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing group", func() {
BeforeEach(func() {
identityBackend.On("GetGroup", mock.Anything, mock.Anything, mock.Anything).Return(newGroup, nil)
})
It("gets the group", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups/"+*newGroup.Id, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("PostGroup", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBufferString("{invalid"))
svc.PostGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing display name", func() {
newGroup = libregraph.NewGroup()
newGroup.SetId("disallowed")
newGroup.SetMembersodataBind([]string{"/non-users/user"})
newGroupJson, err := json.Marshal(newGroup)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBuffer(newGroupJson))
svc.PostGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("disallows group create ids", func() {
newGroup = libregraph.NewGroup()
newGroup.SetId("disallowed")
newGroup.SetDisplayName("New Group")
newGroup.SetMembersodataBind([]string{"/non-users/user"})
newGroupJson, err := json.Marshal(newGroup)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBuffer(newGroupJson))
svc.PostGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("creates the group", func() {
newGroup = libregraph.NewGroup()
newGroup.SetDisplayName("New Group")
newGroupJson, err := json.Marshal(newGroup)
Expect(err).ToNot(HaveOccurred())
identityBackend.On("CreateGroup", mock.Anything, mock.Anything).Return(newGroup, nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBuffer(newGroupJson))
svc.PostGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusCreated))
})
})
Describe("PatchGroup", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups/", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty group id", func() {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", nil)
svc.PatchGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", "")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing group", func() {
BeforeEach(func() {
identityBackend.On("GetGroup", mock.Anything, mock.Anything, mock.Anything).Return(newGroup, nil)
})
It("fails when the number of users is exceeded - spec says 20 max", func() {
updatedGroup := libregraph.NewGroup()
updatedGroup.SetMembersodataBind([]string{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
"19", "20", "21",
})
updatedGroupJson, err := json.Marshal(updatedGroup)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
resp, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(resp)).To(ContainSubstring("Request is limited to 20"))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("succeeds when the number of users is over 20 but the limit is raised to 21", func() {
updatedGroup := libregraph.NewGroup()
updatedGroup.SetMembersodataBind([]string{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
"19", "20", "21",
})
updatedGroupJson, err := json.Marshal(updatedGroup)
Expect(err).ToNot(HaveOccurred())
cfg.API.GroupMembersPatchLimit = 21
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
resp, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values"))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid user refs", func() {
updatedGroup := libregraph.NewGroup()
updatedGroup.SetMembersodataBind([]string{"invalid"})
updatedGroupJson, err := json.Marshal(updatedGroup)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails when the adding non-users users", func() {
updatedGroup := libregraph.NewGroup()
updatedGroup.SetMembersodataBind([]string{"/non-users/user1"})
updatedGroupJson, err := json.Marshal(updatedGroup)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds members to the group", func() {
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
updatedGroup := libregraph.NewGroup()
updatedGroup.SetMembersodataBind([]string{"/users/user1"})
updatedGroupJson, err := json.Marshal(updatedGroup)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
})
It("updates the group name", func() {
identityBackend.On("UpdateGroupName", mock.Anything, mock.Anything, mock.Anything).Return(nil)
updatedGroup := libregraph.NewGroup()
updatedGroup.SetDisplayName("group1 updated")
updatedGroupJson, err := json.Marshal(updatedGroup)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "UpdateGroupName", 1)
})
})
})
Describe("DeleteGroup", func() {
Context("with an existing group", func() {
BeforeEach(func() {
identityBackend.On("GetGroup", mock.Anything, mock.Anything, mock.Anything).Return(newGroup, nil)
})
})
It("deletes the group", func() {
identityBackend.On("DeleteGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteGroup(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "DeleteGroup", 1)
eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1)
})
})
Describe("GetGroupMembers", func() {
It("gets the list of members", func() {
user := libregraph.NewUser("display name", "username")
user.SetId("userid")
identityBackend.On("GetGroupMembers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.User{user}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups/{groupID}/members", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetGroupMembers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var members []*libregraph.User
err = json.Unmarshal(data, &members)
Expect(err).ToNot(HaveOccurred())
Expect(len(members)).To(Equal(1))
Expect(members[0].GetId()).To(Equal("userid"))
})
})
Describe("PostGroupMembers", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostGroupMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing member refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostGroupMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid member refs", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/invalidtype/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostGroupMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new member", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/users/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostGroupMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
})
})
Describe("DeleteGroupMembers", func() {
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/groups/{groupID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteGroupMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/groups/{groupID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("memberID", "/users/user")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteGroupMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes members", func() {
identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/groups/{groupID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("groupID", *newGroup.Id)
rctx.URLParams.Add("memberID", "/users/user1")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteGroupMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1)
})
})
})
@@ -0,0 +1,199 @@
package svc
import (
"context"
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/nats-io/nats.go/jetstream"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go.opentelemetry.io/otel/trace"
"github.com/qsfera/server/pkg/keycloak"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/roles"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/identity"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Context context.Context
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
RequireAdminMiddleware func(http.Handler) http.Handler
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
IdentityBackend identity.Backend
IdentityEducationBackend identity.EducationBackend
RoleService RoleService
UserProfilePhotoService UsersUserProfilePhotoProvider
PermissionService Permissions
ValueService settingssvc.ValueService
RoleManager *roles.Manager
EventsPublisher events.Publisher
EventsConsumer events.Consumer
SearchService searchsvc.SearchProviderService
KeycloakClient keycloak.Client
EventHistoryClient ehsvc.EventHistoryService
TraceProvider trace.TracerProvider
NatsKeyValue jetstream.KeyValue
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Context provides a function to set the context option.
func Context(ctx context.Context) Option {
return func(o *Options) {
o.Context = ctx
}
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Middleware provides a function to set the middleware option.
func Middleware(val ...func(http.Handler) http.Handler) Option {
return func(o *Options) {
o.Middleware = val
}
}
// WithRequireAdminMiddleware provides a function to set the RequireAdminMiddleware option.
func WithRequireAdminMiddleware(val func(http.Handler) http.Handler) Option {
return func(o *Options) {
o.RequireAdminMiddleware = val
}
}
// WithGatewaySelector provides a function to set the gateway client option.
func WithGatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.GatewaySelector = val
}
}
// WithIdentityBackend provides a function to set the IdentityBackend option.
func WithIdentityBackend(val identity.Backend) Option {
return func(o *Options) {
o.IdentityBackend = val
}
}
// WithIdentityEducationBackend provides a function to set the IdentityEducationBackend option.
func WithIdentityEducationBackend(val identity.EducationBackend) Option {
return func(o *Options) {
o.IdentityEducationBackend = val
}
}
// WithNatsKeyValue provides a function to set the NatsKeyValue option.
func WithNatsKeyValue(val jetstream.KeyValue) Option {
return func(o *Options) {
o.NatsKeyValue = val
}
}
// WithRoleService provides a function to set the RoleService option.
func WithRoleService(val RoleService) Option {
return func(o *Options) {
o.RoleService = val
}
}
// WithValueService provides a function to set the ValueService option.
func WithValueService(val settingssvc.ValueService) Option {
return func(o *Options) {
o.ValueService = val
}
}
// WithSearchService provides a function to set the SearchService option.
func WithSearchService(val searchsvc.SearchProviderService) Option {
return func(o *Options) {
o.SearchService = val
}
}
// PermissionService provides a function to set the PermissionService option.
func PermissionService(val settingssvc.PermissionService) Option {
return func(o *Options) {
o.PermissionService = val
}
}
// RoleManager provides a function to set the RoleManager option.
func RoleManager(val *roles.Manager) Option {
return func(o *Options) {
o.RoleManager = val
}
}
// EventsPublisher provides a function to set the EventsPublisher option.
func EventsPublisher(val events.Publisher) Option {
return func(o *Options) {
o.EventsPublisher = val
}
}
// EventsConsumer provides a function to set the EventsConsumer option.
func EventsConsumer(val events.Consumer) Option {
return func(o *Options) {
o.EventsConsumer = val
}
}
// KeycloakClient provides a function to set the KeycloakCient option.
func KeycloakClient(val keycloak.Client) Option {
return func(o *Options) {
o.KeycloakClient = val
}
}
// EventHistoryClient provides a function to set the EventHistoryClient option.
func EventHistoryClient(val ehsvc.EventHistoryService) Option {
return func(o *Options) {
o.EventHistoryClient = val
}
}
// TraceProvider provides a function to set the TraceProvider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
// UserProfilePhotoService provides a function to set the UserProfilePhotoService option.
func UserProfilePhotoService(p UsersUserProfilePhotoProvider) Option {
return func(o *Options) {
o.UserProfilePhotoService = p
}
}
@@ -0,0 +1,32 @@
package svc
import (
"strings"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
// lessSpacesByLastModifiedDateTime reports whether the element i
// must sort before the element j.
func lessSpacesByLastModifiedDateTime(i, j *libregraph.Drive) bool {
// compare the items when both dates are set
if i.LastModifiedDateTime != nil && j.LastModifiedDateTime != nil {
return i.LastModifiedDateTime.Before(*j.LastModifiedDateTime)
}
// an item without a timestamp is considered "less than" an item with a timestamp
if i.LastModifiedDateTime == nil && j.LastModifiedDateTime != nil {
return true
}
// an item without a timestamp is considered "less than" an item with a timestamp
if i.LastModifiedDateTime != nil && j.LastModifiedDateTime == nil {
return false
}
// fallback to name if no dateTime is set on both items
return strings.ToLower(i.Name) < strings.ToLower(j.Name)
}
func reverse(less func(i, j int) bool) func(i, j int) bool {
return func(i, j int) bool {
return !less(i, j)
}
}
@@ -0,0 +1,112 @@
package svc
import (
"net/http"
"strings"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
)
// ChangeOwnPassword implements the Service interface. It allows the user to change
// its own password
func (g Graph) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u, ok := revactx.ContextGetUser(ctx)
if !ok {
g.logger.Error().Msg("user not in context")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
_, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
cpw := libregraph.NewPasswordChangeWithDefaults()
err = StrictJSONUnmarshal(r.Body, cpw)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
currentPw := cpw.GetCurrentPassword()
if currentPw == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "current password cannot be empty")
return
}
newPw := cpw.GetNewPassword()
if newPw == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password cannot be empty")
return
}
if newPw == currentPw {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password must be different from current password")
return
}
authReq := &gateway.AuthenticateRequest{
Type: "basic",
ClientId: u.Username,
ClientSecret: currentPw,
}
client, err := g.gatewaySelector.Next()
if err != nil {
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
return
}
authRes, err := client.Authenticate(r.Context(), authReq)
if err != nil {
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
switch authRes.Status.Code {
case cs3rpc.Code_CODE_OK:
break
case cs3rpc.Code_CODE_UNAUTHENTICATED, cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "wrong current password")
return
default:
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
return
}
newPwProfile := libregraph.NewPasswordProfile()
newPwProfile.SetPassword(newPw)
changes := libregraph.NewUserUpdate()
changes.SetPasswordProfile(*newPwProfile)
_, err = g.identityBackend.UpdateUser(ctx, u.Id.OpaqueId, *changes)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
g.logger.Debug().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to update user password")
return
}
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(
ctx,
events.UserFeatureChanged{
Executant: currentUser.Id,
UserID: u.Id.OpaqueId,
Features: []events.UserFeature{
{Name: "password", Value: "***"},
},
},
)
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
@@ -0,0 +1,171 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-ldap/ldap/v3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/identity"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
var _ = Describe("Users changing their own password", func() {
var (
svc service.Service
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
ldapClient *identitymocks.Client
ldapConfig config.LDAP
identityBackend identity.Backend
eventsPublisher mocks.Publisher
ctx context.Context
cfg *config.Config
user *userv1beta1.User
err error
)
JustBeforeEach(func() {
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
ldapClient = mockedLDAPClient()
ldapConfig = config.LDAP{
WriteEnabled: true,
UserDisplayNameAttribute: "displayName",
UserNameAttribute: "uid",
UserEmailAttribute: "mail",
UserIDAttribute: "qsferaUUID",
UserSearchScope: "sub",
GroupNameAttribute: "cn",
GroupIDAttribute: "qsferaUUID",
GroupSearchScope: "sub",
}
logger := log.NewLogger()
identityBackend, err = identity.NewLDAPBackend(ldapClient, ldapConfig, &logger)
Expect(err).To(BeNil())
eventsPublisher = mocks.Publisher{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.WithIdentityBackend(identityBackend),
service.EventsPublisher(&eventsPublisher),
)
Expect(err).ToNot(HaveOccurred())
user = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
ctx = revactx.ContextSetUser(ctx, user)
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
})
It("fails if no user in context", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/changePassword", nil)
rr := httptest.NewRecorder()
svc.ChangeOwnPassword(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
DescribeTable("changing the password",
func(current string, newpw string, authresult string, expected int) {
switch authresult {
case "error":
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(nil, errors.New("fail"))
case "deny":
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
Status: status.NewPermissionDenied(ctx, errors.New("wrong password"), "wrong password"),
Token: "authtoken",
}, nil)
default:
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
Status: status.NewOK(ctx),
Token: "authtoken",
}, nil)
}
cpw := libregraph.NewPasswordChangeWithDefaults()
cpw.SetCurrentPassword(current)
cpw.SetNewPassword(newpw)
body, _ := json.Marshal(cpw)
b := bytes.NewBuffer(body)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/me/changePassword", b).WithContext(ctx)
rr := httptest.NewRecorder()
svc.ChangeOwnPassword(rr, r)
Expect(rr.Code).To(Equal(expected))
},
Entry("fails when current password is empty", "", "newpassword", "", http.StatusBadRequest),
Entry("fails when new password is empty", "currentpassword", "", "", http.StatusBadRequest),
Entry("fails when current and new password are equal", "password", "password", "", http.StatusBadRequest),
Entry("fails authentication with current password errors", "currentpassword", "newpassword", "error", http.StatusInternalServerError),
Entry("fails when current password is wrong", "currentpassword", "newpassword", "deny", http.StatusBadRequest),
Entry("succeeds when current password is correct", "currentpassword", "newpassword", "", http.StatusNoContent),
)
})
func mockedLDAPClient() *identitymocks.Client {
lm := &identitymocks.Client{}
userEntry := ldap.NewEntry("uid=test", map[string][]string{
"uid": {"test"},
"displayName": {"test"},
"mail": {"test@example.org"},
})
lm.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything,
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(
&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}},
nil)
mr := ldap.NewModifyRequest("uid=test", nil)
mr.Changes = []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "userPassword",
Vals: []string{"newpassword"},
},
},
}
lm.On("Modify", mr).Return(nil)
return lm
}
@@ -0,0 +1,331 @@
package svc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"github.com/qsfera/server/services/graph/pkg/errorcode"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
ehmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rhttp"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
var (
_backupFileName = "personal_data_export.json"
// TokenTransportHeader holds the header key for the reva transfer token
TokenTransportHeader = "X-Reva-Transfer"
)
// Marshaller is the common interface for a marshaller
type Marshaller func(any) ([]byte, error)
// ExportPersonalDataRequest is the body of the request
type ExportPersonalDataRequest struct {
StorageLocation string `json:"storageLocation"`
}
func (g Graph) getPersonalSpace(ctx context.Context, u *user.UserId) (*provider.StorageSpace, error) {
// we cannot assume the space id is the same as the user id, so we have to look up his personal space first
lReq := &storageprovider.ListStorageSpacesRequest{
Filters: []*storageprovider.ListStorageSpacesRequest_Filter{
{
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_OWNER,
Term: &storageprovider.ListStorageSpacesRequest_Filter_Owner{
Owner: u,
},
},
{
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
Term: &storageprovider.ListStorageSpacesRequest_Filter_SpaceType{
SpaceType: "personal",
},
},
},
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
return nil, err
}
res, err := gatewayClient.ListStorageSpaces(ctx, lReq)
if err != nil {
return nil, err
}
if res.Status.Code != rpc.Code_CODE_OK {
return nil, errorcode.FromCS3Status(res.Status, fmt.Errorf("could not list storage spaces"))
}
if len(res.GetStorageSpaces()) == 0 {
return nil, fmt.Errorf("could not find personal space for user %s", u.GetOpaqueId())
}
return res.GetStorageSpaces()[0], nil
}
// ExportPersonalData exports all personal data the system holds
func (g Graph) ExportPersonalData(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := revactx.ContextMustGetUser(ctx)
if reqUserID := chi.URLParam(r, "userID"); reqUserID != u.GetId().GetOpaqueId() {
g.logger.Info().Msg("uid mismatch")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("personal data export for other users are not permitted"))
return
}
// Get location from request
loc := getLocation(r)
// prepare marshaller
var marsh Marshaller
switch filepath.Ext(loc) {
default:
g.logger.Info().Str("path", loc).Msg("invalid location")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("only json format is supported for personal data export"))
return
case ".json":
marsh = json.Marshal
}
personalSpace, err := g.getPersonalSpace(ctx, u.GetId())
if err != nil {
g.logger.Error().Err(err).Msg("could not get personal space")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not get personal space, aborting")
return
}
ref := &provider.Reference{
ResourceId: personalSpace.GetRoot(),
Path: loc,
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
return
}
// touch file
if err := mustTouchFile(ctx, ref, gatewayClient); err != nil {
g.logger.Error().Err(err).Msg("error touching file")
w.WriteHeader(http.StatusInternalServerError)
return
}
// go start gathering
go g.GatherPersonalData(u, ref, r.Header.Get(revactx.TokenHeader), marsh)
w.WriteHeader(http.StatusAccepted)
}
// GatherPersonalData will all gather all personal data of the user and save it to a file in the users personal space
func (g Graph) GatherPersonalData(usr *user.User, ref *provider.Reference, token string, marsh Marshaller) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
return
}
// the context might already be cancelled. We need to impersonate the acting user again
ctx, err := utils.GetServiceUserContext(g.config.ServiceAccount.ServiceAccountID, gatewayClient, g.config.ServiceAccount.ServiceAccountSecret)
if err != nil {
g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot impersonate user")
}
// create data
data := make(map[string]any)
// reva user
data["user"] = usr
// Check if we have a keycloak client, and if so, get the keycloak export.
if ctx != nil && g.keycloakClient != nil {
kcd, err := g.keycloakClient.GetPIIReport(ctx, g.config.Keycloak.UserRealm, usr.GetUsername())
if err != nil {
g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot get keycloak personal data")
}
data["keycloak"] = kcd
}
// get event data
if ctx != nil && g.historyClient != nil {
resp, err := g.historyClient.GetEventsForUser(ctx, &ehsvc.GetEventsForUserRequest{UserID: usr.GetId().GetOpaqueId()})
if err != nil {
g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot get event personal data")
}
data["events"] = convertEvents(resp.GetEvents())
}
// marshal
by, err := marsh(data)
if err != nil {
g.logger.Error().Err(err).Msg("cannot marshal personal user data")
return
}
// upload
var errmsg string
if err := g.upload(usr, by, ref, token); err != nil {
g.logger.Error().Err(err).Msg("failed uploading personal data export")
errmsg = err.Error()
}
if err := events.Publish(ctx, g.eventsPublisher, events.PersonalDataExtracted{
Executant: usr.GetId(),
Timestamp: utils.TSNow(),
ErrorMsg: errmsg,
}); err != nil {
g.logger.Error().Err(err).Msg("cannot publish event")
}
}
func (g Graph) upload(u *user.User, data []byte, ref *provider.Reference, th string) error {
uReq := &provider.InitiateFileUploadRequest{
Ref: ref,
Opaque: utils.AppendPlainToOpaque(nil, "Upload-Length", strconv.FormatUint(uint64(len(data)), 10)),
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
return err
}
ctx, err := utils.GetServiceUserContext(g.config.ServiceAccount.ServiceAccountID, gatewayClient, g.config.ServiceAccount.ServiceAccountSecret)
if err != nil {
return err
}
client, err := g.gatewaySelector.Next()
if err != nil {
return err
}
ctx = revactx.ContextSetToken(ctx, th)
uRes, err := client.InitiateFileUpload(ctx, uReq)
if err != nil {
return err
}
if uRes.Status.Code != rpc.Code_CODE_OK {
return fmt.Errorf("wrong status code while initiating upload: %s", uRes.GetStatus().GetMessage())
}
var uploadEP, uploadToken string
for _, p := range uRes.Protocols {
if p.Protocol == "simple" {
uploadEP, uploadToken = p.UploadEndpoint, p.Token
}
}
httpUploadReq, err := rhttp.NewRequest(ctx, "PUT", uploadEP, bytes.NewBuffer(data))
if err != nil {
return err
}
httpUploadReq.Header.Set(TokenTransportHeader, uploadToken)
httpUploadRes, err := rhttp.GetHTTPClient(rhttp.Insecure(true)).Do(httpUploadReq)
if err != nil {
return err
}
defer httpUploadRes.Body.Close()
if httpUploadRes.StatusCode != http.StatusOK {
return fmt.Errorf("wrong status uploading file: %d", httpUploadRes.StatusCode)
}
return nil
}
// touches the file, creating folders if necessary
func mustTouchFile(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
if err := touchFile(ctx, ref, gwc); err == nil {
return nil
}
if err := createFolders(ctx, ref, gwc); err != nil {
return err
}
return touchFile(ctx, ref, gwc)
}
func touchFile(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
resp, err := gwc.TouchFile(ctx, &provider.TouchFileRequest{
Opaque: utils.AppendPlainToOpaque(nil, "markprocessing", "true"),
Ref: ref,
})
if err != nil {
return err
}
if resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
return fmt.Errorf("unexpected statuscode while touching file: %d %s", resp.GetStatus().GetCode(), resp.GetStatus().GetMessage())
}
return nil
}
func createFolders(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
var paths []string
p := filepath.Dir(ref.GetPath())
for p != "." {
paths = append([]string{p}, paths...)
p = filepath.Dir(p)
}
for _, p := range paths {
r := &provider.Reference{ResourceId: ref.GetResourceId(), Path: p}
resp, err := gwc.CreateContainer(ctx, &provider.CreateContainerRequest{Ref: r})
if err != nil {
return err
}
code := resp.GetStatus().GetCode()
if code != rpc.Code_CODE_OK && code != rpc.Code_CODE_ALREADY_EXISTS {
return fmt.Errorf("unexpected statuscode while creating folder: %d %s", code, resp.GetStatus().GetMessage())
}
}
return nil
}
func getLocation(r *http.Request) string {
// from body
var req ExportPersonalDataRequest
if b, err := io.ReadAll(r.Body); err == nil {
if err := json.Unmarshal(b, &req); err == nil && req.StorageLocation != "" {
return req.StorageLocation
}
}
// from header?
return _backupFileName
}
// we want the events to look nice in the file, don't we?
func convertEvents(evs []*ehmsg.Event) []map[string]any {
out := make([]map[string]any, len(evs))
for i, e := range evs {
var content map[string]any
_ = json.Unmarshal(e.GetEvent(), &content)
out[i] = map[string]any{
"id": e.GetId(),
"type": e.GetType(),
"event": content,
}
}
return out
}
@@ -0,0 +1,37 @@
package svc
import (
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
// GetRoleDefinitions a list of permission roles than can be used when sharing with users or groups
func (g Graph) GetRoleDefinitions(w http.ResponseWriter, r *http.Request) {
render.Status(r, http.StatusOK)
render.JSON(w, r, g.availableRoles)
}
// GetRoleDefinition a permission role than can be used when sharing with users or groups
func (g Graph) GetRoleDefinition(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
roleID, err := url.PathUnescape(chi.URLParam(r, "roleID"))
if err != nil {
logger.Debug().Err(err).Str("roleID", chi.URLParam(r, "roleID")).Msg("could not get roleID: unescaping is failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping role id failed")
return
}
role, err := unifiedrole.GetRole(unifiedrole.RoleFilterIDs(roleID))
if err != nil {
logger.Debug().Str("roleID", roleID).Msg("could not get role: not found")
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, role)
}
@@ -0,0 +1,608 @@
package svc
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
ldapv3 "github.com/go-ldap/ldap/v3"
"github.com/jellydator/ttlcache/v3"
"github.com/qsfera/server/services/graph/pkg/identity/cache"
"github.com/riandyrn/otelchi"
microstore "go-micro.dev/v4/store"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/store"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/opencloud-eu/reva/v2/pkg/utils/ldap"
ocldap "github.com/qsfera/server/pkg/ldap"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/roles"
"github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/tracing"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/pkg/identity"
graphm "github.com/qsfera/server/services/graph/pkg/middleware"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
const (
// HeaderPurge defines the header name for the purge header.
HeaderPurge = "Purge"
displayNameAttr = "displayName"
)
// Service defines the service handlers.
type Service interface { //nolint:interfacebloat
ServeHTTP(w http.ResponseWriter, r *http.Request)
ListApplications(w http.ResponseWriter, r *http.Request)
GetApplication(w http.ResponseWriter, r *http.Request)
GetMe(w http.ResponseWriter, r *http.Request)
GetUsers(w http.ResponseWriter, r *http.Request)
GetUser(w http.ResponseWriter, r *http.Request)
PostUser(w http.ResponseWriter, r *http.Request)
DeleteUser(w http.ResponseWriter, r *http.Request)
PatchUser(w http.ResponseWriter, r *http.Request)
ChangeOwnPassword(w http.ResponseWriter, r *http.Request)
ListAppRoleAssignments(w http.ResponseWriter, r *http.Request)
CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request)
DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request)
GetGroups(w http.ResponseWriter, r *http.Request)
GetGroup(w http.ResponseWriter, r *http.Request)
PostGroup(w http.ResponseWriter, r *http.Request)
PatchGroup(w http.ResponseWriter, r *http.Request)
DeleteGroup(w http.ResponseWriter, r *http.Request)
GetGroupMembers(w http.ResponseWriter, r *http.Request)
PostGroupMember(w http.ResponseWriter, r *http.Request)
DeleteGroupMember(w http.ResponseWriter, r *http.Request)
GetEducationSchools(w http.ResponseWriter, r *http.Request)
GetEducationSchool(w http.ResponseWriter, r *http.Request)
PostEducationSchool(w http.ResponseWriter, r *http.Request)
PatchEducationSchool(w http.ResponseWriter, r *http.Request)
DeleteEducationSchool(w http.ResponseWriter, r *http.Request)
GetEducationSchoolUsers(w http.ResponseWriter, r *http.Request)
PostEducationSchoolUser(w http.ResponseWriter, r *http.Request)
DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Request)
GetEducationSchoolClasses(w http.ResponseWriter, r *http.Request)
PostEducationSchoolClass(w http.ResponseWriter, r *http.Request)
DeleteEducationSchoolClass(w http.ResponseWriter, r *http.Request)
GetEducationClasses(w http.ResponseWriter, r *http.Request)
GetEducationClass(w http.ResponseWriter, r *http.Request)
PostEducationClass(w http.ResponseWriter, r *http.Request)
PatchEducationClass(w http.ResponseWriter, r *http.Request)
DeleteEducationClass(w http.ResponseWriter, r *http.Request)
GetEducationClassMembers(w http.ResponseWriter, r *http.Request)
PostEducationClassMember(w http.ResponseWriter, r *http.Request)
GetEducationUsers(w http.ResponseWriter, r *http.Request)
GetEducationUser(w http.ResponseWriter, r *http.Request)
PostEducationUser(w http.ResponseWriter, r *http.Request)
DeleteEducationUser(w http.ResponseWriter, r *http.Request)
PatchEducationUser(w http.ResponseWriter, r *http.Request)
DeleteEducationClassMember(w http.ResponseWriter, r *http.Request)
GetEducationClassTeachers(w http.ResponseWriter, r *http.Request)
PostEducationClassTeacher(w http.ResponseWriter, r *http.Request)
DeleteEducationClassTeacher(w http.ResponseWriter, r *http.Request)
GetDrivesV1(w http.ResponseWriter, r *http.Request)
GetDrivesV1Beta1(w http.ResponseWriter, r *http.Request)
GetSingleDrive(w http.ResponseWriter, r *http.Request)
GetAllDrivesV1(w http.ResponseWriter, r *http.Request)
GetAllDrivesV1Beta1(w http.ResponseWriter, r *http.Request)
CreateDrive(w http.ResponseWriter, r *http.Request)
UpdateDrive(w http.ResponseWriter, r *http.Request)
DeleteDrive(w http.ResponseWriter, r *http.Request)
GetSharedByMe(w http.ResponseWriter, r *http.Request)
ListSharedWithMe(w http.ResponseWriter, r *http.Request)
GetRootDriveChildren(w http.ResponseWriter, r *http.Request)
GetDriveItem(w http.ResponseWriter, r *http.Request)
GetDriveItemChildren(w http.ResponseWriter, r *http.Request)
CreateUploadSession(w http.ResponseWriter, r *http.Request)
GetTags(w http.ResponseWriter, r *http.Request)
AssignTags(w http.ResponseWriter, r *http.Request)
UnassignTags(w http.ResponseWriter, r *http.Request)
}
// NewService returns a service implementation for Service.
func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
options := newOptions(opts...)
m := chi.NewMux()
m.Use(options.Middleware...)
m.Use(
otelchi.Middleware(
"graph",
otelchi.WithChiRoutes(m),
otelchi.WithTracerProvider(options.TraceProvider),
otelchi.WithPropagators(tracing.GetPropagator()),
),
)
spacePropertiesCache := ttlcache.New(
ttlcache.WithTTL[string, any](
time.Duration(options.Config.Spaces.ExtendedSpacePropertiesCacheTTL),
),
ttlcache.WithDisableTouchOnHit[string, any](),
)
go spacePropertiesCache.Start()
identityCache := cache.NewIdentityCache(
cache.IdentityCacheWithGatewaySelector(options.GatewaySelector),
cache.IdentityCacheWithUsersTTL(time.Duration(options.Config.Spaces.UsersCacheTTL)),
cache.IdentityCacheWithGroupsTTL(time.Duration(options.Config.Spaces.GroupsCacheTTL)),
)
publicBaseURL, err := url.Parse(options.Config.Spaces.WebDavBase)
if err != nil {
return Graph{}, fmt.Errorf("could not parse graph.spaces.webdav_base: %w", err)
}
baseGraphService := BaseGraphService{
logger: &options.Logger,
identityCache: identityCache,
gatewaySelector: options.GatewaySelector,
config: options.Config,
availableRoles: unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(options.Config.UnifiedRoles.AvailableRoles...)),
publicBaseURL: publicBaseURL,
}
drivesDriveItemService, err := NewDrivesDriveItemService(options.Logger, options.GatewaySelector)
if err != nil {
return Graph{}, err
}
drivesDriveItemApi, err := NewDrivesDriveItemApi(drivesDriveItemService, baseGraphService, options.Logger)
if err != nil {
return Graph{}, err
}
driveItemPermissionsService, err := NewDriveItemPermissionsService(options.Logger, options.GatewaySelector, identityCache, options.Config)
if err != nil {
return Graph{}, err
}
driveItemPermissionsApi, err := NewDriveItemPermissionsApi(driveItemPermissionsService, options.Logger, options.Config)
if err != nil {
return Graph{}, err
}
usersUserProfilePhotoApi, err := NewUsersUserProfilePhotoApi(options.UserProfilePhotoService, options.Logger)
if err != nil {
return Graph{}, err
}
svc := Graph{
BaseGraphService: baseGraphService,
mux: m,
specialDriveItemsCache: spacePropertiesCache,
eventsPublisher: options.EventsPublisher,
eventsConsumer: options.EventsConsumer,
searchService: options.SearchService,
identityEducationBackend: options.IdentityEducationBackend,
keycloakClient: options.KeycloakClient,
historyClient: options.EventHistoryClient,
traceProvider: options.TraceProvider,
valueService: options.ValueService,
natskv: options.NatsKeyValue,
}
if err := setIdentityBackends(options, &svc); err != nil {
return svc, err
}
if options.PermissionService == nil {
grpcClient, err := grpc.NewClient(append(grpc.GetClientOptions(options.Config.GRPCClientTLS), grpc.WithTraceProvider(options.TraceProvider))...)
if err != nil {
return svc, err
}
svc.permissionsService = settingssvc.NewPermissionService("qsfera.api.settings", grpcClient)
} else {
svc.permissionsService = options.PermissionService
}
svc.roleService = options.RoleService
roleManager := options.RoleManager
if roleManager == nil {
storeOptions := []microstore.Option{
store.Store(options.Config.Cache.Store),
store.TTL(options.Config.Cache.TTL),
microstore.Nodes(options.Config.Cache.Nodes...),
microstore.Database(options.Config.Cache.Database),
microstore.Table(options.Config.Cache.Table),
store.DisablePersistence(options.Config.Cache.DisablePersistence),
store.Authentication(options.Config.Cache.AuthUsername, options.Config.Cache.AuthPassword),
}
m := roles.NewManager(
roles.StoreOptions(storeOptions),
roles.Logger(options.Logger),
roles.RoleService(options.RoleService),
)
roleManager = &m
}
var requireAdmin func(http.Handler) http.Handler
if options.RequireAdminMiddleware == nil {
requireAdmin = graphm.RequireAdmin(roleManager, options.Logger)
} else {
requireAdmin = options.RequireAdminMiddleware
}
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Use(middleware.StripSlashes)
r.Route("/v1beta1", func(r chi.Router) {
r.Route("/me", func(r chi.Router) {
r.Get("/drives", svc.GetDrives(APIVersion_1_Beta_1))
r.Route("/drive", func(r chi.Router) {
r.Get("/sharedByMe", svc.GetSharedByMe)
r.Get("/sharedWithMe", svc.ListSharedWithMe)
})
})
r.Route("/drives", func(r chi.Router) {
r.Get("/", svc.GetAllDrives(APIVersion_1_Beta_1))
r.Route("/{driveID}", func(r chi.Router) {
r.Route("/root", func(r chi.Router) {
r.Post("/children", drivesDriveItemApi.CreateDriveItem)
r.Post("/invite", driveItemPermissionsApi.SpaceRootInvite)
r.Post("/createLink", driveItemPermissionsApi.CreateSpaceRootLink)
r.Route("/permissions", func(r chi.Router) {
r.Get("/", driveItemPermissionsApi.ListSpaceRootPermissions)
r.Route("/{permissionID}", func(r chi.Router) {
r.Delete("/", driveItemPermissionsApi.DeleteSpaceRootPermission)
r.Patch("/", driveItemPermissionsApi.UpdateSpaceRootPermission)
r.Post("/setPassword", driveItemPermissionsApi.SetSpaceRootLinkPassword)
})
})
})
r.Route("/items/{itemID}", func(r chi.Router) {
r.Get("/", drivesDriveItemApi.GetDriveItem)
r.Patch("/", drivesDriveItemApi.UpdateDriveItem)
r.Delete("/", drivesDriveItemApi.DeleteDriveItem)
r.Post("/invite", driveItemPermissionsApi.Invite)
r.Post("/createLink", driveItemPermissionsApi.CreateLink)
r.Route("/permissions", func(r chi.Router) {
r.Get("/", driveItemPermissionsApi.ListPermissions)
r.Route("/{permissionID}", func(r chi.Router) {
r.Delete("/", driveItemPermissionsApi.DeletePermission)
r.Patch("/", driveItemPermissionsApi.UpdatePermission)
r.Post("/setPassword", driveItemPermissionsApi.SetLinkPassword)
})
})
})
})
})
r.Route("/roleManagement/permissions/roleDefinitions", func(r chi.Router) {
r.Get("/", svc.GetRoleDefinitions)
r.Get("/{roleID}", svc.GetRoleDefinition)
})
})
r.Route("/v1.0", func(r chi.Router) {
r.Route("/extensions/org.libregraph", func(r chi.Router) {
r.Get("/tags", svc.GetTags)
r.Put("/tags", svc.AssignTags)
r.Delete("/tags", svc.UnassignTags)
})
r.Route("/applications", func(r chi.Router) {
r.Get("/", svc.ListApplications)
r.Get("/{applicationID}", svc.GetApplication)
})
r.Route("/me", func(r chi.Router) {
r.Get("/", svc.GetMe)
r.Patch("/", svc.PatchMe)
r.Route("/drive", func(r chi.Router) {
r.Get("/", svc.GetUserDrive)
r.Get("/root/children", svc.GetRootDriveChildren)
r.Post("/items/{itemID}/follow", svc.FollowDriveItem)
r.Delete("/following/{itemID}", svc.UnfollowDriveItem)
})
r.Get("/drives", svc.GetDrives(APIVersion_1))
r.Post("/changePassword", svc.ChangeOwnPassword)
r.Route("/photo/$value", func(r chi.Router) {
r.Get("/", usersUserProfilePhotoApi.GetProfilePhoto(GetUserIDFromCTX))
r.Put("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX))
r.Patch("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX))
r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto(GetUserIDFromCTX))
})
})
r.Route("/users", func(r chi.Router) {
r.Get("/", svc.GetUsers)
r.With(requireAdmin).Post("/", svc.PostUser)
r.Route("/{userID}", func(r chi.Router) {
r.Get("/", svc.GetUser)
r.Get("/drive", svc.GetUserDrive)
r.Post("/exportPersonalData", svc.ExportPersonalData)
r.Route("/photo/$value", func(r chi.Router) {
r.Get("/", usersUserProfilePhotoApi.GetProfilePhoto(GetSlugValue("userID")))
})
r.With(requireAdmin).Delete("/", svc.DeleteUser)
r.With(requireAdmin).Patch("/", svc.PatchUser)
if svc.roleService != nil {
r.With(requireAdmin).Route("/appRoleAssignments", func(r chi.Router) {
r.Get("/", svc.ListAppRoleAssignments)
r.Post("/", svc.CreateAppRoleAssignment)
r.Delete("/{appRoleAssignmentID}", svc.DeleteAppRoleAssignment)
})
}
})
})
r.Route("/groups", func(r chi.Router) {
r.Get("/", svc.GetGroups)
r.With(requireAdmin).Post("/", svc.PostGroup)
r.Route("/{groupID}", func(r chi.Router) {
r.Get("/", svc.GetGroup)
r.With(requireAdmin).Delete("/", svc.DeleteGroup)
r.With(requireAdmin).Patch("/", svc.PatchGroup)
r.Route("/members", func(r chi.Router) {
r.With(requireAdmin).Get("/", svc.GetGroupMembers)
r.With(requireAdmin).Post("/$ref", svc.PostGroupMember)
r.With(requireAdmin).Delete("/{memberID}/$ref", svc.DeleteGroupMember)
})
})
})
r.Route("/drives", func(r chi.Router) {
r.Get("/", svc.GetAllDrives(APIVersion_1))
r.Post("/", svc.CreateDrive)
r.Route("/{driveID}", func(r chi.Router) {
r.Patch("/", svc.UpdateDrive)
r.Get("/", svc.GetSingleDrive)
r.Delete("/", svc.DeleteDrive)
r.Route("/items/{driveItemID}", func(r chi.Router) {
r.Get("/", svc.GetDriveItem)
r.Get("/children", svc.GetDriveItemChildren)
r.Post("/createUploadSession", svc.CreateUploadSession)
})
})
})
r.With(requireAdmin).Route("/education", func(r chi.Router) {
r.Route("/schools", func(r chi.Router) {
r.Get("/", svc.GetEducationSchools)
r.Post("/", svc.PostEducationSchool)
r.Route("/{schoolID}", func(r chi.Router) {
r.Get("/", svc.GetEducationSchool)
r.Delete("/", svc.DeleteEducationSchool)
r.Patch("/", svc.PatchEducationSchool)
r.Route("/users", func(r chi.Router) {
r.Get("/", svc.GetEducationSchoolUsers)
r.Post("/$ref", svc.PostEducationSchoolUser)
r.Delete("/{userID}/$ref", svc.DeleteEducationSchoolUser)
})
r.Route("/classes", func(r chi.Router) {
r.Get("/", svc.GetEducationSchoolClasses)
r.Post("/$ref", svc.PostEducationSchoolClass)
r.Delete("/{classID}/$ref", svc.DeleteEducationSchoolClass)
})
})
})
r.Route("/users", func(r chi.Router) {
r.Get("/", svc.GetEducationUsers)
r.Post("/", svc.PostEducationUser)
r.Route("/{userID}", func(r chi.Router) {
r.Get("/", svc.GetEducationUser)
r.Delete("/", svc.DeleteEducationUser)
r.Patch("/", svc.PatchEducationUser)
})
})
r.Route("/classes", func(r chi.Router) {
r.Get("/", svc.GetEducationClasses)
r.Post("/", svc.PostEducationClass)
r.Route("/{classID}", func(r chi.Router) {
r.Get("/", svc.GetEducationClass)
r.Delete("/", svc.DeleteEducationClass)
r.Patch("/", svc.PatchEducationClass)
r.Route("/members", func(r chi.Router) {
r.Get("/", svc.GetEducationClassMembers)
r.Post("/$ref", svc.PostEducationClassMember)
r.Delete("/{memberID}/$ref", svc.DeleteEducationClassMember)
})
r.Route("/teachers", func(r chi.Router) {
r.Get("/", svc.GetEducationClassTeachers)
r.Post("/$ref", svc.PostEducationClassTeacher)
r.Delete("/{teacherID}/$ref", svc.DeleteEducationClassTeacher)
})
})
})
})
})
})
_ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
return nil
})
return svc, nil
}
func setIdentityBackends(options Options, svc *Graph) error {
if options.IdentityBackend == nil {
switch options.Config.Identity.Backend {
case "cs3":
gatewaySelector, err := pool.GatewaySelector(
options.Config.Reva.Address,
append(
options.Config.Reva.GetRevaOptions(),
pool.WithRegistry(registry.GetRegistry()),
pool.WithTracerProvider(options.TraceProvider),
)...,
)
if err != nil {
return err
}
svc.identityBackend = &identity.CS3{
Config: options.Config.Reva,
Logger: &options.Logger,
GatewaySelector: gatewaySelector,
}
case "ldap":
var err error
var tlsConf *tls.Config
if options.Config.Identity.LDAP.Insecure {
// When insecure is set to true then we don't need a certificate.
options.Config.Identity.LDAP.CACert = ""
tlsConf = &tls.Config{
MinVersion: tls.VersionTLS12,
//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
}
}
if options.Config.Identity.LDAP.CACert != "" {
if err := ocldap.WaitForCA(options.Logger,
options.Config.Identity.LDAP.Insecure,
options.Config.Identity.LDAP.CACert); err != nil {
options.Logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
}
if tlsConf == nil {
tlsConf = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
certs := x509.NewCertPool()
pemData, err := os.ReadFile(options.Config.Identity.LDAP.CACert)
if err != nil {
options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
return err
}
if !certs.AppendCertsFromPEM(pemData) {
options.Logger.Error().Msg("Error initializing LDAP Backend. Adding CA cert failed")
return err
}
tlsConf.RootCAs = certs
}
conn := ldap.NewLDAPWithReconnect(
ldap.Config{
URI: options.Config.Identity.LDAP.URI,
BindDN: options.Config.Identity.LDAP.BindDN,
BindPassword: options.Config.Identity.LDAP.BindPassword,
TLSConfig: tlsConf,
},
)
conn.SetLogger(&options.Logger.Logger)
lb, err := identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger)
if err != nil {
options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
return err
}
svc.identityBackend = lb
if options.IdentityEducationBackend == nil {
if options.Config.Identity.LDAP.EducationResourcesEnabled {
svc.identityEducationBackend = lb
} else {
errEduBackend := &identity.ErrEducationBackend{}
svc.identityEducationBackend = errEduBackend
}
}
disableMechanismType, err := identity.ParseDisableMechanismType(options.Config.Identity.LDAP.DisableUserMechanism)
if err != nil {
options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
return err
}
if disableMechanismType == identity.DisableMechanismGroup {
options.Logger.Info().Msg("LocalUserDisable is true, will create group if not exists")
err := lb.CreateLDAPGroupByDN(options.Config.Identity.LDAP.LdapDisabledUsersGroupDN)
if err != nil {
isAnError := false
var lerr *ldapv3.Error
if errors.As(err, &lerr) {
if lerr.ResultCode != ldapv3.LDAPResultEntryAlreadyExists {
isAnError = true
}
} else {
isAnError = true
}
if isAnError {
msg := "error adding group for disabling users"
options.Logger.Error().Err(err).Str("local_user_disable", options.Config.Identity.LDAP.LdapDisabledUsersGroupDN).Msg(msg)
return err
}
}
}
default:
err := fmt.Errorf("unknown identity backend: '%s'", options.Config.Identity.Backend)
options.Logger.Err(err)
return err
}
} else {
svc.identityBackend = options.IdentityBackend
}
return svc.StartListenForLogonEvents(options.Context, options.Logger)
}
func (g *Graph) StartListenForLogonEvents(ctx context.Context, l log.Logger) error {
if g.eventsConsumer == nil {
return nil
}
var _registeredEvents = []events.Unmarshaller{
events.UserSignedIn{},
}
evChannel, err := events.Consume(g.eventsConsumer, "graph", _registeredEvents...)
if err != nil {
l.Error().Err(err).Msg("cannot consume from nats")
return err
}
go func() {
for loop := true; loop; {
select {
case e := <-evChannel:
switch ev := e.Event.(type) {
default:
l.Error().Interface("event", e).Msg("unhandled event")
case events.UserSignedIn:
if err := g.identityBackend.UpdateLastSignInDate(ctx, ev.Executant.OpaqueId, utils.TSToTime(ev.Timestamp)); err != nil {
l.Error().Err(err).Str("userid", ev.Executant.OpaqueId).Msg("Error updating last sign in date")
}
}
case <-ctx.Done():
l.Info().Msg("context cancelled")
loop = false
}
}
}()
return nil
}
// parseHeaderPurge parses the 'Purge' header.
// '1', 't', 'T', 'TRUE', 'true', 'True' are parsed as true
// all other values are false.
func parsePurgeHeader(h http.Header) bool {
val := h.Get(HeaderPurge)
if b, err := strconv.ParseBool(val); err == nil {
return b
}
return false
}
@@ -0,0 +1,30 @@
package svc
import (
"net/http"
"testing"
)
func TestParsePurgeHeader(t *testing.T) {
tests := map[string]bool{
"": false,
"f": false,
"F": false,
"anything": false,
"t": true,
"T": true,
}
for input, expected := range tests {
h := make(http.Header)
h.Add(HeaderPurge, input)
if expected != parsePurgeHeader(h) {
t.Errorf("parsePurgeHeader with input %s got %t expected %t", input, !expected, expected)
}
}
if h := make(http.Header); parsePurgeHeader(h) {
t.Error("parsePurgeHeader without Purge header set got true expected false")
}
}
@@ -0,0 +1,101 @@
package svc
import (
"fmt"
"net/http"
"strings"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type driveItemsByResourceID map[string]libregraph.DriveItem
// GetSharedByMe implements the Service interface (/me/drives/sharedByMe endpoint)
func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) {
g.logger.Debug().Msg("Calling GetRootDriveChildren")
ctx := r.Context()
driveItems, err := g.listUserShares(ctx, nil, make(driveItemsByResourceID))
if err != nil {
errorcode.RenderError(w, r, err)
return
}
if g.config.IncludeOCMSharees {
driveItems, err = g.listOCMShares(ctx, nil, driveItems)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
}
driveItems, err = g.listPublicShares(ctx, nil, driveItems)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
expand := r.URL.Query().Get("$expand")
expandThumbnails := strings.Contains(expand, "thumbnails")
if expandThumbnails {
for k, item := range driveItems {
mt := item.GetFile().MimeType
if mt == nil {
continue
}
_, match := thumbnail.SupportedMimeTypes[*mt]
if match {
baseUrl := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail",
g.config.Commons.QsferaURL,
item.GetId())
smallUrl := baseUrl + "&x=36&y=36"
mediumUrl := baseUrl + "&x=48&y=48"
largeUrl := baseUrl + "&x=96&y=96"
item.SetThumbnails([]libregraph.ThumbnailSet{
{
Small: &libregraph.Thumbnail{Url: &smallUrl},
Medium: &libregraph.Thumbnail{Url: &mediumUrl},
Large: &libregraph.Thumbnail{Url: &largeUrl},
},
})
driveItems[k] = item // assign modified item back to the map
}
}
}
res := make([]libregraph.DriveItem, 0, len(driveItems))
for _, v := range driveItems {
res = append(res, v)
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: res})
}
func cs3StatusToErrCode(code rpc.Code) (errcode errorcode.ErrorCode) {
switch code {
case rpc.Code_CODE_UNAUTHENTICATED:
errcode = errorcode.Unauthenticated
case rpc.Code_CODE_PERMISSION_DENIED:
errcode = errorcode.AccessDenied
case rpc.Code_CODE_NOT_FOUND:
errcode = errorcode.ItemNotFound
case rpc.Code_CODE_LOCKED:
errcode = errorcode.ItemIsLocked
case rpc.Code_CODE_INVALID_ARGUMENT:
errcode = errorcode.InvalidRequest
case rpc.Code_CODE_FAILED_PRECONDITION:
errcode = errorcode.InvalidRequest
default:
errcode = errorcode.GeneralException
}
return errcode
}
@@ -0,0 +1,538 @@
package svc_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/conversions"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/qsfera/server/services/graph/pkg/linktype"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
var _ = Describe("sharedbyme", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
identityBackend *identitymocks.Backend
driveItemCreateLink *libregraph.DriveItemCreateLink
publicShare link.PublicShare
rr *httptest.ResponseRecorder
)
expiration := time.Now()
editorResourcePermissions := conversions.NewEditorRole().CS3ResourcePermissions()
userShare := collaboration.Share{
Id: &collaboration.ShareId{
OpaqueId: "share-id",
},
ResourceId: &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "opaqueid",
},
Grantee: &provider.Grantee{
Type: provider.GranteeType_GRANTEE_TYPE_USER,
Id: &provider.Grantee_UserId{
UserId: &userpb.UserId{
OpaqueId: "user-id",
},
},
},
Permissions: &collaboration.SharePermissions{
Permissions: editorResourcePermissions,
},
}
groupShare := collaboration.Share{
Id: &collaboration.ShareId{
OpaqueId: "share-id",
},
ResourceId: &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "opaqueid",
},
Grantee: &provider.Grantee{
Type: provider.GranteeType_GRANTEE_TYPE_GROUP,
Id: &provider.Grantee_GroupId{
GroupId: &grouppb.GroupId{
OpaqueId: "group-id",
},
},
},
Permissions: &collaboration.SharePermissions{
Permissions: editorResourcePermissions,
},
}
userShareWithExpiration := collaboration.Share{
Id: &collaboration.ShareId{
OpaqueId: "expire-share-id",
},
ResourceId: &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "expire-opaqueid",
},
Grantee: &provider.Grantee{
Type: provider.GranteeType_GRANTEE_TYPE_USER,
Id: &provider.Grantee_UserId{
UserId: &userpb.UserId{
OpaqueId: "user-id",
},
},
},
Permissions: &collaboration.SharePermissions{
Permissions: editorResourcePermissions,
},
Expiration: utils.TimeToTS(expiration),
}
driveItemCreateLink = &libregraph.DriveItemCreateLink{}
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
linkType, err := libregraph.NewSharingLinkTypeFromValue("view")
Expect(err).To(BeNil())
driveItemCreateLink.Type = linkType
driveItemCreateLink.ExpirationDateTime = libregraph.PtrTime(time.Now().Add(time.Hour))
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(*driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
Expect(err).To(BeNil())
publicShare = link.PublicShare{
Id: &link.PublicShareId{
OpaqueId: "public-share-id",
},
Token: "public-share-token",
ResourceId: &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "public-share-opaqueid",
},
Permissions: &link.PublicSharePermissions{Permissions: permissions},
}
rr = httptest.NewRecorder()
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewayClient.On("Stat",
mock.Anything,
mock.MatchedBy(
func(req *provider.StatRequest) bool {
return req.Ref.ResourceId.OpaqueId == userShareWithExpiration.ResourceId.OpaqueId
})).
Return(&provider.StatResponse{
Status: status.NewOK(ctx),
Info: &provider.ResourceInfo{
Id: userShareWithExpiration.ResourceId,
},
}, nil)
gatewayClient.On("Stat",
mock.Anything,
mock.MatchedBy(
func(req *provider.StatRequest) bool {
return req.Ref.ResourceId.OpaqueId == publicShare.ResourceId.OpaqueId
})).
Return(&provider.StatResponse{
Status: status.NewOK(ctx),
Info: &provider.ResourceInfo{
Id: publicShare.ResourceId,
},
}, nil)
gatewayClient.On("Stat",
mock.Anything,
mock.Anything).
Return(&provider.StatResponse{
Status: status.NewOK(ctx),
Info: &provider.ResourceInfo{
Id: userShare.ResourceId,
Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER,
},
}, nil)
gatewayClient.On("GetUser",
mock.Anything,
mock.MatchedBy(func(req *userpb.GetUserRequest) bool {
return req.UserId.OpaqueId == "user-id"
})).
Return(&userpb.GetUserResponse{
Status: status.NewOK(ctx),
User: &userpb.User{
Id: &userpb.UserId{
Idp: "idp",
OpaqueId: "user-id",
},
DisplayName: "User Name",
},
}, nil)
gatewayClient.On("GetUser",
mock.Anything,
mock.Anything).
Return(&userpb.GetUserResponse{
Status: status.NewNotFound(ctx, "mock user not found"),
User: nil,
}, nil)
gatewayClient.On("GetGroup",
mock.Anything,
mock.MatchedBy(func(req *grouppb.GetGroupRequest) bool {
return req.GroupId.OpaqueId == "group-id"
})).
Return(&grouppb.GetGroupResponse{
Status: status.NewOK(ctx),
Group: &grouppb.Group{
Id: &grouppb.GroupId{
Idp: "idp",
OpaqueId: "group-id",
},
DisplayName: "Group Name",
},
}, nil)
gatewayClient.On("GetGroup",
mock.Anything,
mock.Anything).
Return(&grouppb.GetGroupResponse{
Status: status.NewNotFound(ctx, "mock group not found"),
Group: nil,
}, nil)
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityBackend = &identitymocks.Backend{}
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{}
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
)
Expect(err).ToNot(HaveOccurred())
})
emptyListPublicSharesMock := func() {
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
&link.ListPublicSharesResponse{
Status: status.NewOK(ctx),
Share: []*link.PublicShare{},
},
nil,
)
}
emptyListSharesMock := func() {
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
&collaboration.ListSharesResponse{
Status: status.NewOK(ctx),
Shares: []*collaboration.Share{},
},
nil,
)
}
Describe("GetSharedByMe", func() {
It("handles a failing ListShares", func() {
emptyListPublicSharesMock()
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(nil, errors.New("some error"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("handles ListShares returning an error status", func() {
emptyListPublicSharesMock()
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
&collaboration.ListSharesResponse{Status: status.NewInternal(ctx, "error listing shares")},
nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("succeeds, when no shares are returned", func() {
emptyListPublicSharesMock()
emptyListSharesMock()
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(0))
})
It("returns a proper driveItem, when a single user share is returned", func() {
emptyListPublicSharesMock()
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
&collaboration.ListSharesResponse{
Status: status.NewOK(ctx),
Shares: []*collaboration.Share{
&userShare,
},
},
nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
di := res.Value[0]
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(userShare.GetResourceId())))
perm := di.GetPermissions()
Expect(perm[0].GetId()).To(Equal(userShare.GetId().GetOpaqueId()))
_, ok := perm[0].GetExpirationDateTimeOk()
Expect(ok).To(BeFalse())
_, ok = perm[0].GrantedToV2.GetGroupOk()
Expect(ok).To(BeFalse())
user, ok := perm[0].GrantedToV2.GetUserOk()
Expect(ok).To(BeTrue())
Expect(user.GetId()).To(Equal(userShare.GetGrantee().GetUserId().GetOpaqueId()))
_, ok = perm[0].GetLinkOk()
Expect(ok).To(BeFalse())
roles, ok := perm[0].GetRolesOk()
Expect(ok).To(BeTrue())
Expect(len(roles)).To(Equal(1))
Expect(roles[0]).To(Equal(unifiedrole.UnifiedRoleEditorID))
})
It("returns a proper driveItem, when a single group share is returned", func() {
emptyListPublicSharesMock()
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
&collaboration.ListSharesResponse{
Status: status.NewOK(ctx),
Shares: []*collaboration.Share{
&groupShare,
},
},
nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
di := res.Value[0]
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(groupShare.GetResourceId())))
perm := di.GetPermissions()
Expect(perm[0].GetId()).To(Equal(userShare.GetId().GetOpaqueId()))
_, ok := perm[0].GetExpirationDateTimeOk()
Expect(ok).To(BeFalse())
_, ok = perm[0].GrantedToV2.GetUserOk()
Expect(ok).To(BeFalse())
group, ok := perm[0].GrantedToV2.GetGroupOk()
Expect(ok).To(BeTrue())
Expect(group.GetId()).To(Equal(groupShare.GetGrantee().GetGroupId().GetOpaqueId()))
_, ok = perm[0].GetLinkOk()
Expect(ok).To(BeFalse())
roles, ok := perm[0].GetRolesOk()
Expect(ok).To(BeTrue())
Expect(len(roles)).To(Equal(1))
Expect(roles[0]).To(Equal(unifiedrole.UnifiedRoleEditorID))
})
It("returns a single driveItem, when a mulitple shares for the same resource are returned", func() {
emptyListPublicSharesMock()
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
&collaboration.ListSharesResponse{
Status: status.NewOK(ctx),
Shares: []*collaboration.Share{
&groupShare,
&userShare,
},
},
nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
di := res.Value[0]
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(groupShare.GetResourceId())))
// one permission per share
Expect(len(di.GetPermissions())).To(Equal(2))
})
It("return a driveItem with the expiration date set, for expiring shares", func() {
emptyListPublicSharesMock()
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
&collaboration.ListSharesResponse{
Status: status.NewOK(ctx),
Shares: []*collaboration.Share{
&userShareWithExpiration,
},
},
nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
di := res.Value[0]
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(userShareWithExpiration.GetResourceId())))
perm := di.GetPermissions()
Expect(perm[0].GetId()).To(Equal(userShareWithExpiration.GetId().GetOpaqueId()))
exp, ok := perm[0].GetExpirationDateTimeOk()
Expect(ok).To(BeTrue())
Expect(exp.Equal(expiration)).To(BeTrue())
_, ok = perm[0].GrantedToV2.GetGroupOk()
Expect(ok).To(BeFalse())
user, ok := perm[0].GrantedToV2.GetUserOk()
Expect(ok).To(BeTrue())
Expect(user.GetId()).To(Equal(userShareWithExpiration.GetGrantee().GetUserId().GetOpaqueId()))
_, ok = perm[0].GetLinkOk()
Expect(ok).To(BeFalse())
})
// Public Shares / "links" in graph terms
It("handles a failing ListPublicShares", func() {
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(nil, errors.New("some error"))
emptyListSharesMock()
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("handles ListPublicShares returning an error status", func() {
emptyListSharesMock()
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
&link.ListPublicSharesResponse{Status: status.NewInternal(ctx, "error listing shares")},
nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("returns a proper driveItem, when a single public share is returned", func() {
emptyListSharesMock()
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
&link.ListPublicSharesResponse{
Status: status.NewOK(ctx),
Share: []*link.PublicShare{
&publicShare,
},
},
nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
svc.GetSharedByMe(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
di := res.Value[0]
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(publicShare.GetResourceId())))
perm := di.GetPermissions()
Expect(perm[0].GetId()).To(Equal(publicShare.GetId().GetOpaqueId()))
_, ok := perm[0].GetExpirationDateTimeOk()
Expect(ok).To(BeFalse())
_, ok = perm[0].GetGrantedToV2Ok()
Expect(ok).To(BeFalse())
link, ok := perm[0].GetLinkOk()
Expect(ok).To(BeTrue())
Expect(link.GetWebUrl()).To(Equal("https://localhost:9200/s/" + publicShare.GetToken()))
})
})
})
@@ -0,0 +1,104 @@
package svc
import (
"context"
"fmt"
"net/http"
"strings"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail"
)
// ListSharedWithMe lists the files shared with the current user.
func (g Graph) ListSharedWithMe(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
expand := r.URL.Query().Get("$expand")
expandThumbnails := strings.Contains(expand, "thumbnails")
driveItems, err := g.listSharedWithMe(ctx, expandThumbnails)
if err != nil {
g.logger.Error().Err(err).Msg("listSharedWithMe failed")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: driveItems})
}
// listSharedWithMe is a helper function that lists the drive items shared with the current user.
func (g Graph) listSharedWithMe(ctx context.Context, expandThumbnails bool) ([]libregraph.DriveItem, error) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
return nil, err
}
listReceivedSharesResponse, err := gatewayClient.ListReceivedShares(ctx, &collaboration.ListReceivedSharesRequest{
Filters: []*collaboration.Filter{
share.SpaceRootFilter(false),
},
})
if err := errorcode.FromCS3Status(listReceivedSharesResponse.GetStatus(), err); err != nil {
g.logger.Error().Err(err).Msg("listing shares failed")
return nil, err
}
driveItems, err := cs3ReceivedSharesToDriveItems(ctx, g.logger, gatewayClient, g.identityCache, listReceivedSharesResponse.GetShares(), g.availableRoles)
if err != nil {
g.logger.Error().Err(err).Msg("could not convert received shares to drive items")
return nil, err
}
if g.config.IncludeOCMSharees {
listReceivedOCMSharesResponse, err := gatewayClient.ListReceivedOCMShares(ctx, &ocm.ListReceivedOCMSharesRequest{})
if err := errorcode.FromCS3Status(listReceivedSharesResponse.GetStatus(), err); err != nil {
g.logger.Error().Err(err).Msg("listing shares failed")
return nil, err
}
ocmDriveItems, err := cs3ReceivedOCMSharesToDriveItems(ctx, g.logger, gatewayClient, g.identityCache, listReceivedOCMSharesResponse.GetShares(), g.availableRoles)
if err != nil {
g.logger.Error().Err(err).Msg("could not convert received ocm shares to drive items")
return nil, err
}
driveItems = append(driveItems, ocmDriveItems...)
}
if expandThumbnails {
for k, item := range driveItems {
mt := item.GetFile().MimeType
if mt == nil {
continue
}
_, match := thumbnail.SupportedMimeTypes[*mt]
if match {
baseUrl := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail",
g.config.Commons.QsferaURL,
item.RemoteItem.GetId())
smallUrl := baseUrl + "&x=36&y=36"
mediumUrl := baseUrl + "&x=48&y=48"
largeUrl := baseUrl + "&x=96&y=96"
item.SetThumbnails([]libregraph.ThumbnailSet{
{
Small: &libregraph.Thumbnail{Url: &smallUrl},
Medium: &libregraph.Thumbnail{Url: &mediumUrl},
Large: &libregraph.Thumbnail{Url: &largeUrl},
},
})
driveItems[k] = item // assign modified item back to the map
}
}
}
return driveItems, err
}
@@ -0,0 +1,417 @@
package svc_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
groupv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
roleconversions "github.com/opencloud-eu/reva/v2/pkg/conversions"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"github.com/tidwall/gjson"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
// "github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
var _ = Describe("SharedWithMe", func() {
var (
svc service.Service
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
identityBackend *identitymocks.Backend
ctx context.Context
tape *httptest.ResponseRecorder
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityBackend = &identitymocks.Backend{}
tape = 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{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.WithIdentityBackend(identityBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("ListSharedWithMe", func() {
var (
listReceivedSharesResponse *collaborationv1beta1.ListReceivedSharesResponse
statResponse *providerv1beta1.StatResponse
getUserResponseDefault *userv1beta1.GetUserResponse
getUserResponseResourceCreator *userv1beta1.GetUserResponse
getUserResponseShareCreator *userv1beta1.GetUserResponse
)
toResourceID := func(in string) *providerv1beta1.ResourceId {
out, err := storagespace.ParseID(in)
Expect(err).To(BeNil())
return &out
}
BeforeEach(func() {
getUserResponseDefault = &userv1beta1.GetUserResponse{
Status: status.NewOK(ctx),
User: &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "2699b42d-c6ca-4ce1-90de-89dedfb3022c",
},
DisplayName: "John Romero",
},
}
getUserResponseResourceCreator = &userv1beta1.GetUserResponse{
Status: status.NewOK(ctx),
User: &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "resource-creator-id",
},
DisplayName: "Resource Creator",
},
}
getUserResponseShareCreator = &userv1beta1.GetUserResponse{
Status: status.NewOK(ctx),
User: &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "share-creator-id",
},
DisplayName: "Share Creator",
},
}
gatewayClient.On("GetUser", mock.Anything, mock.MatchedBy(
func(req *userv1beta1.GetUserRequest) bool {
return req.UserId.OpaqueId == "resource-creator-id"
})).
Return(getUserResponseResourceCreator, nil)
gatewayClient.On("GetUser", mock.Anything, mock.MatchedBy(
func(req *userv1beta1.GetUserRequest) bool {
return req.UserId.OpaqueId == "share-creator-id"
})).
Return(getUserResponseShareCreator, nil)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponseDefault, nil)
gatewayClient.On("GetGroup", mock.Anything, mock.Anything).
Return(
&groupv1beta1.GetGroupResponse{
Status: status.NewOK(ctx),
Group: &groupv1beta1.Group{
Id: &groupv1beta1.GroupId{
OpaqueId: "group-id",
},
DisplayName: "Group",
},
}, nil)
listReceivedSharesResponse = &collaborationv1beta1.ListReceivedSharesResponse{
Status: status.NewOK(ctx),
Shares: []*collaborationv1beta1.ReceivedShare{
{
Share: &collaborationv1beta1.Share{
ResourceId: toResourceID("1$2!3"),
Id: &collaborationv1beta1.ShareId{
OpaqueId: "sh:are:id",
},
Permissions: &collaborationv1beta1.SharePermissions{
Permissions: roleconversions.NewViewerRole().CS3ResourcePermissions(),
},
Creator: getUserResponseShareCreator.User.Id,
Ctime: utils.TSNow(),
},
MountPoint: &providerv1beta1.Reference{
ResourceId: &providerv1beta1.ResourceId{
StorageId: utils.ShareStorageProviderID,
SpaceId: utils.ShareStorageSpaceID,
},
Path: "some folder",
},
State: collaborationv1beta1.ShareState_SHARE_STATE_ACCEPTED,
},
},
}
gatewayClient.On("ListReceivedShares", mock.Anything, mock.Anything).Return(listReceivedSharesResponse, nil)
statResponse = &providerv1beta1.StatResponse{
Status: status.NewOK(ctx),
Info: &providerv1beta1.ResourceInfo{
Type: providerv1beta1.ResourceType_RESOURCE_TYPE_CONTAINER,
},
}
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(func(_ context.Context, r *providerv1beta1.StatRequest, _ ...grpc.CallOption) (*providerv1beta1.StatResponse, error) {
// copy the response to avoid side effects
res := proto.Clone(statResponse).(*providerv1beta1.StatResponse)
for _, share := range listReceivedSharesResponse.Shares {
if share.Share.ResourceId != r.Ref.ResourceId {
continue
}
res.Info.Id = share.Share.ResourceId
return res, nil
}
return nil, nil
})
})
It("fails if no received shares were found", func() {
listReceivedSharesResponse.Status = status.NewNotFound(ctx, "msg")
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
Expect(tape.Code, errorcode.ItemNotFound)
})
It("includes hidden shares", func() {
listReceivedSharesResponse.Shares = append(listReceivedSharesResponse.Shares, &collaborationv1beta1.ReceivedShare{
Hidden: true,
Share: &collaborationv1beta1.Share{
ResourceId: toResourceID("7$8!9"),
Ctime: utils.TSNow(),
},
})
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value")
Expect(len(listReceivedSharesResponse.Shares)).To(Equal(2))
Expect(jsonData.Get("#").Num).To(Equal(float64(2)))
})
It("populates the driveItem properties", func() {
share := listReceivedSharesResponse.Shares[0].Share
share.Id = &collaborationv1beta1.ShareId{OpaqueId: "1:2:3"}
share.Ctime = &typesv1beta1.Timestamp{Seconds: 4001}
share.Mtime = &typesv1beta1.Timestamp{Seconds: 4002}
resourceInfo := statResponse.Info
resourceInfo.Name = "some folder"
resourceInfo.Etag = "\"5ffb8e4bec7026050af7fde9482b289a\""
resourceInfo.Owner = &userv1beta1.UserId{
OpaqueId: "resource-creator-id",
}
resourceInfo.Mtime = &typesv1beta1.Timestamp{Seconds: 40000}
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value.0")
Expect(jsonData.Get("eTag").String()).To(Equal(resourceInfo.Etag))
Expect(jsonData.Get("id").String()).To(Equal(storagespace.FormatResourceID(
&providerv1beta1.ResourceId{
StorageId: utils.ShareStorageProviderID,
SpaceId: utils.ShareStorageSpaceID,
OpaqueId: share.Id.OpaqueId,
},
)))
Expect(jsonData.Get("lastModifiedDateTime").String()).To(Equal(utils.TSToTime(resourceInfo.Mtime).Format(time.RFC3339Nano)))
Expect(jsonData.Get("createdBy.user.id").String()).To(Equal(resourceInfo.Owner.OpaqueId))
Expect(jsonData.Get("name").String()).To(Equal(resourceInfo.Name))
})
It("populates the driveItem parentReference properties", func() {
share := listReceivedSharesResponse.Shares[0].Share
share.Id = &collaborationv1beta1.ShareId{OpaqueId: "1:2:3"}
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value.0.parentReference")
Expect(jsonData.Get("driveId").String()).To(Equal(storagespace.FormatStorageID(utils.ShareStorageProviderID, utils.ShareStorageSpaceID)))
})
It("populates the driveItem remoteItem properties", func() {
share := listReceivedSharesResponse.Shares[0].Share
share.Id = &collaborationv1beta1.ShareId{OpaqueId: "1:2:3"}
share.Ctime = &typesv1beta1.Timestamp{Seconds: 4001}
share.Mtime = &typesv1beta1.Timestamp{Seconds: 40002}
resourceInfo := statResponse.Info
resourceInfo.Name = "some folder"
resourceInfo.Mtime = &typesv1beta1.Timestamp{Seconds: 40000}
resourceInfo.Size = 500
resourceInfo.Etag = "\"5ffb8e4bec7026050af7fde9482b289a\""
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem")
Expect(jsonData.Get("eTag").String()).To(Equal(resourceInfo.Etag))
Expect(jsonData.Get("id").String()).To(Equal(storagespace.FormatResourceID(share.ResourceId)))
Expect(jsonData.Get("lastModifiedDateTime").String()).To(Equal(utils.TSToTime(resourceInfo.Mtime).Format(time.RFC3339Nano)))
Expect(jsonData.Get("name").String()).To(Equal(resourceInfo.Name))
Expect(jsonData.Get("size").Num).To(Equal(float64(resourceInfo.Size)))
})
It("populates the driveItem.remoteItem.createdBy properties", func() {
driveOwner := getUserResponseDefault.User
resourceInfo := statResponse.Info
resourceInfo.Owner = driveOwner.Id
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem.createdBy")
Expect(jsonData.Get("user.displayName").String()).To(Equal(driveOwner.DisplayName))
Expect(jsonData.Get("user.id").String()).To(Equal(driveOwner.Id.OpaqueId))
})
It("populates the driveItem.remoteItem.folder properties", func() {
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem")
Expect(jsonData.Get("file").Exists()).To(BeFalse())
Expect(jsonData.Get("folder.childCount").Num).To(Equal(float64(0)))
})
It("populates the driveItem.remoteItem.file properties", func() {
resourceInfo := statResponse.Info
resourceInfo.Type = providerv1beta1.ResourceType_RESOURCE_TYPE_FILE
resourceInfo.MimeType = "application/pdf"
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem")
Expect(jsonData.Get("folder").Exists()).To(BeFalse())
Expect(jsonData.Get("file.mimeType").String()).To(Equal(resourceInfo.MimeType))
})
It("returns shares created on project space", func() {
ownerID := &userv1beta1.UserId{
OpaqueId: "project-space-id",
Type: userv1beta1.UserType_USER_TYPE_SPACE_OWNER,
}
share := listReceivedSharesResponse.Shares[0].Share
share.Owner = ownerID
resourceInfo := statResponse.Info
resourceInfo.Owner = ownerID
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
jsonData := gjson.Get(tape.Body.String(), "value.0.createdBy")
Expect(jsonData.String()).To(Equal(""))
jsonData = gjson.Get(tape.Body.String(), "value.0.remoteItem.permissions.0.invitation.invitedBy.user")
Expect(jsonData.Get("displayName").String()).To(Equal(getUserResponseShareCreator.User.DisplayName))
Expect(jsonData.Get("id").String()).To(Equal(getUserResponseShareCreator.User.Id.OpaqueId))
})
It("returns a single drive item when multiple shares exist for the same resource", func() {
anotherShare := &collaborationv1beta1.ReceivedShare{
Share: &collaborationv1beta1.Share{
ResourceId: toResourceID("1$2!3"),
Id: &collaborationv1beta1.ShareId{
OpaqueId: "sh:are:id2",
},
Permissions: &collaborationv1beta1.SharePermissions{
Permissions: roleconversions.NewViewerRole().CS3ResourcePermissions(),
},
Creator: getUserResponseShareCreator.User.Id,
Ctime: utils.TSNow(),
Grantee: &providerv1beta1.Grantee{
Type: providerv1beta1.GranteeType_GRANTEE_TYPE_GROUP,
Id: &providerv1beta1.Grantee_GroupId{
GroupId: &groupv1beta1.GroupId{
OpaqueId: "group-id",
},
},
},
},
}
listReceivedSharesResponse.Shares = append(listReceivedSharesResponse.Shares, anotherShare)
svc.ListSharedWithMe(
tape,
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
)
driveItems := libregraph.CollectionOfDriveItems{}
err := json.Unmarshal(tape.Body.Bytes(), &driveItems)
Expect(err).To(BeNil())
Expect(len(driveItems.Value)).To(Equal(1))
ri := driveItems.GetValue()[0].GetRemoteItem()
Expect(len(ri.GetPermissions())).To(Equal(2))
})
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,124 @@
package svc
import (
"context"
"embed"
"fmt"
"io/fs"
"path/filepath"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
v1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/qsfera/server/pkg/l10n"
l10n_pkg "github.com/qsfera/server/services/graph/pkg/l10n"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
var (
//go:embed spacetemplate/*
_spaceTemplateFS embed.FS
// name of the secret space folder
_spaceFolderName = ".space"
// path to the image file
_imagepath = "spacetemplate/image.png"
// default description for new spaces
_readmeText = l10n.Template("Here you can add a description for this Space.")
// name of the readme.md file
_readmeName = "readme.md"
// HeaderAcceptLanguage is the header key for the accept-language header
HeaderAcceptLanguage = "Accept-Language"
// TemplateParameter is the key for the template parameter in the request
TemplateParameter = "template"
)
func (g Graph) applySpaceTemplate(ctx context.Context, gwc gateway.GatewayAPIClient, root *storageprovider.ResourceId, template string, locale string) error {
switch template {
default:
fallthrough
case "none":
return nil
case "default":
return g.applyDefaultTemplate(ctx, gwc, root, locale)
}
}
func (g Graph) applyDefaultTemplate(ctx context.Context, gwc gateway.GatewayAPIClient, root *storageprovider.ResourceId, locale string) error {
mdc := metadata.NewCS3(g.config.Reva.Address, g.config.Spaces.StorageUsersAddress)
mdc.SpaceRoot = root
var opaque *v1beta1.Opaque
// create .space folder
if err := mdc.MakeDirIfNotExist(ctx, _spaceFolderName); err != nil {
return err
}
// upload space image
iid, err := imageUpload(ctx, mdc)
if err != nil {
return err
}
opaque = utils.AppendPlainToOpaque(opaque, SpaceImageSpecialFolderName, iid)
// upload readme.md
rid, err := readmeUpload(ctx, mdc, locale, g.config.Spaces.DefaultLanguage, g.config.Spaces.TranslationPath)
if err != nil {
return err
}
opaque = utils.AppendPlainToOpaque(opaque, ReadmeSpecialFolderName, rid)
// update space
resp, err := gwc.UpdateStorageSpace(ctx, &storageprovider.UpdateStorageSpaceRequest{
StorageSpace: &storageprovider.StorageSpace{
Id: &storageprovider.StorageSpaceId{
OpaqueId: storagespace.FormatResourceID(root),
},
Root: root,
Opaque: opaque,
},
})
switch {
case err != nil:
return err
case resp.GetStatus().GetCode() != rpc.Code_CODE_OK:
return fmt.Errorf("could not update storage space: %s", resp.GetStatus().GetMessage())
default:
return nil
}
}
func imageUpload(ctx context.Context, mdc *metadata.CS3) (string, error) {
b, err := fs.ReadFile(_spaceTemplateFS, _imagepath)
if err != nil {
return "", err
}
res, err := mdc.Upload(ctx, metadata.UploadRequest{
Path: filepath.Join(_spaceFolderName, filepath.Base(_imagepath)),
Content: b,
})
if err != nil {
return "", err
}
return res.FileID, nil
}
func readmeUpload(ctx context.Context, mdc *metadata.CS3, locale string, defaultLocale string, translationPath string) (string, error) {
res, err := mdc.Upload(ctx, metadata.UploadRequest{
Path: filepath.Join(_spaceFolderName, _readmeName),
Content: []byte(l10n_pkg.Translate(_readmeText, locale, defaultLocale, translationPath)),
})
if err != nil {
return "", err
}
return res.FileID, nil
}
@@ -0,0 +1,247 @@
package svc
import (
"net/http"
"strings"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/render"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/tags"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"go-micro.dev/v4/metadata"
)
// GetTags returns all available tags
func (g Graph) GetTags(w http.ResponseWriter, r *http.Request) {
th := r.Header.Get(revaCtx.TokenHeader)
ctx := revaCtx.ContextSetToken(r.Context(), th)
ctx = metadata.Set(ctx, revaCtx.TokenHeader, th)
sr, err := g.searchService.Search(ctx, &searchsvc.SearchRequest{
Query: "Tags:*",
PageSize: -1,
})
if err != nil {
g.logger.Error().Err(err).Msg("Could not search for tags")
w.WriteHeader(http.StatusInternalServerError)
return
}
tagList := tags.New("")
for _, match := range sr.Matches {
for _, tag := range match.Entity.Tags {
tagList.Add(tag)
}
}
tagCollection := libregraph.NewCollectionOfTags()
tagCollection.Value = tagList.AsSlice()
render.Status(r, http.StatusOK)
render.JSON(w, r, tagCollection)
}
// AssignTags assigns a tag to a resource
func (g Graph) AssignTags(w http.ResponseWriter, r *http.Request) {
var (
assignment libregraph.TagAssignment
ctx = r.Context()
)
if err := StrictJSONUnmarshal(r.Body, &assignment); err != nil {
g.logger.Debug().Err(err).Interface("body", r.Body).Msg("could not decode tag assignment request")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
return
}
rid, err := storagespace.ParseID(assignment.ResourceId)
if err != nil {
g.logger.Debug().Err(err).Msg("could not parse resourceId")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid resourceId")
return
}
client, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("error selecting next gateway client")
w.WriteHeader(http.StatusInternalServerError)
return
}
sres, err := client.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{ResourceId: &rid},
})
if err != nil {
g.logger.Error().Err(err).Msg("error stating file")
w.WriteHeader(http.StatusInternalServerError)
return
}
if sres.GetStatus().GetCode() != rpc.Code_CODE_OK {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "can't stat resource")
return
}
// use resource id from stat response to work on the actual resource and not a share jail item
rid = *sres.GetInfo().GetId()
pm := sres.GetInfo().GetPermissionSet()
if pm == nil {
g.logger.Error().Err(err).Msg("no permissionset on file")
w.WriteHeader(http.StatusInternalServerError)
return
}
// it says we need "write access" to set tags. One of those should do
if !pm.InitiateFileUpload && !pm.CreateContainer {
g.logger.Info().Msg("no permission to create a tag")
w.WriteHeader(http.StatusForbidden)
return
}
var currentTags string
if m := sres.GetInfo().GetArbitraryMetadata().GetMetadata(); m != nil {
currentTags = m["tags"]
}
allTags := tags.New(currentTags)
if !allTags.Add(assignment.Tags...) {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "no new tags in createtagsrequest or maximum reached")
return
}
resp, err := client.SetArbitraryMetadata(ctx, &provider.SetArbitraryMetadataRequest{
Ref: &provider.Reference{ResourceId: &rid},
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"tags": allTags.AsList(),
},
},
})
if err != nil || resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
g.logger.Error().Err(err).Interface("status", resp.GetStatus()).Msg("error setting tags")
if resp.GetStatus().GetCode() == rpc.Code_CODE_LOCKED {
errorcode.InvalidRequest.Render(w, r, http.StatusLocked, "file is locked")
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
if g.eventsPublisher != nil {
ev := events.TagsAdded{
Tags: strings.Join(assignment.Tags, ","),
Ref: &provider.Reference{
ResourceId: &rid,
Path: ".",
},
SpaceOwner: sres.Info.Owner,
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
}
if err := events.Publish(r.Context(), g.eventsPublisher, ev); err != nil {
g.logger.Error().Err(err).Msg("Failed to publish TagsAdded event")
}
}
}
// UnassignTags removes a tag from a resource
func (g Graph) UnassignTags(w http.ResponseWriter, r *http.Request) {
var (
unassignment libregraph.TagUnassignment
ctx = r.Context()
)
if err := StrictJSONUnmarshal(r.Body, &unassignment); err != nil {
g.logger.Debug().Err(err).Interface("body", r.Body).Msg("could not decode tag assignment request")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
return
}
rid, err := storagespace.ParseID(unassignment.ResourceId)
if err != nil {
g.logger.Debug().Err(err).Msg("could not parse resourceId")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid resourceId")
return
}
client, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("error selecting next gateway client")
w.WriteHeader(http.StatusInternalServerError)
return
}
sres, err := client.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{ResourceId: &rid},
})
if err != nil {
g.logger.Error().Err(err).Msg("error stating file")
w.WriteHeader(http.StatusInternalServerError)
return
}
if sres.GetStatus().GetCode() != rpc.Code_CODE_OK {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "can't stat resource")
return
}
// use resource id from stat response to work on the actual resource and not a share jail item
rid = *sres.GetInfo().GetId()
pm := sres.GetInfo().GetPermissionSet()
if pm == nil {
g.logger.Error().Err(err).Msg("no permissionset on file")
w.WriteHeader(http.StatusInternalServerError)
return
}
// it says we need "write access" to set tags. One of those should do
if !pm.InitiateFileUpload && !pm.CreateContainer {
g.logger.Info().Msg("no permission to create a tag")
w.WriteHeader(http.StatusForbidden)
return
}
var currentTags string
if m := sres.GetInfo().GetArbitraryMetadata().GetMetadata(); m != nil {
currentTags = m["tags"]
}
allTags := tags.New(currentTags)
if !allTags.Remove(unassignment.Tags...) {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "no new tags in createtagsrequest or maximum reached")
return
}
resp, err := client.SetArbitraryMetadata(ctx, &provider.SetArbitraryMetadataRequest{
Ref: &provider.Reference{ResourceId: &rid},
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"tags": allTags.AsList(),
},
},
})
if err != nil || resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
g.logger.Error().Err(err).Msg("error setting tags")
w.WriteHeader(http.StatusInternalServerError)
return
}
if g.eventsPublisher != nil {
ev := events.TagsRemoved{
Tags: strings.Join(unassignment.Tags, ","),
Ref: &provider.Reference{
ResourceId: &rid,
Path: ".",
},
SpaceOwner: sres.Info.Owner,
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
}
if err := events.Publish(ctx, g.eventsPublisher, ev); err != nil {
g.logger.Error().Err(err).Msg("Failed to publish TagsAdded event")
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,484 @@
package svc
import (
"context"
"strings"
"github.com/CiscoM31/godata"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
)
const (
appRoleID = "appRoleId"
appRoleAssignments = "appRoleAssignments"
unsupportedFilter = "unsupported filter"
)
func invalidFilterError() error {
return godata.BadRequestError("invalid filter")
}
func unsupportedFilterError() error {
return godata.NotImplementedError(unsupportedFilter)
}
func (g Graph) applyUserFilter(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if root == nil {
root = req.Query.Filter.Tree
}
switch root.Token.Type {
case godata.ExpressionTokenLambdaNav:
return g.applyFilterLambda(ctx, req, root.Children)
case godata.ExpressionTokenLogical:
return g.applyFilterLogical(ctx, req, root)
case godata.ExpressionTokenFunc:
return g.applyFilterFunction(ctx, req, root)
}
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return users, unsupportedFilterError()
}
func (g Graph) applyFilterFunction(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if root.Token.Type != godata.ExpressionTokenFunc {
return users, invalidFilterError()
}
switch root.Token.Value {
case "startswith":
// 'startswith' needs 2 operands
if len(root.Children) != 2 {
return users, invalidFilterError()
}
return g.applyFilterFunctionStartsWith(ctx, req, root.Children[0], root.Children[1]), nil
case "contains":
// 'contains' needs 2 operands
if len(root.Children) != 2 {
return users, invalidFilterError()
}
return g.applyFilterFunctionContains(ctx, req, root.Children[0], root.Children[1]), nil
}
logger.Debug().Str("Token", root.Token.Value).Msg("unsupported function filter")
return users, unsupportedFilterError()
}
func (g Graph) applyFilterFunctionStartsWith(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User) {
logger := g.logger.SubloggerWithRequestID(ctx)
if operand1.Token.Type != godata.ExpressionTokenLiteral {
logger.Debug().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
return users
}
switch operand1.Token.Value {
case "displayName":
var retUsers []*libregraph.User
filterValue := strings.Trim(operand2.Token.Value, "'")
logger.Debug().Str("property", operand2.Token.Value).Str("value", filterValue).Msg("Filtering displayName by startsWith")
if users, err := g.identityBackend.GetUsers(ctx, req); err == nil {
for _, user := range users {
if strings.HasPrefix(strings.ToLower(user.GetDisplayName()), strings.ToLower(filterValue)) {
retUsers = append(retUsers, user)
}
}
}
return retUsers
default:
logger.Warn().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
return nil
}
}
func (g Graph) applyFilterFunctionContains(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User) {
logger := g.logger.SubloggerWithRequestID(ctx)
if operand1.Token.Type != godata.ExpressionTokenLiteral {
logger.Debug().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
return users
}
switch operand1.Token.Value {
case "displayName":
var retUsers []*libregraph.User
filterValue := strings.Trim(operand2.Token.Value, "'")
logger.Debug().Str("property", operand2.Token.Value).Str("value", filterValue).Msg("Filtering displayName by contains")
if users, err := g.identityBackend.GetUsers(ctx, req); err == nil {
for _, user := range users {
if strings.Contains(strings.ToLower(user.GetDisplayName()), strings.ToLower(filterValue)) {
retUsers = append(retUsers, user)
}
}
}
return retUsers
default:
logger.Warn().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
return nil
}
}
func (g Graph) applyFilterLogical(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if root.Token.Type != godata.ExpressionTokenLogical {
return users, invalidFilterError()
}
// As we currently don't suppport the 'has' or 'in' operator, all our
// currently supported user filters of the ExpressionTokenLogical type
// require exactly two operands.
if len(root.Children) != 2 {
return users, invalidFilterError()
}
switch root.Token.Value {
case "and":
return g.applyFilterLogicalAnd(ctx, req, root.Children[0], root.Children[1])
case "or":
return g.applyFilterLogicalOr(ctx, req, root.Children[0], root.Children[1])
case "eq":
return g.applyFilterEq(ctx, req, root.Children[0], root.Children[1])
case "le":
return g.applyFilterLessOrEqual(ctx, req, root)
}
logger.Debug().Str("Token", root.Token.Value).Msg("unsupported logical filter")
return users, unsupportedFilterError()
}
func (g Graph) applyFilterLogicalAnd(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
var res1, res2 []*libregraph.User
// The appRoleAssignmentFilter requires a full list of user, get try to avoid a full user listing and
// process the other part of the filter first, if that is an appRoleAssignmentFilter as well, we have
// to bite the bullet and get a full user listing anyway
if matched, property, value := g.isAppRoleAssignmentFilter(ctx, operand1); matched {
logger.Debug().Str("property", property).Str("value", value).Msg("delay processing of approleAssignments filter")
if property != appRoleID {
return users, unsupportedFilterError()
}
res2, err = g.applyUserFilter(ctx, req, operand2)
if err != nil {
return []*libregraph.User{}, err
}
logger.Debug().Str("property", property).Str("value", value).Msg("applying approleAssignments filter on result set")
return g.filterUsersByAppRoleID(ctx, req, value, res2)
}
// 1st part is no appRoleAssignmentFilter, run the filter query
res1, err = g.applyUserFilter(ctx, req, operand1)
if err != nil {
return []*libregraph.User{}, err
}
// Now check 2nd part for appRoleAssignmentFilter and apply that using the result return from the first
// filter
if matched, property, value := g.isAppRoleAssignmentFilter(ctx, operand2); matched {
if property != appRoleID {
return users, unsupportedFilterError()
}
logger.Debug().Str("property", property).Str("value", value).Msg("applying approleAssignments filter on result set")
return g.filterUsersByAppRoleID(ctx, req, value, res1)
}
// 2nd part is no appRoleAssignmentFilter either
res2, err = g.applyUserFilter(ctx, req, operand2)
if err != nil {
return []*libregraph.User{}, err
}
// We now have two slice with results of the subfilters. Now turn one of them
// into a map for efficiently getting the intersection of both slices
userSet := userSliceToMap(res1)
var filteredUsers []*libregraph.User
for _, user := range res2 {
if _, found := userSet[user.GetId()]; found {
filteredUsers = append(filteredUsers, user)
}
}
return filteredUsers, nil
}
func (g Graph) applyFilterLogicalOr(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
// logger := g.logger.SubloggerWithRequestID(ctx)
var res1, res2 []*libregraph.User
res1, err = g.applyUserFilter(ctx, req, operand1)
if err != nil {
return []*libregraph.User{}, err
}
res2, err = g.applyUserFilter(ctx, req, operand2)
if err != nil {
return []*libregraph.User{}, err
}
// We now have two slices with results of the subfilters. Now turn one of them
// into a map for efficiently getting the union of both slices
userSet := userSliceToMap(res1)
filteredUsers := make([]*libregraph.User, 0, len(res1)+len(res2))
filteredUsers = append(filteredUsers, res1...)
for _, user := range res2 {
if _, found := userSet[user.GetId()]; !found {
filteredUsers = append(filteredUsers, user)
}
}
return filteredUsers, nil
}
func (g Graph) applyFilterEq(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
// We only support the 'eq' on 'userType' for now
switch {
case operand1.Token.Type != godata.ExpressionTokenLiteral:
fallthrough
case operand1.Token.Value != "userType":
fallthrough
case operand2.Token.Type != godata.ExpressionTokenString:
return users, unsupportedFilterError()
}
// unquote
value := strings.Trim(operand2.Token.Value, "'")
switch value {
case "Member", "Guest":
return g.identityBackend.GetUsers(ctx, req)
case "Federated":
return g.searchOCMAcceptedUsers(ctx, req)
}
return users, unsupportedFilterError()
}
func (g Graph) applyFilterLessOrEqual(ctx context.Context, req *godata.GoDataRequest, filterRoot *godata.ParseNode) (users []*libregraph.User, err error) {
// Assume that our identity backend is able to apply this filter
return g.identityBackend.FilterUsers(ctx, req, filterRoot)
}
func (g Graph) applyFilterLambda(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if len(nodes) != 2 {
return users, invalidFilterError()
}
// We only support the "any" operator for lambda queries for now
if nodes[1].Token.Type != godata.ExpressionTokenLambda || nodes[1].Token.Value != "any" {
logger.Debug().Str("Token", nodes[1].Token.Value).Msg("unsupported lambda filter")
return users, unsupportedFilterError()
}
if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
return users, unsupportedFilterError()
}
switch nodes[0].Token.Value {
case "memberOf":
return g.applyLambdaMemberOfAny(ctx, req, nodes[1].Children)
case appRoleAssignments:
return g.applyLambdaAppRoleAssignmentAny(ctx, req, nodes[1].Children)
}
logger.Debug().Str("Token", nodes[0].Token.Value).Msg("unsupported relation for lambda filter")
return users, unsupportedFilterError()
}
func (g Graph) applyLambdaMemberOfAny(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
if len(nodes) != 2 {
return users, invalidFilterError()
}
// First element is the "name" of the lambda function's parameter
if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
return users, invalidFilterError()
}
// We only support the 'eq' expression for now
if nodes[1].Token.Type != godata.ExpressionTokenLogical || nodes[1].Token.Value != "eq" {
return users, unsupportedFilterError()
}
return g.applyMemberOfEq(ctx, req, nodes[1].Children)
}
func (g Graph) applyMemberOfEq(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if len(nodes) != 2 {
return users, invalidFilterError()
}
if nodes[0].Token.Type != godata.ExpressionTokenNav {
return users, invalidFilterError()
}
if len(nodes[0].Children) != 2 {
return users, invalidFilterError()
}
switch nodes[0].Children[1].Token.Value {
case "id":
filterValue, err := g.getUUIDTokenValue(ctx, nodes[1])
if err != nil {
return users, unsupportedFilterError()
}
logger.Debug().Str("property", nodes[0].Children[1].Token.Value).Str("value", filterValue).Msg("Filtering memberOf by group id")
return g.identityBackend.GetGroupMembers(ctx, filterValue, req)
default:
return users, unsupportedFilterError()
}
}
func (g Graph) applyLambdaAppRoleAssignmentAny(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
if len(nodes) != 2 {
return users, invalidFilterError()
}
// First element is the "name" of the lambda function's parameter
if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
return users, invalidFilterError()
}
// We only support the 'eq' expression for now
if nodes[1].Token.Type != godata.ExpressionTokenLogical || nodes[1].Token.Value != "eq" {
return users, unsupportedFilterError()
}
return g.applyAppRoleAssignmentEq(ctx, req, nodes[1].Children)
}
func (g Graph) applyAppRoleAssignmentEq(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if len(nodes) != 2 {
return users, invalidFilterError()
}
if nodes[0].Token.Type != godata.ExpressionTokenNav {
return users, invalidFilterError()
}
if len(nodes[0].Children) != 2 {
return users, invalidFilterError()
}
if nodes[0].Children[1].Token.Value == appRoleID {
filterValue, err := g.getUUIDTokenValue(ctx, nodes[1])
if err != nil {
return users, unsupportedFilterError()
}
logger.Debug().Str("property", nodes[0].Children[1].Token.Value).Str("value", filterValue).Msg("Filtering appRoleAssignments by appRoleId")
if users, err = g.identityBackend.GetUsers(ctx, req); err != nil {
return users, err
}
return g.filterUsersByAppRoleID(ctx, req, filterValue, users)
}
return users, unsupportedFilterError()
}
func (g Graph) filterUsersByAppRoleID(ctx context.Context, req *godata.GoDataRequest, id string, users []*libregraph.User) ([]*libregraph.User, error) {
// We're using a map for the results here, in order to avoid returning
// a user twice. The settings API, still has an issue that causes it to
// duplicate some assignments on restart:
// https://github.com/owncloud/ocis/issues/3432
usersByIdMap := make(map[string]*libregraph.User, len(users))
for _, user := range users {
usersByIdMap[user.GetId()] = user
}
var expand bool
if exp := req.Query.GetExpand(); exp != nil {
for _, item := range exp.ExpandItems {
if item.Path[0].Value == appRoleAssignments {
expand = true
break
}
}
}
assignmentsForRole, err := g.roleService.ListRoleAssignmentsFiltered(
ctx,
&settingssvc.ListRoleAssignmentsFilteredRequest{
Filters: []*settingsmsg.UserRoleAssignmentFilter{
{
Type: settingsmsg.UserRoleAssignmentFilter_TYPE_ROLE,
Term: &settingsmsg.UserRoleAssignmentFilter_RoleId{RoleId: id},
},
},
},
)
if err != nil {
return users, err
}
resultUsers := make([]*libregraph.User, 0, len(assignmentsForRole.GetAssignments()))
for _, assignment := range assignmentsForRole.GetAssignments() {
if user, ok := usersByIdMap[assignment.GetAccountUuid()]; ok {
if expand {
user.AppRoleAssignments = []libregraph.AppRoleAssignment{g.assignmentToAppRoleAssignment(assignment)}
}
resultUsers = append(resultUsers, user)
}
}
return resultUsers, nil
}
func (g Graph) getUUIDTokenValue(ctx context.Context, node *godata.ParseNode) (string, error) {
var value string
switch node.Token.Type {
case godata.ExpressionTokenGuid:
value = node.Token.Value
case godata.ExpressionTokenString:
// unquote
value = strings.Trim(node.Token.Value, "'")
default:
return "", unsupportedFilterError()
}
return value, nil
}
func (g Graph) isAppRoleAssignmentFilter(ctx context.Context, node *godata.ParseNode) (match bool, property string, filter string) {
if node.Token.Type != godata.ExpressionTokenLambdaNav {
return false, "", ""
}
if len(node.Children) != 2 {
return false, "", ""
}
if node.Children[0].Token.Type != godata.ExpressionTokenLiteral || node.Children[0].Token.Value != appRoleAssignments {
return false, "", ""
}
if node.Children[1].Token.Type != godata.ExpressionTokenLambda || node.Children[1].Token.Value != "any" {
return false, "", ""
}
if len(node.Children[1].Children) != 2 {
return false, "", ""
}
lambdaParam := node.Children[1].Children
// We only support the 'eq' expression for now
if lambdaParam[1].Token.Type != godata.ExpressionTokenLogical || lambdaParam[1].Token.Value != "eq" {
return false, "", ""
}
if len(lambdaParam[1].Children) != 2 {
return false, "", ""
}
expression := lambdaParam[1].Children
if expression[0].Token.Type != godata.ExpressionTokenNav || expression[0].Token.Value != "/" {
return false, "", ""
}
if len(expression[0].Children) != 2 {
return false, "", ""
}
if expression[0].Children[1].Token.Value != appRoleID {
return false, "", ""
}
filterValue, err := g.getUUIDTokenValue(ctx, expression[1])
if err != nil {
return false, "", ""
}
return true, appRoleID, filterValue
}
func userSliceToMap(users []*libregraph.User) map[string]*libregraph.User {
resMap := make(map[string]*libregraph.User, len(users))
for _, user := range users {
resMap[user.GetId()] = user
}
return resMap
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,837 @@
package svc
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3User "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"golang.org/x/sync/errgroup"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity/cache"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
// StrictJSONUnmarshal is a wrapper around json.Unmarshal that returns an error if the json contains unknown fields.
func StrictJSONUnmarshal(r io.Reader, v any) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
return dec.Decode(v)
}
// IsSpaceRoot returns true if the resourceID is a space root.
func IsSpaceRoot(rid *storageprovider.ResourceId) bool {
if rid == nil {
return false
}
if rid.GetSpaceId() == "" || rid.GetOpaqueId() == "" {
return false
}
return rid.GetSpaceId() == rid.GetOpaqueId()
}
// GetDriveAndItemIDParam parses the driveID and itemID from the request,
// validates the common fields and returns the parsed IDs if ok.
func GetDriveAndItemIDParam(r *http.Request, logger *log.Logger) (*storageprovider.ResourceId, *storageprovider.ResourceId, error) {
empty := &storageprovider.ResourceId{}
driveID, err := parseIDParam(r, "driveID")
if err != nil {
logger.Debug().Err(err).Msg("could not parse driveID")
return empty, empty, errorcode.New(errorcode.InvalidRequest, "invalid driveID")
}
itemID, err := parseIDParam(r, "itemID")
if err != nil {
logger.Debug().Err(err).Msg("could not parse itemID")
return empty, empty, errorcode.New(errorcode.InvalidRequest, "invalid itemID")
}
if itemID.GetOpaqueId() == "" {
logger.Debug().Interface("driveID", &driveID).Interface("itemID", &itemID).Msg("empty item opaqueID")
return empty, empty, errorcode.New(errorcode.InvalidRequest, "invalid itemID")
}
if driveID.GetStorageId() != itemID.GetStorageId() || driveID.GetSpaceId() != itemID.GetSpaceId() {
logger.Debug().Interface("driveID", &driveID).Interface("itemID", &itemID).Msg("driveID and itemID do not match")
return empty, empty, errorcode.New(errorcode.ItemNotFound, "driveID and itemID do not match")
}
return &driveID, &itemID, nil
}
// GetGatewayClient returns a gateway client from the gatewaySelector.
func (g Graph) GetGatewayClient(w http.ResponseWriter, r *http.Request) (gateway.GatewayAPIClient, bool) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Debug().Err(err).Msg("selecting gatewaySelector failed")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return nil, false
}
return gatewayClient, true
}
// IsShareJail returns true if given id is a share jail id.
func IsShareJail(id *storageprovider.ResourceId) bool {
return id.GetStorageId() == utils.ShareStorageProviderID && id.GetSpaceId() == utils.ShareStorageSpaceID
}
// userIdToIdentity looks the user for the supplied id using the cache and returns it
// as a libregraph.Identity
func userIdToIdentity(ctx context.Context, cache cache.IdentityCache, tennantId, userID string) (libregraph.Identity, error) {
identity := libregraph.Identity{
Id: libregraph.PtrString(userID),
}
user, err := cache.GetUser(ctx, tennantId, userID)
if err == nil {
identity.SetDisplayName(user.GetDisplayName())
identity.SetLibreGraphUserType(user.GetUserType())
}
return identity, err
}
// federatedIdToIdentity looks the user for the supplied id using the cache and returns it
// as a libregraph.Identity
func federatedIdToIdentity(ctx context.Context, cache cache.IdentityCache, cs3UserID *cs3User.UserId) (libregraph.Identity, error) {
userID := fmt.Sprintf("%s@%s", cs3UserID.GetOpaqueId(), cs3UserID.GetIdp())
identity := libregraph.Identity{
Id: libregraph.PtrString(userID),
LibreGraphUserType: libregraph.PtrString("Federated"),
}
user, err := cache.GetAcceptedUser(ctx, userID)
if err == nil {
identity.SetDisplayName(user.GetDisplayName())
identity.SetLibreGraphUserType(user.GetUserType())
}
return identity, err
}
// cs3UserIdToIdentity looks up the user for the supplied cs3 userid using the cache and returns it
// as a libregraph.Identity. Skips the user lookup if the id type is USER_TYPE_SPACE_OWNER
func cs3UserIdToIdentity(ctx context.Context, cache cache.IdentityCache, cs3UserID *cs3User.UserId) (libregraph.Identity, error) {
if cs3UserID.GetType() == cs3User.UserType_USER_TYPE_FEDERATED {
return federatedIdToIdentity(ctx, cache, cs3UserID)
}
if cs3UserID.GetType() != cs3User.UserType_USER_TYPE_SPACE_OWNER {
return userIdToIdentity(ctx, cache, cs3UserID.GetTenantId(), cs3UserID.GetOpaqueId())
}
return libregraph.Identity{Id: libregraph.PtrString(cs3UserID.GetOpaqueId())}, nil
}
// groupIdToIdentity looks up the group for the supplied cs3 groupid using the cache and returns it
// as a libregraph.Identity.
func groupIdToIdentity(ctx context.Context, cache cache.IdentityCache, groupID string) (libregraph.Identity, error) {
identity := libregraph.Identity{
Id: libregraph.PtrString(groupID),
}
group, err := cache.GetGroup(ctx, groupID)
if err == nil {
identity.SetDisplayName(group.GetDisplayName())
}
return identity, err
}
// identitySetToSpacePermissionID generates an Id for a permission from an identitySet. In libregraph
// permissions need to have an id. For user share permission we just use the cs3 share id as the permission-id
// As permissions on space to not map to a cs3 share we need something else of the ids. So we just
// construct the id for the id of the user or group that the permission applies to and prefix that
// with a "u:" for userids and "g:" for group ids.
func identitySetToSpacePermissionID(identitySet libregraph.SharePointIdentitySet) (id string) {
switch {
case identitySet.HasUser():
id = "u:" + identitySet.User.GetId()
case identitySet.HasGroup():
id = "g:" + identitySet.Group.GetId()
}
return id
}
func cs3ReceivedSharesToDriveItems(ctx context.Context,
logger *log.Logger,
gatewayClient gateway.GatewayAPIClient,
identityCache cache.IdentityCache,
receivedShares []*collaboration.ReceivedShare,
availableRoles []*libregraph.UnifiedRoleDefinition,
) ([]libregraph.DriveItem, error) {
group := new(errgroup.Group)
// Set max concurrency
group.SetLimit(10)
receivedSharesByResourceID := make(map[string][]*collaboration.ReceivedShare, len(receivedShares))
for _, receivedShare := range receivedShares {
if receivedShare == nil {
continue
}
rIDStr := storagespace.FormatResourceID(receivedShare.GetShare().GetResourceId())
receivedSharesByResourceID[rIDStr] = append(receivedSharesByResourceID[rIDStr], receivedShare)
}
ch := make(chan libregraph.DriveItem, len(receivedSharesByResourceID))
for _, receivedSharesForResource := range receivedSharesByResourceID {
receivedShares := receivedSharesForResource
group.Go(func() error {
var err error // redeclare
shareStat, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
Ref: &storageprovider.Reference{
ResourceId: receivedShares[0].GetShare().GetResourceId(),
},
})
if err := errorcode.FromCS3Status(shareStat.GetStatus(), err); err != nil {
logger.Debug().Err(err).
Str("shareid", receivedShares[0].GetShare().GetId().GetOpaqueId()).
Str("resourceid", storagespace.FormatResourceID(receivedShares[0].GetShare().GetResourceId())).
Msg("could not stat received share, skipping")
return nil
}
driveItem, err := fillDriveItemPropertiesFromReceivedShare(ctx, logger, identityCache, receivedShares, shareStat.GetInfo(), availableRoles)
if err != nil {
logger.Debug().Err(err).
Str("shareid", receivedShares[0].GetShare().GetId().GetOpaqueId()).
Str("resourceid", storagespace.FormatResourceID(receivedShares[0].GetShare().GetResourceId())).
Msg("could not fill drive item properties from received share, skipping")
return nil
}
if !driveItem.HasUIHidden() {
driveItem.SetUIHidden(false)
}
if !driveItem.HasClientSynchronize() {
driveItem.SetClientSynchronize(false)
if name := shareStat.GetInfo().GetName(); name != "" {
driveItem.SetName(name)
}
}
remoteItem := driveItem.RemoteItem
{
if id := shareStat.GetInfo().GetId(); id != nil {
remoteItem.SetId(storagespace.FormatResourceID(id))
}
if name := shareStat.GetInfo().GetName(); name != "" {
remoteItem.SetName(name)
}
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
remoteItem.SetETag(etag)
}
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
remoteItem.SetLastModifiedDateTime(cs3TimestampToTime(mTime))
}
if size := shareStat.GetInfo().GetSize(); size != 0 {
remoteItem.SetSize(int64(size))
}
parentReference := libregraph.NewItemReference()
if spaceType := shareStat.GetInfo().GetSpace().GetSpaceType(); spaceType != "" {
parentReference.SetDriveType(spaceType)
}
if root := shareStat.GetInfo().GetSpace().GetRoot(); root != nil {
parentReference.SetDriveId(storagespace.FormatResourceID(root))
}
if !reflect.ValueOf(*parentReference).IsZero() {
remoteItem.ParentReference = parentReference
}
}
// the parentReference of the outer driveItem should be the drive
// containing the mountpoint i.e. the share jail
driveItem.ParentReference = libregraph.NewItemReference()
driveItem.ParentReference.SetDriveType(_spaceTypeVirtual)
driveItem.ParentReference.SetDriveId(storagespace.FormatStorageID(utils.ShareStorageProviderID, utils.ShareStorageSpaceID))
driveItem.ParentReference.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
StorageId: utils.ShareStorageProviderID,
OpaqueId: utils.ShareStorageSpaceID,
SpaceId: utils.ShareStorageSpaceID,
}))
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
driveItem.SetETag(etag)
}
// connect the dots
{
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
t := cs3TimestampToTime(mTime)
driveItem.SetLastModifiedDateTime(t)
remoteItem.SetLastModifiedDateTime(t)
}
if size := shareStat.GetInfo().GetSize(); size != 0 {
s := int64(size)
driveItem.SetSize(s)
remoteItem.SetSize(s)
}
if userID := shareStat.GetInfo().GetOwner(); userID != nil && userID.Type != cs3User.UserType_USER_TYPE_SPACE_OWNER {
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
if err != nil {
// TODO: define a proper error behavior here. We don't
// want the whole request to fail just because a single
// resource owner couldn't be resolved. But, should we
// really return the affected share in the response?
// For now we just log a warning. The returned
// identitySet will just contain the userid.
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get owner of shared resource")
}
remoteItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
driveItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
}
switch info := shareStat.GetInfo(); {
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
folder := libregraph.NewFolder()
remoteItem.Folder = folder
driveItem.Folder = folder
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE:
file := libregraph.NewOpenGraphFile()
if mimeType := info.GetMimeType(); mimeType != "" {
file.MimeType = &mimeType
}
remoteItem.File = file
driveItem.File = file
}
}
ch <- *driveItem
return nil
})
}
var err error
// wait for concurrent requests to finish
go func() {
err = group.Wait()
close(ch)
}()
driveItems := make([]libregraph.DriveItem, 0, len(receivedSharesByResourceID))
for di := range ch {
driveItems = append(driveItems, di)
}
return driveItems, err
}
func fillDriveItemPropertiesFromReceivedShare(ctx context.Context, logger *log.Logger,
identityCache cache.IdentityCache, receivedShares []*collaboration.ReceivedShare,
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.DriveItem, error) {
driveItem := libregraph.NewDriveItem()
permissions := make([]libregraph.Permission, 0, len(receivedShares))
var oldestReceivedShare *collaboration.ReceivedShare
for _, receivedShare := range receivedShares {
switch {
case oldestReceivedShare == nil:
fallthrough
case utils.TSToTime(receivedShare.GetShare().GetCtime()).Before(utils.TSToTime(oldestReceivedShare.GetShare().GetCtime())):
oldestReceivedShare = receivedShare
}
permission, err := cs3ReceivedShareToLibreGraphPermissions(ctx, logger, identityCache, receivedShare, resourceInfo, availableRoles)
if err != nil {
return driveItem, err
}
// If at least one of the shares was accepted, we consider the driveItem's synchronized
// flag enabled.
// Also we use the Mountpoint name of the first accepted mountpoint as the name for
// the driveItem
if receivedShare.GetState() == collaboration.ShareState_SHARE_STATE_ACCEPTED {
driveItem.SetClientSynchronize(true)
if name := receivedShare.GetMountPoint().GetPath(); name != "" && driveItem.GetName() == "" {
driveItem.SetName(receivedShare.GetMountPoint().GetPath())
}
}
// if at least one share is marked as hidden, consider the whole driveItem to be hidden
if receivedShare.GetHidden() {
driveItem.SetUIHidden(true)
}
if userID := receivedShare.GetShare().GetCreator(); userID != nil {
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
if err != nil {
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get creator of the share")
}
permission.SetInvitation(
libregraph.SharingInvitation{
InvitedBy: &libregraph.IdentitySet{
User: &identity,
},
},
)
}
permissions = append(permissions, *permission)
// To stay compatible with the usershareprovider and the webdav
// service the id of the driveItem is composed of the StorageID and
// SpaceID of the sharestorage appended with the opaque ID of
// the oldest share for the resource:
// '<sharestorageid>$<sharespaceid>!<share-opaque-id>
// Note: This means that the driveitem ID will change when the oldest
// share is removed. It would be good to have are more stable ID here (e.g.
// derived from the shared resource's ID. But as we need to use the same
// ID across all services this means we needed to make similar adjustments
// to the sharejail (usershareprovider, webdav). Which we can't currently do
// as some clients rely on the IDs used there having a special format.
driveItem.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
StorageId: utils.ShareStorageProviderID,
OpaqueId: oldestReceivedShare.GetShare().GetId().GetOpaqueId(),
SpaceId: utils.ShareStorageSpaceID,
}))
}
driveItem.RemoteItem = libregraph.NewRemoteItem()
driveItem.RemoteItem.Permissions = permissions
return driveItem, nil
}
func cs3ReceivedShareToLibreGraphPermissions(ctx context.Context, logger *log.Logger,
identityCache cache.IdentityCache, receivedShare *collaboration.ReceivedShare,
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.Permission, error) {
permission := libregraph.NewPermission()
if id := receivedShare.GetShare().GetId().GetOpaqueId(); id != "" {
permission.SetId(id)
}
if expiration := receivedShare.GetShare().GetExpiration(); expiration != nil {
permission.SetExpirationDateTime(cs3TimestampToTime(expiration))
}
if cTime := receivedShare.GetShare().GetCtime(); cTime != nil {
permission.SetCreatedDateTime(cs3TimestampToTime(cTime))
}
if permissionSet := receivedShare.GetShare().GetPermissions().GetPermissions(); permissionSet != nil {
condition, err := roleConditionForResourceType(resourceInfo)
if err != nil {
return nil, err
}
role := unifiedrole.CS3ResourcePermissionsToRole(
availableRoles,
permissionSet,
condition,
false,
)
if role != nil {
permission.SetRoles([]string{role.GetId()})
}
actions := unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissionSet)
// actions only make sense if no role is set
if role == nil && len(actions) > 0 {
permission.SetLibreGraphPermissionsActions(actions)
}
}
switch grantee := receivedShare.GetShare().GetGrantee(); {
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_USER:
user, err := cs3UserIdToIdentity(ctx, identityCache, grantee.GetUserId())
if err != nil {
logger.Error().Err(err).Msg("could not get user")
return nil, err
}
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{User: &user})
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_GROUP:
group, err := groupIdToIdentity(ctx, identityCache, grantee.GetGroupId().GetOpaqueId())
if err != nil {
logger.Error().Err(err).Msg("could not get group")
return nil, err
}
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{Group: &group})
}
return permission, nil
}
func roleConditionForResourceType(ri *storageprovider.ResourceInfo) (string, error) {
switch {
case utils.ResourceIDEqual(ri.GetSpace().GetRoot(), ri.GetId()):
return unifiedrole.UnifiedRoleConditionDrive, nil
case ri.Type == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
return unifiedrole.UnifiedRoleConditionFolder, nil
case ri.Type == storageprovider.ResourceType_RESOURCE_TYPE_FILE:
return unifiedrole.UnifiedRoleConditionFile, nil
default:
return "", errorcode.New(errorcode.InvalidRequest, "unsupported resource type")
}
}
func federatedRoleConditionForResourceType(ri *storageprovider.ResourceInfo) (string, error) {
switch ri.GetType() {
case storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
return unifiedrole.UnifiedRoleConditionFolderFederatedUser, nil
case storageprovider.ResourceType_RESOURCE_TYPE_FILE:
return unifiedrole.UnifiedRoleConditionFileFederatedUser, nil
default:
return "", errorcode.New(errorcode.InvalidRequest, "unsupported resource type for federated role")
}
}
// ExtractShareIdFromResourceId is a bit of a hack.
// We should not rely on a specific format of the item id.
// But currently there is no other way to get the ShareID.
func ExtractShareIdFromResourceId(rid *storageprovider.ResourceId) *collaboration.ShareId {
return &collaboration.ShareId{
OpaqueId: rid.GetOpaqueId(),
}
}
func cs3ReceivedOCMSharesToDriveItems(ctx context.Context,
logger *log.Logger,
gatewayClient gateway.GatewayAPIClient,
identityCache cache.IdentityCache,
receivedShares []*ocm.ReceivedShare, availableRoles []*libregraph.UnifiedRoleDefinition) ([]libregraph.DriveItem, error) {
group := new(errgroup.Group)
// Set max concurrency
group.SetLimit(10)
receivedSharesByResourceID := make(map[string][]*ocm.ReceivedShare, len(receivedShares))
for _, receivedShare := range receivedShares {
rIDStr := receivedShare.GetRemoteShareId()
receivedSharesByResourceID[rIDStr] = append(receivedSharesByResourceID[rIDStr], receivedShare)
}
ch := make(chan libregraph.DriveItem, len(receivedSharesByResourceID))
for _, receivedSharesForResource := range receivedSharesByResourceID {
receivedShares := receivedSharesForResource
group.Go(func() error {
var err error // redeclare
// for OCM shares the opaqueID is the '/' for shared directories and '/filename' for
// file shares
resOpaqueID := "/"
if receivedShares[0].GetResourceType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE {
resOpaqueID += receivedShares[0].GetName()
}
shareStat, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
Ref: &storageprovider.Reference{
ResourceId: &storageprovider.ResourceId{
// TODO maybe the reference is wrong
StorageId: utils.OCMStorageProviderID,
SpaceId: receivedShares[0].GetId().GetOpaqueId(),
OpaqueId: base64.StdEncoding.EncodeToString([]byte(resOpaqueID)),
},
},
})
if err := errorcode.FromCS3Status(shareStat.GetStatus(), err); err != nil {
logger.Debug().Err(err).
Str("shareid", receivedShares[0].GetId().GetOpaqueId()).
Str("remoteshareid", receivedShares[0].GetRemoteShareId()).
Msg("could not stat received ocm share, skipping")
return nil
}
driveItem, err := fillDriveItemPropertiesFromReceivedOCMShare(ctx, logger, identityCache, receivedShares, shareStat.GetInfo(), availableRoles)
if err != nil {
logger.Debug().Err(err).
Str("shareid", receivedShares[0].GetId().GetOpaqueId()).
Str("remoteshareid", receivedShares[0].GetRemoteShareId()).
Msg("could not fill drive item properties from received ocm share, skipping")
return nil
}
if !driveItem.HasUIHidden() {
driveItem.SetUIHidden(false)
}
if !driveItem.HasClientSynchronize() {
driveItem.SetClientSynchronize(false)
if name := shareStat.GetInfo().GetName(); name != "" {
driveItem.SetName(name) // FIXME name is not set???
}
}
remoteItem := driveItem.RemoteItem
{
if id := shareStat.GetInfo().GetId(); id != nil {
remoteItem.SetId(storagespace.FormatResourceID(id))
}
if name := shareStat.GetInfo().GetName(); name != "" {
remoteItem.SetName(name)
}
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
remoteItem.SetETag(etag)
}
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
remoteItem.SetLastModifiedDateTime(cs3TimestampToTime(mTime))
}
if size := shareStat.GetInfo().GetSize(); size != 0 {
remoteItem.SetSize(int64(size))
}
parentReference := libregraph.NewItemReference()
if spaceType := shareStat.GetInfo().GetSpace().GetSpaceType(); spaceType != "" {
parentReference.SetDriveType(spaceType)
}
if root := shareStat.GetInfo().GetSpace().GetRoot(); root != nil {
parentReference.SetDriveId(storagespace.FormatResourceID(root))
}
if !reflect.ValueOf(*parentReference).IsZero() {
remoteItem.ParentReference = parentReference
}
}
// the parentReference of the outer driveItem should be the drive
// containing the mountpoint i.e. the share jail
driveItem.ParentReference = libregraph.NewItemReference()
driveItem.ParentReference.SetDriveType("virtual")
driveItem.ParentReference.SetDriveId(storagespace.FormatStorageID(utils.ShareStorageProviderID, utils.ShareStorageSpaceID))
driveItem.ParentReference.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
StorageId: utils.ShareStorageProviderID,
OpaqueId: utils.ShareStorageSpaceID,
SpaceId: utils.ShareStorageSpaceID,
}))
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
driveItem.SetETag(etag)
}
// connect the dots
{
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
t := cs3TimestampToTime(mTime)
driveItem.SetLastModifiedDateTime(t)
remoteItem.SetLastModifiedDateTime(t)
}
if size := shareStat.GetInfo().GetSize(); size != 0 {
s := int64(size)
driveItem.SetSize(s)
remoteItem.SetSize(s)
}
if userID := shareStat.GetInfo().GetOwner(); userID != nil && userID.Type != cs3User.UserType_USER_TYPE_SPACE_OWNER {
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
if err != nil {
// TODO: define a proper error behavior here. We don't
// want the whole request to fail just because a single
// resource owner couldn't be resolved. But, should we
// really return the affected share in the response?
// For now we just log a warning. The returned
// identitySet will just contain the userid.
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get owner of shared resource")
}
remoteItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
driveItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
}
switch info := shareStat.GetInfo(); {
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
folder := libregraph.NewFolder()
remoteItem.Folder = folder
driveItem.Folder = folder
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE:
file := libregraph.NewOpenGraphFile()
if mimeType := info.GetMimeType(); mimeType != "" {
file.MimeType = &mimeType
}
remoteItem.File = file
driveItem.File = file
}
}
ch <- *driveItem
return nil
})
}
var err error
// wait for concurrent requests to finish
go func() {
err = group.Wait()
close(ch)
}()
driveItems := make([]libregraph.DriveItem, 0, len(receivedSharesByResourceID))
for di := range ch {
driveItems = append(driveItems, di)
}
return driveItems, err
}
func fillDriveItemPropertiesFromReceivedOCMShare(ctx context.Context, logger *log.Logger,
identityCache cache.IdentityCache, receivedShares []*ocm.ReceivedShare,
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.DriveItem, error) {
driveItem := libregraph.NewDriveItem()
permissions := make([]libregraph.Permission, 0, len(receivedShares))
var oldestReceivedShare *ocm.ReceivedShare
for _, receivedShare := range receivedShares {
switch {
case oldestReceivedShare == nil:
fallthrough
case utils.TSToTime(receivedShare.GetCtime()).Before(utils.TSToTime(oldestReceivedShare.GetCtime())):
oldestReceivedShare = receivedShare
}
permission, err := cs3ReceivedOCMShareToLibreGraphPermissions(ctx, logger, identityCache, receivedShare, resourceInfo, availableRoles)
if err != nil {
return driveItem, err
}
driveItem.SetName(resourceInfo.GetName())
// If at least one of the shares was accepted, we consider the driveItem's synchronized
// flag enabled.
// Also we use the Mountpoint name of the first accepted mountpoint as the name for
// the driveItem
if receivedShare.GetState() == ocm.ShareState_SHARE_STATE_ACCEPTED {
driveItem.SetClientSynchronize(true)
if name := receivedShare.GetName(); name != "" && driveItem.GetName() == "" {
driveItem.SetName(receivedShare.GetName())
}
}
// if at least one share is marked as hidden, consider the whole driveItem to be hidden
/*
if receivedShare.GetHidden() {
driveItem.SetUIHidden(true)
}
*/
if userID := receivedShare.GetCreator(); userID != nil {
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
if err != nil {
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get creator of the ocm share")
}
permission.SetInvitation(
libregraph.SharingInvitation{
InvitedBy: &libregraph.IdentitySet{
User: &identity,
},
},
)
}
permissions = append(permissions, *permission)
// To stay compatible with the usershareprovider and the webdav
// service the id of the driveItem is composed of the StorageID and
// SpaceID of the sharestorage appended with the opaque ID of
// the oldest share for the resource:
// '<sharestorageid>$<sharespaceid>!<share-opaque-id>
// Note: This means that the driveitem ID will change when the oldest
// share is removed. It would be good to have are more stable ID here (e.g.
// derived from the shared resource's ID. But as we need to use the same
// ID across all services this means we needed to make similar adjustments
// to the sharejail (usershareprovider, webdav). Which we can't currently do
// as some clients rely on the IDs used there having a special format.
driveItem.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
StorageId: utils.OCMStorageProviderID,
SpaceId: utils.OCMStorageSpaceID,
OpaqueId: oldestReceivedShare.GetRemoteShareId(),
}))
}
driveItem.RemoteItem = libregraph.NewRemoteItem()
driveItem.RemoteItem.Permissions = permissions
return driveItem, nil
}
func cs3ReceivedOCMShareToLibreGraphPermissions(ctx context.Context, logger *log.Logger,
identityCache cache.IdentityCache, receivedShare *ocm.ReceivedShare,
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.Permission, error) {
permission := libregraph.NewPermission()
if id := receivedShare.GetId().GetOpaqueId(); id != "" {
permission.SetId(id)
}
if cTime := receivedShare.GetCtime(); cTime != nil {
permission.SetCreatedDateTime(cs3TimestampToTime(cTime))
}
if expiration := receivedShare.GetExpiration(); expiration != nil {
permission.SetExpirationDateTime(cs3TimestampToTime(expiration))
}
var permissions *storageprovider.ResourcePermissions
for _, protocol := range receivedShare.GetProtocols() {
if protocol.GetWebdavOptions().GetPermissions() != nil {
permissions = protocol.GetWebdavOptions().GetPermissions().GetPermissions()
}
}
condition, err := federatedRoleConditionForResourceType(resourceInfo)
if err != nil {
return nil, err
}
role := unifiedrole.CS3ResourcePermissionsToRole(
availableRoles,
permissions,
condition,
true,
)
if role != nil {
permission.SetRoles([]string{role.GetId()})
} else {
actions := unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissions)
permission.SetLibreGraphPermissionsActions(actions)
permission.SetRoles(nil)
}
switch grantee := receivedShare.GetGrantee(); {
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_USER:
user, err := cs3UserIdToIdentity(ctx, identityCache, grantee.GetUserId())
if err != nil {
logger.Error().Err(err).Msg("could not get user")
return nil, err
}
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{User: &user})
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_GROUP:
group, err := groupIdToIdentity(ctx, identityCache, grantee.GetGroupId().GetOpaqueId())
if err != nil {
logger.Error().Err(err).Msg("could not get group")
return nil, err
}
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{Group: &group})
}
return permission, nil
}
@@ -0,0 +1,153 @@
package svc_test
import (
"context"
"net/http"
"net/http/httptest"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
rConversions "github.com/opencloud-eu/reva/v2/pkg/conversions"
"github.com/opencloud-eu/reva/v2/pkg/utils"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"google.golang.org/protobuf/testing/protocmp"
"github.com/qsfera/server/pkg/conversions"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/identity/cache"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
var _ = Describe("Utils", func() {
DescribeTable("GetDriveAndItemIDParam",
func(driveID, itemID string, shouldPass bool) {
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", driveID)
rctx.URLParams.Add("itemID", itemID)
extractedDriveID, extractedItemID, err := service.GetDriveAndItemIDParam(
httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rctx),
),
conversions.ToPointer(log.NopLogger()),
)
switch shouldPass {
case true:
Expect(err).To(BeNil())
parsedItemID, _ := storagespace.ParseID(itemID)
Expect(extractedItemID).To(BeComparableTo(&parsedItemID, protocmp.Transform()))
parsedDriveID, _ := storagespace.ParseID(driveID)
Expect(extractedDriveID).To(BeComparableTo(&parsedDriveID, protocmp.Transform()))
default:
Expect(err).ToNot(BeNil())
}
},
Entry("fails: invalid driveID", "", "1$2!3", false),
Entry("fails: invalid itemID", "1$2", "", false),
Entry("fails: incompatible driveID and itemID", "1$2", "3$4!5", false),
Entry("fails: no itemID opaqueId", "1$2", "3$4", false),
Entry("pass: valid driveID and itemID", "1$2", "1$2!5", true),
)
DescribeTable("IsSpaceRoot",
func(resourceID *provider.ResourceId, isRoot bool) {
Expect(service.IsSpaceRoot(resourceID)).To(Equal(isRoot))
},
Entry("spaceId and opaqueID equal", &provider.ResourceId{
StorageId: "1",
OpaqueId: "2",
SpaceId: "2",
}, true),
Entry("nil", nil, false),
Entry("spaceID empty", &provider.ResourceId{
StorageId: "1",
OpaqueId: "2",
}, false),
Entry("opaqueID empty", &provider.ResourceId{
StorageId: "1",
SpaceId: "3",
}, false),
Entry("spaceID and opaqueID unequal", &provider.ResourceId{
OpaqueId: "2",
SpaceId: "3",
}, false),
)
DescribeTable("IsShareJail",
func(resourceID *provider.ResourceId, isShareJail bool) {
Expect(service.IsShareJail(resourceID)).To(Equal(isShareJail))
},
Entry("valid: share jail", &provider.ResourceId{
StorageId: utils.ShareStorageProviderID,
SpaceId: utils.ShareStorageSpaceID,
}, true),
Entry("invalid: empty storageId", &provider.ResourceId{
SpaceId: utils.ShareStorageSpaceID,
}, false),
Entry("invalid: empty spaceId", &provider.ResourceId{
StorageId: utils.ShareStorageProviderID,
}, false),
Entry("invalid: empty storageId and spaceId", &provider.ResourceId{}, false),
Entry("invalid: non share jail storageId", &provider.ResourceId{
StorageId: "123",
SpaceId: utils.ShareStorageSpaceID,
}, false),
Entry("invalid: non share jail spaceId", &provider.ResourceId{
StorageId: utils.ShareStorageProviderID,
SpaceId: "123",
}, false),
Entry("invalid: non share jail storageID and spaceId", &provider.ResourceId{
StorageId: "123",
SpaceId: "123",
}, false),
)
DescribeTable("_cs3ReceivedShareToLibreGraphPermissions",
func(permissionSet *provider.ResourcePermissions, match func(*libregraph.Permission)) {
permission, err := service.CS3ReceivedShareToLibreGraphPermissions(
context.Background(),
nil,
cache.IdentityCache{},
&collaboration.ReceivedShare{
Share: &collaboration.Share{
Permissions: &collaboration.SharePermissions{
Permissions: permissionSet,
},
},
}, &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
},
unifiedrole.GetRoles(unifiedrole.RoleFilterAll()),
)
Expect(err).ToNot(HaveOccurred())
match(permission)
},
Entry(
"permissions match a role",
rConversions.NewViewerRole().CS3ResourcePermissions(),
func(p *libregraph.Permission) {
Expect(p.GetRoles()).To(HaveExactElements([]string{unifiedrole.UnifiedRoleViewerID}))
Expect(p.GetLibreGraphPermissionsActions()).To(BeNil())
},
),
Entry(
"permissions do not match any role",
&provider.ResourcePermissions{
AddGrant: true,
},
func(p *libregraph.Permission) {
Expect(p.GetRoles()).To(BeNil())
Expect(p.GetLibreGraphPermissionsActions()).To(HaveExactElements([]string{unifiedrole.DriveItemPermissionsCreate}))
},
),
)
})