diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b009842e..3a592bc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] (beta) ### Added +- Drive items backup and restore link shares ### Fixed - Return a ServiceNotEnabled error when a tenant has no active SharePoint license. +### Known issues +- If a link share is created for an item with inheritance disabled + (via the Graph API), the link shares restored in that item will + not be inheritable by children +- Link shares with password protection can't be restored + ## [v0.10.0] (beta) - 2023-06-26 ### Added diff --git a/src/cmd/factory/impl/common.go b/src/cmd/factory/impl/common.go index f530e1d92..b18e73ca8 100644 --- a/src/cmd/factory/impl/common.go +++ b/src/cmd/factory/impl/common.go @@ -274,10 +274,12 @@ func generateAndRestoreDriveItems( { Name: fmt.Sprintf("file-1st-count-%d-at-%s", i, currentTime), Data: fileAData, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, + }, }, }, { @@ -291,18 +293,22 @@ func generateAndRestoreDriveItems( }, { Name: folderAName, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, { Name: folderCName, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, }, @@ -315,17 +321,21 @@ func generateAndRestoreDriveItems( { Name: fmt.Sprintf("file-count-%d-at-%s", i, currentTime), Data: fileEData, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, + }, }, }, }, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, { @@ -338,10 +348,12 @@ func generateAndRestoreDriveItems( Data: fileAData, }, }, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, { @@ -352,20 +364,24 @@ func generateAndRestoreDriveItems( // permissions. Name: fmt.Sprintf("file-count-%d-at-%s", i, currentTime), Data: fileBData, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, + }, }, }, }, Folders: []odStub.ItemData{ { Name: folderAName, - Perms: odStub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: odStub.MetaData{ + Perms: odStub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, }, diff --git a/src/internal/m365/helper_test.go b/src/internal/m365/helper_test.go index fe1de81f8..04c0ecc81 100644 --- a/src/internal/m365/helper_test.go +++ b/src/internal/m365/helper_test.go @@ -684,6 +684,33 @@ func permissionEqual(expected metadata.Permission, got metadata.Permission) bool return true } +func linkSharesEqual(expected metadata.LinkShare, got metadata.LinkShare) bool { + if !strings.EqualFold(expected.Link.Scope, got.Link.Scope) { + return false + } + + if !strings.EqualFold(expected.Link.Type, got.Link.Type) { + return false + } + + if !slices.Equal(expected.Entities, got.Entities) { + return false + } + + if (expected.Expiration == nil && got.Expiration != nil) || + (expected.Expiration != nil && got.Expiration == nil) { + return false + } + + if expected.Expiration != nil && + got.Expiration != nil && + !expected.Expiration.Equal(ptr.Val(got.Expiration)) { + return false + } + + return true +} + func compareDriveItem( t *testing.T, expected map[string][]byte, @@ -773,6 +800,8 @@ func compareDriveItem( return true } + assert.Equal(t, expectedMeta.SharingMode, itemMeta.SharingMode, "sharing mode") + // We cannot restore owner permissions, so skip checking them itemPerms := []metadata.Permission{} @@ -792,6 +821,13 @@ func compareDriveItem( config.Service == path.SharePointService, permissionEqual) + testElementsMatch( + t, + expectedMeta.LinkShares, + itemMeta.LinkShares, + false, + linkSharesEqual) + return true } diff --git a/src/internal/m365/onedrive/handlers.go b/src/internal/m365/onedrive/handlers.go index e55db3759..490ddd647 100644 --- a/src/internal/m365/onedrive/handlers.go +++ b/src/internal/m365/onedrive/handlers.go @@ -80,13 +80,14 @@ type GetItemer interface { // --------------------------------------------------------------------------- type RestoreHandler interface { - DeleteItemPermissioner GetFolderByNamer GetRootFolderer ItemInfoAugmenter NewItemContentUploader PostItemInContainerer + DeleteItemPermissioner UpdateItemPermissioner + UpdateItemLinkSharer } type NewItemContentUploader interface { @@ -113,6 +114,14 @@ type UpdateItemPermissioner interface { ) (drives.ItemItemsItemInviteResponseable, error) } +type UpdateItemLinkSharer interface { + PostItemLinkShareUpdate( + ctx context.Context, + driveID, itemID string, + body *drives.ItemItemsItemCreateLinkPostRequestBody, + ) (models.Permissionable, error) +} + type PostItemInContainerer interface { PostItemInContainer( ctx context.Context, diff --git a/src/internal/m365/onedrive/item.go b/src/internal/m365/onedrive/item.go index bf12e91c4..a149efd12 100644 --- a/src/internal/m365/onedrive/item.go +++ b/src/internal/m365/onedrive/item.go @@ -109,6 +109,7 @@ func downloadItemMeta( } meta.Permissions = metadata.FilterPermissions(ctx, perm.GetValue()) + meta.LinkShares = metadata.FilterLinkShares(ctx, perm.GetValue()) } metaJSON, err := json.Marshal(meta) diff --git a/src/internal/m365/onedrive/item_handler.go b/src/internal/m365/onedrive/item_handler.go index c440d4082..847c526ee 100644 --- a/src/internal/m365/onedrive/item_handler.go +++ b/src/internal/m365/onedrive/item_handler.go @@ -169,6 +169,14 @@ func (h itemRestoreHandler) PostItemPermissionUpdate( return h.ac.PostItemPermissionUpdate(ctx, driveID, itemID, body) } +func (h itemRestoreHandler) PostItemLinkShareUpdate( + ctx context.Context, + driveID, itemID string, + body *drives.ItemItemsItemCreateLinkPostRequestBody, +) (models.Permissionable, error) { + return h.ac.PostItemLinkShareUpdate(ctx, driveID, itemID, body) +} + func (h itemRestoreHandler) PostItemInContainer( ctx context.Context, driveID, parentFolderID string, diff --git a/src/internal/m365/onedrive/metadata/metadata.go b/src/internal/m365/onedrive/metadata/metadata.go index 32fb33707..915a92065 100644 --- a/src/internal/m365/onedrive/metadata/metadata.go +++ b/src/internal/m365/onedrive/metadata/metadata.go @@ -5,6 +5,31 @@ import ( "time" ) +type Entity struct { + ID string `json:"id,omitempty"` + EntityType GV2Type `json:"entityType,omitempty"` +} + +type LinkShareLink struct { + Scope string `json:"scope,omitempty"` + Type string `json:"type,omitempty"` + WebURL string `json:"webUrl,omitempty"` // we cannot restore this, but can be used for comparisons + PreventsDownload bool `json:"preventsDownload,omitempty"` +} + +type LinkShare struct { + ID string `json:"id,omitempty"` + Link LinkShareLink `json:"link,omitempty"` + Roles []string `json:"roles,omitempty"` + Entities []Entity `json:"entities,omitempty"` // this is the resource owner's ID + HasPassword bool `json:"hasPassword,omitempty"` // We cannot restore ones with password + Expiration *time.Time `json:"expiration,omitempty"` +} + +func (ls LinkShare) Equals(other LinkShare) bool { + return ls.Link.WebURL == other.Link.WebURL +} + // ItemMeta contains metadata about the Item. It gets stored in a // separate file in kopia type Metadata struct { @@ -14,6 +39,7 @@ type Metadata struct { // - custom: use Permissions to set correct permissions ("shared" has value in delta) SharingMode SharingMode `json:"permissionMode,omitempty"` Permissions []Permission `json:"permissions,omitempty"` + LinkShares []LinkShare `json:"linkShares,omitempty"` } type Item struct { diff --git a/src/internal/m365/onedrive/metadata/permissions.go b/src/internal/m365/onedrive/metadata/permissions.go index 73f8e58e1..55201da39 100644 --- a/src/internal/m365/onedrive/metadata/permissions.go +++ b/src/internal/m365/onedrive/metadata/permissions.go @@ -40,13 +40,14 @@ type Permission struct { Expiration *time.Time `json:"expiration,omitempty"` } -// isSamePermission checks equality of two UserPermission objects +// Equal checks equality of two UserPermission objects func (p Permission) Equals(other Permission) bool { // EntityID can be empty for older backups and Email can be empty // for newer ones. It is not possible for both to be empty. Also, // if EntityID/Email for one is not empty then the other will also // have EntityID/Email as we backup permissions for all the // parents and children when we have a change in permissions. + // We cannot just compare id because of the problem described in #3117 if p.EntityID != "" && p.EntityID != other.EntityID { return false } @@ -64,19 +65,51 @@ func (p Permission) Equals(other Permission) bool { return slices.Equal(p1r, p2r) } +// DiffLinkShares is just a wrapper on top of DiffPermissions but we +// filter out link shares which do not have any associated users. This +// is useful for two reason: +// - When a user creates a link share on parent after creating a child +// link with `retainInheritedPermissisons`, all the previous link shares +// are inherited onto the child but without any users associated with +// the share. We have to drop the empty ones to make sure we reset. +// - We are restoring link shares so that we can restore permissions for +// the user, but restoring links without users is not useful. +func DiffLinkShares(current, expected []LinkShare) ([]LinkShare, []LinkShare) { + filteredCurrent := []LinkShare{} + filteredExpected := []LinkShare{} + + for _, ls := range current { + if len(ls.Entities) == 0 { + continue + } + + filteredCurrent = append(filteredCurrent, ls) + } + + for _, ls := range expected { + if len(ls.Entities) == 0 { + continue + } + + filteredExpected = append(filteredExpected, ls) + } + + return DiffPermissions(filteredCurrent, filteredExpected) +} + // DiffPermissions compares the before and after set, returning // the permissions that were added and removed (in that order) // in the after set. -func DiffPermissions(before, after []Permission) ([]Permission, []Permission) { +func DiffPermissions[T interface{ Equals(T) bool }](current, expected []T) ([]T, []T) { var ( - added = []Permission{} - removed = []Permission{} + added = []T{} + removed = []T{} ) - for _, cp := range after { + for _, cp := range expected { found := false - for _, pp := range before { + for _, pp := range current { if cp.Equals(pp) { found = true break @@ -88,10 +121,10 @@ func DiffPermissions(before, after []Permission) ([]Permission, []Permission) { } } - for _, pp := range before { + for _, pp := range current { found := false - for _, cp := range after { + for _, cp := range expected { if cp.Equals(pp) { found = true break @@ -116,45 +149,19 @@ func FilterPermissions(ctx context.Context, perms []models.Permissionable) []Per continue } - var ( - // Below are the mapping from roles to "Advanced" permissions - // screen entries: - // - // owner - Full Control - // write - Design | Edit | Contribute (no difference in /permissions api) - // read - Read - // empty - Restricted View - // - // helpful docs: - // https://devblogs.microsoft.com/microsoft365dev/controlling-app-access-on-specific-sharepoint-site-collections/ - roles = p.GetRoles() - gv2 = p.GetGrantedToV2() - entityID string - gv2t GV2Type - ) + // Below are the mapping from roles to "Advanced" permissions + // screen entries: + // + // owner - Full Control + // write - Design | Edit | Contribute (no difference in /permissions api) + // read - Read + // empty - Restricted View + // + // helpful docs: + // https://devblogs.microsoft.com/microsoft365dev/controlling-app-access-on-specific-sharepoint-site-collections/ + roles := p.GetRoles() - switch true { - case gv2.GetUser() != nil: - gv2t = GV2User - entityID = ptr.Val(gv2.GetUser().GetId()) - case gv2.GetSiteUser() != nil: - gv2t = GV2SiteUser - entityID = ptr.Val(gv2.GetSiteUser().GetId()) - case gv2.GetGroup() != nil: - gv2t = GV2Group - entityID = ptr.Val(gv2.GetGroup().GetId()) - case gv2.GetSiteGroup() != nil: - gv2t = GV2SiteGroup - entityID = ptr.Val(gv2.GetSiteGroup().GetId()) - case gv2.GetApplication() != nil: - gv2t = GV2App - entityID = ptr.Val(gv2.GetApplication().GetId()) - case gv2.GetDevice() != nil: - gv2t = GV2Device - entityID = ptr.Val(gv2.GetDevice().GetId()) - default: - logger.Ctx(ctx).Info("untracked permission") - } + gv2t, entityID := getIdentityDetails(ctx, p.GetGrantedToV2()) // Technically GrantedToV2 can also contain devices, but the // documentation does not mention about devices in permissions @@ -174,3 +181,83 @@ func FilterPermissions(ctx context.Context, perms []models.Permissionable) []Per return up } + +func FilterLinkShares(ctx context.Context, perms []models.Permissionable) []LinkShare { + up := []LinkShare{} + + for _, p := range perms { + link := p.GetLink() + if link == nil { + // Non link share based permissions are handled separately + continue + } + + var ( + roles = p.GetRoles() + gv2 = p.GetGrantedToIdentitiesV2() + ) + + idens := []Entity{} + + for _, g := range gv2 { + gv2t, entityID := getIdentityDetails(ctx, g) + + // Technically GrantedToV2 can also contain devices, but the + // documentation does not mention about devices in permissions + if entityID == "" { + // This should ideally not be hit + continue + } + + idens = append(idens, Entity{ID: entityID, EntityType: gv2t}) + } + + up = append(up, LinkShare{ + ID: ptr.Val(p.GetId()), + Link: LinkShareLink{ + Scope: ptr.Val(link.GetScope()), + Type: ptr.Val(link.GetType()), + WebURL: ptr.Val(link.GetWebUrl()), + PreventsDownload: ptr.Val(link.GetPreventsDownload()), + }, + Roles: roles, + Entities: idens, + HasPassword: ptr.Val(p.GetHasPassword()), + Expiration: p.GetExpirationDateTime(), + }) + } + + return up +} + +func getIdentityDetails(ctx context.Context, gv2 models.SharePointIdentitySetable) (GV2Type, string) { + var ( + gv2t GV2Type + entityID string + ) + + switch true { + case gv2.GetUser() != nil: + gv2t = GV2User + entityID = ptr.Val(gv2.GetUser().GetId()) + case gv2.GetSiteUser() != nil: + gv2t = GV2SiteUser + entityID = ptr.Val(gv2.GetSiteUser().GetId()) + case gv2.GetGroup() != nil: + gv2t = GV2Group + entityID = ptr.Val(gv2.GetGroup().GetId()) + case gv2.GetSiteGroup() != nil: + gv2t = GV2SiteGroup + entityID = ptr.Val(gv2.GetSiteGroup().GetId()) + case gv2.GetApplication() != nil: + gv2t = GV2App + entityID = ptr.Val(gv2.GetApplication().GetId()) + case gv2.GetDevice() != nil: + gv2t = GV2Device + entityID = ptr.Val(gv2.GetDevice().GetId()) + default: + logger.Ctx(ctx).Info("untracked permission") + } + + return gv2t, entityID +} diff --git a/src/internal/m365/onedrive/metadata/permissions_test.go b/src/internal/m365/onedrive/metadata/permissions_test.go index 47ccd2b3b..926475e4a 100644 --- a/src/internal/m365/onedrive/metadata/permissions_test.go +++ b/src/internal/m365/onedrive/metadata/permissions_test.go @@ -149,6 +149,71 @@ func (suite *PermissionsUnitTestSuite) TestDiffPermissions() { } } +func (suite *PermissionsUnitTestSuite) TestDiffLinkShares() { + entities1 := []Entity{{ID: "e1"}} + ls1 := LinkShare{ + ID: "id1", + Entities: entities1, + Link: LinkShareLink{WebURL: "id1"}, + } + + lsempty := LinkShare{ + ID: "id2", + Link: LinkShareLink{WebURL: "id2"}, + } + + table := []struct { + name string + before []LinkShare + after []LinkShare + added []LinkShare + removed []LinkShare + }{ + { + name: "single link share added", + before: []LinkShare{}, + after: []LinkShare{ls1}, + added: []LinkShare{ls1}, + removed: []LinkShare{}, + }, + { + name: "empty filtered from before", + before: []LinkShare{lsempty}, + after: []LinkShare{}, + added: []LinkShare{}, + removed: []LinkShare{}, + }, + { + name: "empty filtered from after", + before: []LinkShare{}, + after: []LinkShare{lsempty}, + added: []LinkShare{}, + removed: []LinkShare{}, + }, + { + name: "empty filtered from both", + before: []LinkShare{lsempty, ls1}, + after: []LinkShare{lsempty}, + added: []LinkShare{}, + removed: []LinkShare{ls1}, + }, + } + + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + _, flush := tester.NewContext(t) + defer flush() + + added, removed := DiffLinkShares(test.before, test.after) + + assert.Equal(t, added, test.added, "added link shares") + assert.Equal(t, removed, test.removed, "removed link shares") + }) + } +} + func getPermsAndResourceOwnerPerms( permID, resourceOwner string, gv2t GV2Type, diff --git a/src/internal/m365/onedrive/mock/handlers.go b/src/internal/m365/onedrive/mock/handlers.go index b8d328213..1569d6dd6 100644 --- a/src/internal/m365/onedrive/mock/handlers.go +++ b/src/internal/m365/onedrive/mock/handlers.go @@ -268,6 +268,14 @@ func (h RestoreHandler) PostItemPermissionUpdate( return nil, clues.New("not implemented") } +func (h RestoreHandler) PostItemLinkShareUpdate( + ctx context.Context, + driveID, itemID string, + body *drives.ItemItemsItemCreateLinkPostRequestBody, +) (models.Permissionable, error) { + return nil, clues.New("not implemented") +} + func (h RestoreHandler) PostItemInContainer( context.Context, string, string, diff --git a/src/internal/m365/onedrive/permission.go b/src/internal/m365/onedrive/permission.go index 642f8d751..023b89bb8 100644 --- a/src/internal/m365/onedrive/permission.go +++ b/src/internal/m365/onedrive/permission.go @@ -2,6 +2,7 @@ package onedrive import ( "context" + "strings" "github.com/alcionai/clues" "github.com/microsoftgraph/msgraph-sdk-go/drives" @@ -11,6 +12,7 @@ import ( "github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/internal/m365/onedrive/metadata" "github.com/alcionai/corso/src/internal/version" + "github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/path" ) @@ -81,11 +83,58 @@ func getCollectionMetadata( return meta, nil } -// computeParentPermissions computes the parent permissions by +// Unlike permissions, link shares are inherited from all the parents +// of an item and not just the direct parent. +func computePreviousLinkShares( + ctx context.Context, + originDir path.Path, + parentMetas map[string]metadata.Metadata, +) ([]metadata.LinkShare, error) { + linkShares := []metadata.LinkShare{} + ctx = clues.Add(ctx, "origin_dir", originDir) + + parent, err := originDir.Dir() + if err != nil { + return nil, clues.New("getting parent").WithClues(ctx) + } + + for len(parent.Elements()) > 0 { + ictx := clues.Add(ctx, "current_ancestor_dir", parent) + + drivePath, err := path.ToDrivePath(parent) + if err != nil { + return nil, clues.New("transforming dir to drivePath").WithClues(ictx) + } + + if len(drivePath.Folders) == 0 { + break + } + + meta, ok := parentMetas[parent.String()] + if !ok { + return nil, clues.New("no metadata found in parent").WithClues(ictx) + } + + // Any change in permissions would change it to custom + // permission set and so we can filter on that. + if meta.SharingMode == metadata.SharingModeCustom { + linkShares = append(linkShares, meta.LinkShares...) + } + + parent, err = parent.Dir() + if err != nil { + return nil, clues.New("getting parent").WithClues(ctx) + } + } + + return linkShares, nil +} + +// computePreviousMetadata computes the parent permissions by // traversing parentMetas and finding the first item with custom // permissions. parentMetas is expected to have all the parent // directory metas for this to work. -func computeParentPermissions( +func computePreviousMetadata( ctx context.Context, originDir path.Path, // map parent dir -> parent's metadata @@ -227,6 +276,128 @@ func UpdatePermissions( return nil } +type updateDeleteItemLinkSharer interface { + DeleteItemPermissioner // Deletion logic is same as permissions + UpdateItemLinkSharer +} + +func UpdateLinkShares( + ctx context.Context, + upils updateDeleteItemLinkSharer, + driveID string, + itemID string, + lsAdded, lsRemoved []metadata.LinkShare, + oldLinkShareIDToNewID map[string]string, +) (bool, error) { + // You can only delete inherited sharing links the first time you + // create a sharing link which is done using + // `retainInheritedPermissions`. We cannot separately delete any + // inherited link shares via DELETE API call like for permissions. + alreadyDeleted := false + + for _, ls := range lsAdded { + ictx := clues.Add(ctx, "link_share_id", ls.ID) + + // Links with password are not shared with a specific user + // even when we select a particular user, plus we are not + // able to get the password or retain the original link and + // so restoring them makes no sense. + if ls.HasPassword { + continue + } + + idens := []map[string]string{} + entities := []string{} + + for _, iden := range ls.Entities { + // TODO: sitegroup support. Currently errors with "One or more users could not be resolved", + // likely due to the site group entityID consisting of a single integer (ex: 4) + if iden.EntityType == metadata.GV2SiteGroup { + continue + } + + // Using DriveRecipient seems to error out on Graph end + idens = append(idens, map[string]string{"objectId": iden.ID}) + entities = append(entities, iden.ID) + } + + ictx = clues.Add(ictx, "link_share_entity_ids", strings.Join(entities, ",")) + + // https://learn.microsoft.com/en-us/graph/api/driveitem-createlink?view=graph-rest-beta&tabs=http + // v1.0 version of the graph API does not support creating a + // link without sending a notification to the user and so we + // use the beta API. Since we use the v1.0 API, we have to + // stuff some of the data into the AdditionalData fields as + // the actual fields don't exist in the stable sdk. + // Here is the data that we have to send: + // { + // "type": "view", + // "scope": "anonymous", + // "password": "String", + // "expirationDateTime": "...", + // "recipients": [{"@odata.type": "microsoft.graph.driveRecipient"}], + // "sendNotification": true, + // "retainInheritedPermissions": false + // } + lsbody := drives.NewItemItemsItemCreateLinkPostRequestBody() + lsbody.SetType(ptr.To(ls.Link.Type)) + lsbody.SetScope(ptr.To(ls.Link.Scope)) + lsbody.SetExpirationDateTime(ls.Expiration) + + ad := map[string]any{ + "sendNotification": false, + "recipients": idens, + } + lsbody.SetAdditionalData(ad) + + if !alreadyDeleted { + // The only way to delete any is to use this and so if + // we have any deleted items, we can be sure that all the + // inherited permissions would have been removed. + lsbody.SetRetainInheritedPermissions(ptr.To(len(lsRemoved) == 0)) + + // This value only effective on the first call, but lets + // make sure to not send it on followups. + alreadyDeleted = true + } + + newLS, err := upils.PostItemLinkShareUpdate(ictx, driveID, itemID, lsbody) + if err != nil { + return alreadyDeleted, clues.Stack(err) + } + + oldLinkShareIDToNewID[ls.ID] = ptr.Val(newLS.GetId()) + } + + // It is possible to have empty link shares even though we should + // have inherited one if the user creates a link using + // `retainInheritedPermissions` as false, but then deleted it. We + // can recreate this by creating a link with no users and deleting it. + if len(lsRemoved) > 0 && len(lsAdded) == 0 { + lsbody := drives.NewItemItemsItemCreateLinkPostRequestBody() + lsbody.SetType(ptr.To("view")) + // creating a `users` link without any users ensure that even + // if we fail to delete the link there are no links lying + // around that could be used to access this + lsbody.SetScope(ptr.To("users")) + lsbody.SetRetainInheritedPermissions(ptr.To(false)) + + newLS, err := upils.PostItemLinkShareUpdate(ctx, driveID, itemID, lsbody) + if err != nil { + return alreadyDeleted, clues.Stack(err) + } + + alreadyDeleted = true + + err = upils.DeleteItemPermission(ctx, driveID, itemID, ptr.Val(newLS.GetId())) + if err != nil { + return alreadyDeleted, clues.Stack(err) + } + } + + return alreadyDeleted, nil +} + // RestorePermissions takes in the permissions of an item, computes // what permissions need to added and removed based on the parent // folder metas and uses that to add/remove the necessary permissions @@ -246,14 +417,46 @@ func RestorePermissions( ctx = clues.Add(ctx, "permission_item_id", itemID) - parents, err := computeParentPermissions(ctx, itemPath, caches.ParentDirToMeta) + previousLinkShares, err := computePreviousLinkShares(ctx, itemPath, caches.ParentDirToMeta) if err != nil { - return clues.Wrap(err, "parent permissions") + return clues.Wrap(err, "previous link shares") } - permAdded, permRemoved := metadata.DiffPermissions(parents.Permissions, current.Permissions) + lsAdded, lsRemoved := metadata.DiffLinkShares(previousLinkShares, current.LinkShares) - return UpdatePermissions( + // Link shares have to be updated before permissions as we have to + // use the information about if we had to reset the inheritance to + // decide if we have to restore all the permissions. + didReset, err := UpdateLinkShares( + ctx, + rh, + driveID, + itemID, + lsAdded, + lsRemoved, + caches.OldLinkShareIDToNewID) + if err != nil { + return clues.Wrap(err, "updating link shares") + } + + previous, err := computePreviousMetadata(ctx, itemPath, caches.ParentDirToMeta) + if err != nil { + return clues.Wrap(err, "previous metadata") + } + + permAdded, permRemoved := metadata.DiffPermissions(previous.Permissions, current.Permissions) + + if didReset { + // In case we did a reset of permissions when restoring link + // shares, we have to make sure to restore all the permissions + // that an item has as they too will be removed. + logger.Ctx(ctx).Debug("link share creation reset all inherited permissions") + + permRemoved = []metadata.Permission{} + permAdded = current.Permissions + } + + err = UpdatePermissions( ctx, rh, driveID, @@ -261,4 +464,9 @@ func RestorePermissions( permAdded, permRemoved, caches.OldPermIDToNewID) + if err != nil { + return clues.Wrap(err, "updating permissions") + } + + return nil } diff --git a/src/internal/m365/onedrive/permission_test.go b/src/internal/m365/onedrive/permission_test.go index c345d693e..e6c4881c3 100644 --- a/src/internal/m365/onedrive/permission_test.go +++ b/src/internal/m365/onedrive/permission_test.go @@ -156,7 +156,7 @@ func runComputeParentPermissionsTest( ctx, flush := tester.NewContext(t) defer flush() - m, err := computeParentPermissions(ctx, test.item, test.parentPerms) + m, err := computePreviousMetadata(ctx, test.item, test.parentPerms) require.NoError(t, err, "compute permissions") assert.Equal(t, m, test.meta) diff --git a/src/internal/m365/onedrive/restore.go b/src/internal/m365/onedrive/restore.go index e0031c485..af14fb530 100644 --- a/src/internal/m365/onedrive/restore.go +++ b/src/internal/m365/onedrive/restore.go @@ -39,6 +39,7 @@ type restoreCaches struct { Folders *folderCache ParentDirToMeta map[string]metadata.Metadata OldPermIDToNewID map[string]string + OldLinkShareIDToNewID map[string]string DriveIDToRootFolderID map[string]string pool sync.Pool } @@ -48,6 +49,7 @@ func NewRestoreCaches() *restoreCaches { Folders: NewFolderCache(), ParentDirToMeta: map[string]metadata.Metadata{}, OldPermIDToNewID: map[string]string{}, + OldLinkShareIDToNewID: map[string]string{}, DriveIDToRootFolderID: map[string]string{}, // Buffer pool for uploads pool: sync.Pool{ diff --git a/src/internal/m365/onedrive/stub/stub.go b/src/internal/m365/onedrive/stub/stub.go index 44590d5a1..da313a98c 100644 --- a/src/internal/m365/onedrive/stub/stub.go +++ b/src/internal/m365/onedrive/stub/stub.go @@ -3,6 +3,7 @@ package stub import ( "encoding/json" "fmt" + "strings" "github.com/alcionai/clues" "github.com/google/uuid" @@ -18,53 +19,87 @@ import ( // permission instead of email const versionPermissionSwitchedToID = version.OneDrive4DirIncludesPermissions -func getMetadata(fileName string, perm PermData, permUseID bool) metadata.Metadata { - if len(perm.User) == 0 || len(perm.Roles) == 0 || - perm.SharingMode != metadata.SharingModeCustom { +func getMetadata(fileName string, meta MetaData, permUseID bool) metadata.Metadata { + if meta.SharingMode != metadata.SharingModeCustom { return metadata.Metadata{ FileName: fileName, - SharingMode: perm.SharingMode, + SharingMode: meta.SharingMode, } } - // In case of permissions, the id will usually be same for same - // user/role combo unless deleted and readded, but we have to do - // this as we only have two users of which one is already taken. - id := uuid.NewString() - uperm := metadata.Permission{ID: id, Roles: perm.Roles} + testMeta := metadata.Metadata{FileName: fileName} - if permUseID { - uperm.EntityID = perm.EntityID - } else { - uperm.Email = perm.User + if len(meta.Perms.User) != 0 { + // In case of permissions, the id will usually be same for same + // user/role combo unless deleted and readded, but we have to do + // this as we only have two users of which one is already taken. + id := uuid.NewString() + uperm := metadata.Permission{ID: id, Roles: meta.Perms.Roles} + + if permUseID { + uperm.EntityID = meta.Perms.EntityID + } else { + uperm.Email = meta.Perms.User + } + + testMeta.Permissions = []metadata.Permission{uperm} } - testMeta := metadata.Metadata{ - FileName: fileName, - Permissions: []metadata.Permission{uperm}, + if len(meta.LinkShares) != 0 { + for _, ls := range meta.LinkShares { + id := strings.Join(ls.EntityIDs, "-") + ls.Scope + ls.Type + + entities := []metadata.Entity{} + for _, e := range ls.EntityIDs { + entities = append(entities, metadata.Entity{ID: e, EntityType: "user"}) + } + + ls := metadata.LinkShare{ + ID: id, // id is required for mapping from parent + Link: metadata.LinkShareLink{ + Scope: ls.Scope, + Type: ls.Type, + WebURL: id, + }, + Entities: entities, + } + + testMeta.LinkShares = append(testMeta.LinkShares, ls) + } } return testMeta } type PermData struct { - User string // user is only for older versions - EntityID string - Roles []string + User string // user is only for older versions + EntityID string + Roles []string +} + +type LinkShareData struct { + EntityIDs []string + Scope string + Type string +} + +type MetaData struct { SharingMode metadata.SharingMode + Perms PermData + LinkShares []LinkShareData } type ItemData struct { - Name string - Data []byte - Perms PermData + Name string + Data []byte + Meta MetaData } type ColInfo struct { PathElements []string - Perms PermData Files []ItemData Folders []ItemData + Meta MetaData } type collection struct { @@ -115,20 +150,20 @@ func DataForInfo( onedriveCol := NewCollection(service, c.PathElements, backupVersion) for _, f := range c.Files { - _, err = onedriveCol.withFile(f.Name, f.Data, f.Perms) + _, err = onedriveCol.withFile(f.Name, f.Data, f.Meta) if err != nil { return res, err } } for _, d := range c.Folders { - _, err = onedriveCol.withFolder(d.Name, d.Perms) + _, err = onedriveCol.withFolder(d.Name, d.Meta) if err != nil { return res, err } } - _, err = onedriveCol.withPermissions(c.Perms) + _, err = onedriveCol.withPermissions(c.Meta) if err != nil { return res, err } @@ -139,7 +174,7 @@ func DataForInfo( return res, nil } -func (c *collection) withFile(name string, fileData []byte, perm PermData) (*collection, error) { +func (c *collection) withFile(name string, fileData []byte, meta MetaData) (*collection, error) { switch c.BackupVersion { case 0: // Lookups will occur using the most recent version of things so we need @@ -171,7 +206,7 @@ func (c *collection) withFile(name string, fileData []byte, perm PermData) (*col "", name+metadata.MetaFileSuffix, name+metadata.MetaFileSuffix, - perm, + meta, c.BackupVersion >= versionPermissionSwitchedToID) if err != nil { return c, err @@ -196,7 +231,7 @@ func (c *collection) withFile(name string, fileData []byte, perm PermData) (*col name, name+metadata.MetaFileSuffix, name, - perm, + meta, c.BackupVersion >= versionPermissionSwitchedToID) if err != nil { return c, err @@ -212,7 +247,7 @@ func (c *collection) withFile(name string, fileData []byte, perm PermData) (*col return c, nil } -func (c *collection) withFolder(name string, perm PermData) (*collection, error) { +func (c *collection) withFolder(name string, meta MetaData) (*collection, error) { switch c.BackupVersion { case 0, version.OneDrive4DirIncludesPermissions, version.OneDrive5DirMetaNoName, version.OneDrive6NameInMeta, version.OneDrive7LocationRef, version.All8MigrateUserPNToID: @@ -223,7 +258,7 @@ func (c *collection) withFolder(name string, perm PermData) (*collection, error) "", name+metadata.DirMetaFileSuffix, name+metadata.DirMetaFileSuffix, - perm, + meta, c.BackupVersion >= versionPermissionSwitchedToID) c.Items = append(c.Items, item) @@ -241,7 +276,7 @@ func (c *collection) withFolder(name string, perm PermData) (*collection, error) // withPermissions adds permissions to the folder represented by this // onedriveCollection. -func (c *collection) withPermissions(perm PermData) (*collection, error) { +func (c *collection) withPermissions(meta MetaData) (*collection, error) { // These versions didn't store permissions for the folder or didn't store them // in the folder's collection. if c.BackupVersion < version.OneDrive4DirIncludesPermissions { @@ -264,7 +299,7 @@ func (c *collection) withPermissions(perm PermData) (*collection, error) { name, metaName+metadata.DirMetaFileSuffix, metaName+metadata.DirMetaFileSuffix, - perm, + meta, c.BackupVersion >= versionPermissionSwitchedToID) if err != nil { return c, err @@ -304,10 +339,10 @@ func FileWithData( func ItemWithMetadata( fileName, itemID, lookupKey string, - perm PermData, + meta MetaData, permUseID bool, ) (m365Stub.ItemInfo, error) { - testMeta := getMetadata(fileName, perm, permUseID) + testMeta := getMetadata(fileName, meta, permUseID) testMetaJSON, err := json.Marshal(testMeta) if err != nil { diff --git a/src/internal/m365/onedrive_test.go b/src/internal/m365/onedrive_test.go index a930ce6ff..f93fd0826 100644 --- a/src/internal/m365/onedrive_test.go +++ b/src/internal/m365/onedrive_test.go @@ -231,6 +231,11 @@ func (suite *SharePointIntegrationSuite) TestPermissionsInheritanceRestoreAndBac testPermissionsInheritanceRestoreAndBackup(suite, version.Backup) } +func (suite *SharePointIntegrationSuite) TestLinkSharesInheritanceRestoreAndBackup() { + suite.T().Skip("Temporarily disabled due to CI issues") + testLinkSharesInheritanceRestoreAndBackup(suite, version.Backup) +} + func (suite *SharePointIntegrationSuite) TestRestoreFolderNamedFolderRegression() { // No reason why it couldn't work with previous versions, but this is when it got introduced. testRestoreFolderNamedFolderRegression(suite, version.All8MigrateUserPNToID) @@ -291,6 +296,10 @@ func (suite *OneDriveIntegrationSuite) TestPermissionsInheritanceRestoreAndBacku testPermissionsInheritanceRestoreAndBackup(suite, version.Backup) } +func (suite *OneDriveIntegrationSuite) TestLinkSharesInheritanceRestoreAndBackup() { + testLinkSharesInheritanceRestoreAndBackup(suite, version.Backup) +} + func (suite *OneDriveIntegrationSuite) TestRestoreFolderNamedFolderRegression() { // No reason why it couldn't work with previous versions, but this is when it got introduced. testRestoreFolderNamedFolderRegression(suite, version.All8MigrateUserPNToID) @@ -352,6 +361,10 @@ func (suite *OneDriveNightlySuite) TestPermissionsInheritanceRestoreAndBackup() testPermissionsInheritanceRestoreAndBackup(suite, version.OneDrive4DirIncludesPermissions) } +func (suite *OneDriveNightlySuite) TestLinkSharesInheritanceRestoreAndBackup() { + testLinkSharesInheritanceRestoreAndBackup(suite, version.Backup) +} + func (suite *OneDriveNightlySuite) TestRestoreFolderNamedFolderRegression() { // No reason why it couldn't work with previous versions, but this is when it got introduced. testRestoreFolderNamedFolderRegression(suite, version.All8MigrateUserPNToID) @@ -407,13 +420,17 @@ func testRestoreAndBackupMultipleFilesAndFoldersNoPermissions( folderBName, } + defaultMetadata := stub.MetaData{SharingMode: metadata.SharingModeInherited} + cols := []stub.ColInfo{ { PathElements: rootPath, + Meta: defaultMetadata, Files: []stub.ItemData{ { Name: fileName, Data: fileAData, + Meta: defaultMetadata, }, }, Folders: []stub.ItemData{ @@ -427,10 +444,12 @@ func testRestoreAndBackupMultipleFilesAndFoldersNoPermissions( }, { PathElements: folderAPath, + Meta: defaultMetadata, Files: []stub.ItemData{ { Name: fileName, Data: fileBData, + Meta: defaultMetadata, }, }, Folders: []stub.ItemData{ @@ -441,10 +460,12 @@ func testRestoreAndBackupMultipleFilesAndFoldersNoPermissions( }, { PathElements: subfolderBPath, + Meta: defaultMetadata, Files: []stub.ItemData{ { Name: fileName, Data: fileCData, + Meta: defaultMetadata, }, }, Folders: []stub.ItemData{ @@ -455,19 +476,23 @@ func testRestoreAndBackupMultipleFilesAndFoldersNoPermissions( }, { PathElements: subfolderAPath, + Meta: defaultMetadata, Files: []stub.ItemData{ { Name: fileName, Data: fileDData, + Meta: defaultMetadata, }, }, }, { PathElements: folderBPath, + Meta: defaultMetadata, Files: []stub.ItemData{ { Name: fileName, Data: fileEData, + Meta: defaultMetadata, }, }, }, @@ -557,15 +582,20 @@ func testPermissionsRestoreAndBackup(suite oneDriveSuite, startVersion int) { cols := []stub.ColInfo{ { PathElements: rootPath, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, Files: []stub.ItemData{ { // Test restoring a file that doesn't inherit permissions. Name: fileName, Data: fileAData, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, + }, }, }, { @@ -573,52 +603,69 @@ func testPermissionsRestoreAndBackup(suite oneDriveSuite, startVersion int) { // no permissions. Name: fileName2, Data: fileBData, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, }, }, Folders: []stub.ItemData{ { Name: folderBName, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, }, { Name: folderAName, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, { Name: folderCName, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, }, }, { PathElements: folderBPath, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, Files: []stub.ItemData{ { // Test restoring a file in a non-root folder that doesn't inherit // permissions. Name: fileName, Data: fileBData, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, + }, }, }, }, Folders: []stub.ItemData{ { Name: folderAName, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, }, @@ -654,17 +701,21 @@ func testPermissionsRestoreAndBackup(suite oneDriveSuite, startVersion int) { { Name: fileName, Data: fileEData, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, + }, }, }, }, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, { @@ -675,12 +726,17 @@ func testPermissionsRestoreAndBackup(suite oneDriveSuite, startVersion int) { { Name: fileName, Data: fileAData, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, }, }, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: readPerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: readPerm, + }, }, }, } @@ -746,10 +802,13 @@ func testPermissionsBackupAndNoRestore(suite oneDriveSuite, startVersion int) { { Name: fileName, Data: fileAData, - Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, + }, + SharingMode: metadata.SharingModeCustom, }, }, }, @@ -860,32 +919,44 @@ func testPermissionsInheritanceRestoreAndBackup(suite oneDriveSuite, startVersio folderCName, } - fileSet := []stub.ItemData{ - { - Name: "file-custom", - Data: fileAData, + fileCustom := stub.ItemData{ + Name: "file-custom", + Data: fileAData, + Meta: stub.MetaData{ Perms: stub.PermData{ - User: secondaryUserName, - EntityID: secondaryUserID, - Roles: writePerm, - SharingMode: metadata.SharingModeCustom, - }, - }, - { - Name: "file-inherited", - Data: fileAData, - Perms: stub.PermData{ - SharingMode: metadata.SharingModeInherited, - }, - }, - { - Name: "file-empty", - Data: fileAData, - Perms: stub.PermData{ - SharingMode: metadata.SharingModeCustom, + User: secondaryUserName, + EntityID: secondaryUserID, + Roles: writePerm, }, + SharingMode: metadata.SharingModeCustom, }, } + fileInherited := stub.ItemData{ + Name: "file-inherited", + Data: fileAData, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, + } + fileEmpty := stub.ItemData{ + Name: "file-empty", + Data: fileAData, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeCustom, + }, + } + + // If parent is empty, then empty permissions would be inherited + fileEmptyInherited := stub.ItemData{ + Name: "file-empty", + Data: fileAData, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, + } + + fileSet := []stub.ItemData{fileCustom, fileInherited, fileEmpty} + fileSetEmpty := []stub.ItemData{fileCustom, fileInherited, fileEmptyInherited} // Here is what this test is testing // - custom-permission-folder @@ -912,6 +983,9 @@ func testPermissionsInheritanceRestoreAndBackup(suite oneDriveSuite, startVersio Folders: []stub.ItemData{ {Name: folderAName}, }, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, }, { PathElements: folderAPath, @@ -921,33 +995,231 @@ func testPermissionsInheritanceRestoreAndBackup(suite oneDriveSuite, startVersio {Name: folderBName}, {Name: folderCName}, }, - Perms: stub.PermData{ - User: tertiaryUserName, - EntityID: tertiaryUserID, - Roles: readPerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: tertiaryUserName, + EntityID: tertiaryUserID, + Roles: readPerm, + }, + SharingMode: metadata.SharingModeCustom, }, }, { PathElements: subfolderAAPath, Files: fileSet, - Perms: stub.PermData{ - User: tertiaryUserName, - EntityID: tertiaryUserID, - Roles: writePerm, + Meta: stub.MetaData{ + Perms: stub.PermData{ + User: tertiaryUserName, + EntityID: tertiaryUserID, + Roles: writePerm, + }, SharingMode: metadata.SharingModeCustom, }, }, { PathElements: subfolderABPath, Files: fileSet, - Perms: stub.PermData{ + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, + }, + { + PathElements: subfolderACPath, + Files: fileSetEmpty, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeCustom, + }, + }, + } + + expected, err := stub.DataForInfo(suite.Service(), cols, version.Backup) + require.NoError(suite.T(), err) + bss := suite.Service().String() + + for vn := startVersion; vn <= version.Backup; vn++ { + suite.Run(fmt.Sprintf("%s-Version%d", bss, vn), func() { + t := suite.T() + // Ideally this can always be true or false and still + // work, but limiting older versions to use emails so as + // to validate that flow as well. + input, err := stub.DataForInfo(suite.Service(), cols, vn) + require.NoError(suite.T(), err) + + testData := restoreBackupInfoMultiVersion{ + service: suite.Service(), + resourceCat: suite.Resource(), + backupVersion: vn, + collectionsPrevious: input, + collectionsLatest: expected, + } + + runRestoreBackupTestVersions( + t, + testData, + suite.Tenant(), + []string{suite.ResourceOwner()}, + control.Options{ + RestorePermissions: true, + ToggleFeatures: control.Toggles{}, + }) + }) + } +} + +func testLinkSharesInheritanceRestoreAndBackup(suite oneDriveSuite, startVersion int) { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + _, secondaryUserID := suite.SecondaryUser() + _, tertiaryUserID := suite.TertiaryUser() + + // Get the default drive ID for the test user. + driveID := mustGetDefaultDriveID( + t, + ctx, + suite.APIClient(), + suite.Service(), + suite.ResourceOwner()) + + folderAName := "custom" + folderBName := "inherited" + folderCName := "empty" + + rootPath := []string{ + odConsts.DrivesPathDir, + driveID, + odConsts.RootPathDir, + } + folderAPath := []string{ + odConsts.DrivesPathDir, + driveID, + odConsts.RootPathDir, + folderAName, + } + subfolderAAPath := []string{ + odConsts.DrivesPathDir, + driveID, + odConsts.RootPathDir, + folderAName, + folderAName, + } + subfolderABPath := []string{ + odConsts.DrivesPathDir, + driveID, + odConsts.RootPathDir, + folderAName, + folderBName, + } + subfolderACPath := []string{ + odConsts.DrivesPathDir, + driveID, + odConsts.RootPathDir, + folderAName, + folderCName, + } + + fileSet := []stub.ItemData{ + { + Name: "file-custom", + Data: fileAData, + Meta: stub.MetaData{ + LinkShares: []stub.LinkShareData{ + { + EntityIDs: []string{secondaryUserID}, + Scope: "users", + Type: "edit", + }, + }, + SharingMode: metadata.SharingModeCustom, + }, + }, + { + Name: "file-inherited", + Data: fileBData, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeInherited, + }, + }, + { + Name: "file-empty", + Data: fileCData, + Meta: stub.MetaData{ + SharingMode: metadata.SharingModeCustom, + }, + }, + } + + // Here is what this test is testing + // - custom-link-share-folder + // - custom-link-share-file + // - inherted-link-share-file + // - empty-link-share-file + // - custom-link-share-folder + // - custom-link-share-file + // - inherted-link-share-file + // - empty-link-share-file + // - inherted-link-share-folder + // - custom-link-share-file + // - inherted-link-share-file + // - empty-link-share-file + // - empty-link-share-folder + // - custom-link-share-file + // - inherted-link-share-file + // - empty-link-share-file + + cols := []stub.ColInfo{ + { + PathElements: rootPath, + Files: []stub.ItemData{}, + Folders: []stub.ItemData{ + {Name: folderAName}, + }, + }, + { + PathElements: folderAPath, + Files: fileSet, + Folders: []stub.ItemData{ + {Name: folderAName}, + {Name: folderBName}, + {Name: folderCName}, + }, + Meta: stub.MetaData{ + LinkShares: []stub.LinkShareData{ + { + EntityIDs: []string{tertiaryUserID}, + Scope: "anonymous", + Type: "edit", + }, + }, + }, + }, + { + PathElements: subfolderAAPath, + Files: fileSet, + Meta: stub.MetaData{ + LinkShares: []stub.LinkShareData{ + { + EntityIDs: []string{tertiaryUserID}, + Scope: "users", + Type: "edit", + }, + }, + SharingMode: metadata.SharingModeCustom, + }, + }, + { + PathElements: subfolderABPath, + Files: fileSet, + Meta: stub.MetaData{ SharingMode: metadata.SharingModeInherited, }, }, { PathElements: subfolderACPath, Files: fileSet, - Perms: stub.PermData{ + Meta: stub.MetaData{ SharingMode: metadata.SharingModeCustom, }, }, diff --git a/src/internal/m365/sharepoint/library_handler.go b/src/internal/m365/sharepoint/library_handler.go index 481dc367a..2dff629cf 100644 --- a/src/internal/m365/sharepoint/library_handler.go +++ b/src/internal/m365/sharepoint/library_handler.go @@ -195,6 +195,14 @@ func (h libraryRestoreHandler) PostItemPermissionUpdate( return h.ac.PostItemPermissionUpdate(ctx, driveID, itemID, body) } +func (h libraryRestoreHandler) PostItemLinkShareUpdate( + ctx context.Context, + driveID, itemID string, + body *drives.ItemItemsItemCreateLinkPostRequestBody, +) (models.Permissionable, error) { + return h.ac.PostItemLinkShareUpdate(ctx, driveID, itemID, body) +} + func (h libraryRestoreHandler) PostItemInContainer( ctx context.Context, driveID, parentFolderID string, diff --git a/src/pkg/services/m365/api/drive.go b/src/pkg/services/m365/api/drive.go index 79dfd90a1..85bc0a8ca 100644 --- a/src/pkg/services/m365/api/drive.go +++ b/src/pkg/services/m365/api/drive.go @@ -30,7 +30,10 @@ type Drives struct { // Folders // --------------------------------------------------------------------------- -const itemByPathRawURLFmt = "https://graph.microsoft.com/v1.0/drives/%s/items/%s:/%s" +const ( + itemByPathRawURLFmt = "https://graph.microsoft.com/v1.0/drives/%s/items/%s:/%s" + createLinkShareURLFmt = "https://graph.microsoft.com/beta/drives/%s/items/%s/createLink" +) var ErrFolderNotFound = clues.New("folder not found") @@ -307,6 +310,27 @@ func (c Drives) DeleteItemPermission( return nil } +func (c Drives) PostItemLinkShareUpdate( + ctx context.Context, + driveID, itemID string, + body *drives.ItemItemsItemCreateLinkPostRequestBody, +) (models.Permissionable, error) { + ctx = graph.ConsumeNTokens(ctx, graph.PermissionsLC) + + // We are using the beta version of the endpoint. This allows us + // to add recipients in the same request as well as to make it not + // send out and email for every link share the user gets added to. + rawURL := fmt.Sprintf(createLinkShareURLFmt, driveID, itemID) + builder := drives.NewItemItemsItemCreateLinkRequestBuilder(rawURL, c.Stable.Adapter()) + + itm, err := builder.Post(ctx, body, nil) + if err != nil { + return nil, graph.Wrap(ctx, err, "creating link share") + } + + return itm, nil +} + // DriveItemCollisionKeyy constructs a key from the item name. // collision keys are used to identify duplicate item conflicts for handling advanced restoration config. func DriveItemCollisionKey(item models.DriveItemable) string { diff --git a/website/docs/support/known-issues.md b/website/docs/support/known-issues.md index 6f98071fb..e6c7a585b 100644 --- a/website/docs/support/known-issues.md +++ b/website/docs/support/known-issues.md @@ -26,3 +26,11 @@ included in backup and restore. * SharePoint document library data can't be restored after the library has been deleted. * Sharing information of items in OneDrive/SharePoint using sharing links aren't backed up and restored. + +* Permissions/Access given to a site group can't be restored + +* If a link share is created for an item with inheritance disabled + (via the Graph API), the link shares restored in that item will + not be inheritable by children + +* Link shares with password protection can't be restored