Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions services/graph/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ type Config struct {
TokenManager *TokenManager `yaml:"token_manager"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`

Application Application `yaml:"application"`
Spaces Spaces `yaml:"spaces"`
Identity Identity `yaml:"identity"`
IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"`
Events Events `yaml:"events"`
UnifiedRoles UnifiedRoles `yaml:"unified_roles"`
MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"`
Application Application `yaml:"application"`
Spaces Spaces `yaml:"spaces"`
Identity Identity `yaml:"identity"`
IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"`
EnableGuestInvites bool `yaml:"enable_guest_invites" env:"GRAPH_ENABLE_GUEST_INVITES" desc:"Enables creating permission invites (shares) to mail addresses. Disabled by default." introductionVersion:"%NEXT%"`
Events Events `yaml:"events"`
UnifiedRoles UnifiedRoles `yaml:"unified_roles"`
MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"`

Keycloak Keycloak `yaml:"keycloak"`
ServiceAccount ServiceAccount `yaml:"service_account"`
Expand Down
3 changes: 2 additions & 1 deletion services/graph/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ func DefaultConfig() *config.Config {
AssignDefaultUserRole: true,
IdentitySearchMinLength: 3,
},
Reva: shared.DefaultRevaConfig(),
EnableGuestInvites: false,
Reva: shared.DefaultRevaConfig(),
Spaces: config.Spaces{
StorageUsersAddress: "eu.opencloud.api.storage-users",
WebDavBase: "https://localhost:9200",
Expand Down
180 changes: 130 additions & 50 deletions services/graph/pkg/service/v0/api_driveitem_permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/mail"
"net/url"
"slices"
"strings"
Expand All @@ -14,6 +15,8 @@ import (
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"
permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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"
Expand Down Expand Up @@ -163,85 +166,142 @@ func (s DriveItemPermissionsService) Invite(ctx context.Context, resourceId *sto
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())
if email := driveRecipient.GetEmail(); email != "" {
if !s.config.EnableGuestInvites {
return libregraph.Permission{}, errorcode.New(errorcode.NotSupported, "sharing with mail recipients is not enabled")
}
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
Group: &libregraph.Identity{
DisplayName: group.GetDisplayName(),
Id: conversions.ToPointer(group.GetId()),
user := revactx.ContextMustGetUser(ctx)
rsp, err := gatewayClient.CheckPermission(ctx, &permissionsapi.CheckPermissionRequest{
Permission: "GuestInvites.Create",
SubjectRef: &permissionsapi.SubjectReference{
Spec: &permissionsapi.SubjectReference_UserId{UserId: user.GetId()},
},
})
if err != nil || rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
return libregraph.Permission{}, errorcode.New(errorcode.NotAllowed, "permission denied")
}
email = strings.TrimSpace(email)
if len(email) == 0 {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid mail recipient")
}

parsedMail, err := mail.ParseAddress(email)
if err != nil {
s.logger.Debug().Err(err).Msg("failed to parse mail recipient")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid mail recipient")
}
createShareRequest := createShareRequestToGroup(group, statResponse.GetInfo(), cs3ResourcePermissions)

// we're only interested in the Address part of the mail address (this is what reva uses as the user id
// for the created share and grants) let's strip on any "Name" part that might be existing
email = parsedMail.Address

createShareRequest := createShareRequestToMail(email, 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())

identity := &libregraph.Identity{
Id: conversions.ToPointer(email),
DisplayName: email,
LibreGraphUserType: conversions.ToPointer("Mail"),
}

permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
User: &libregraph.Identity{
DisplayName: user.GetDisplayName(),
Id: conversions.ToPointer(user.GetId().GetOpaqueId()),
LibreGraphUserType: conversions.ToPointer(identity.CS3UserTypeToGraph(user.GetId().GetType())),
},
User: identity,
}

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)
} else {
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())
}
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
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
Group: &libregraph.Identity{
DisplayName: group.GetDisplayName(),
Id: conversions.ToPointer(group.GetId()),
},
}
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
} else {
createShareRequest := createShareRequestToUser(user, statResponse.GetInfo(), cs3ResourcePermissions)
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.Error().Err(err).Msg("share creation failed")
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()
}

}

}
Expand Down Expand Up @@ -331,6 +391,26 @@ func createShareRequestToFederatedUser(user *userpb.User, resourceId *storagepro
}
}

func createShareRequestToMail(mail string, 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: &userpb.UserId{
Type: userpb.UserType_USER_TYPE_GUEST,
OpaqueId: mail,
},
},
},
Permissions: &collaboration.SharePermissions{
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()
Expand Down
73 changes: 73 additions & 0 deletions services/graph/pkg/service/v0/api_driveitem_permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
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"
permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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"
Expand Down Expand Up @@ -167,6 +169,76 @@ var _ = Describe("DriveItemPermissionsService", func() {
Expect(permission.GrantedToV2.Group.GetId()).To(Equal("2"))
})

It("creates guest share using an email address", func() {
cfg.EnableGuestInvites = true
gatewayClient.On("CheckPermission", mock.Anything, mock.Anything).Return(
&permissionsapi.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil)
gatewayClient.On("CreateShare", mock.Anything, mock.Anything).Return(createShareResponse, nil)
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{Email: libregraph.PtrString("Test User <guest@example.com>")},
}
createShareResponse.Share = &collaboration.Share{
Id: &collaboration.ShareId{OpaqueId: "guest123"},
}

permission, err := driveItemPermissionsService.Invite(ctx, driveItemId, driveItemInvite)
Expect(err).ToNot(HaveOccurred())
Expect(permission.GetId()).To(Equal("guest123"))
Expect(permission.GrantedToV2.User.GetDisplayName()).To(Equal("guest@example.com"))
Expect(permission.GrantedToV2.User.GetId()).To(Equal("guest@example.com"))
Expect(permission.GrantedToV2.User.GetLibreGraphUserType()).To(Equal("Mail"))
})
It("verifies that invalid email addresses are handled", func() {
cfg.EnableGuestInvites = true
gatewayClient.On("CheckPermission", mock.Anything, mock.Anything).Return(
&permissionsapi.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil)
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{Email: libregraph.PtrString("invalid")},
}

_, err := driveItemPermissionsService.Invite(ctx, driveItemId, driveItemInvite)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid mail recipient"))
})

It("verifies that empty email addresses are handled", func() {
cfg.EnableGuestInvites = true
gatewayClient.On("CheckPermission", mock.Anything, mock.Anything).Return(
&permissionsapi.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil)
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{Email: libregraph.PtrString(" ")},
}

_, err := driveItemPermissionsService.Invite(ctx, driveItemId, driveItemInvite)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid mail recipient"))
})

It("rejects guest shares when guest invites are disabled by default", func() {
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{Email: libregraph.PtrString("guest@example.com")},
}

_, err := driveItemPermissionsService.Invite(ctx, driveItemId, driveItemInvite)

Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not enabled"))
})

It("rejects guest shares without the permission to invite guests", func() {
cfg.EnableGuestInvites = true
gatewayClient.On("CheckPermission", mock.Anything, mock.Anything).Return(
&permissionsapi.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_PERMISSION_DENIED}}, nil)
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{Email: libregraph.PtrString("guest@example.com")},
}
_, err := driveItemPermissionsService.Invite(ctx, driveItemId, driveItemInvite)

Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("permission denied"))
})

It("succeeds with file roles (happy path)", func() {
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil)
gatewayClient.On("CreateShare", mock.Anything, mock.Anything).Return(createShareResponse, nil)
Expand Down Expand Up @@ -326,6 +398,7 @@ var _ = Describe("DriveItemPermissionsService", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(listSpacesResponse, nil)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil)
gatewayClient.On("CreateShare", mock.Anything, mock.Anything).Return(createShareResponse, nil)
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{ObjectId: libregraph.PtrString("1"), LibreGraphRecipientType: libregraph.PtrString("user")},
Expand Down
3 changes: 2 additions & 1 deletion services/graph/pkg/service/v0/educationuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
libregraph "github.com/opencloud-eu/libre-graph-api-go"

"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
"github.com/opencloud-eu/opencloud/services/graph/pkg/identity"
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"
Expand Down Expand Up @@ -115,7 +116,7 @@ func (g Graph) PostEducationUser(w http.ResponseWriter, r *http.Request) {
return
}
} else {
u.SetUserType("Member")
u.SetUserType(identity.UserTypeMember)
}

logger.Debug().Interface("user", u).Msg("calling create education user on backend")
Expand Down
2 changes: 1 addition & 1 deletion services/graph/pkg/service/v0/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "userType is a read-only attribute")
return
}
u.SetUserType("Member")
u.SetUserType(identity.UserTypeMember)

logger.Debug().Interface("user", u).Msg("calling create user on backend")
if u, err = g.identityBackend.CreateUser(r.Context(), *u); err != nil {
Expand Down
Loading