diff --git a/CHANGELOG.md b/CHANGELOG.md index 1628256ec..f7dc82166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Skips graph calls for expired item download URLs. +### Fixed +- Catch and report cases where a protected resource is locked out of access. SDK consumers have a new errs sentinel that allows them to check for this case. + ## [v0.14.0] (beta) - 2023-10-09 ### Added diff --git a/src/internal/m365/controller.go b/src/internal/m365/controller.go index 6be0669dd..86444f059 100644 --- a/src/internal/m365/controller.go +++ b/src/internal/m365/controller.go @@ -263,6 +263,10 @@ func (r resourceClient) GetResourceIDAndNameFrom( return nil, clues.Stack(graph.ErrResourceOwnerNotFound, err) } + if graph.IsErrResourceLocked(err) { + return nil, clues.Stack(graph.ErrResourceLocked, err) + } + return nil, err } diff --git a/src/internal/m365/graph/errors.go b/src/internal/m365/graph/errors.go index b15ccc417..915f72fd4 100644 --- a/src/internal/m365/graph/errors.go +++ b/src/internal/m365/graph/errors.go @@ -15,6 +15,7 @@ import ( "github.com/pkg/errors" "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/common/str" "github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/filters" ) @@ -50,6 +51,7 @@ const ( // nameAlreadyExists occurs when a request with // @microsoft.graph.conflictBehavior=fail finds a conflicting file. nameAlreadyExists errorCode = "nameAlreadyExists" + NotAllowed errorCode = "notAllowed" noResolvedUsers errorCode = "noResolvedUsers" QuotaExceeded errorCode = "ErrorQuotaExceeded" RequestResourceNotFound errorCode = "Request_ResourceNotFound" @@ -61,6 +63,11 @@ const ( syncStateNotFound errorCode = "SyncStateNotFound" ) +// inner error codes +const ( + ResourceLocked errorCode = "resourceLocked" +) + type errorMessage string const ( @@ -113,6 +120,11 @@ var ( // replies, no error should get returned. ErrMultipleResultsMatchIdentifier = clues.New("multiple results match the identifier") + // ErrResourceLocked occurs when a resource has had its access locked. + // Example case: https://learn.microsoft.com/en-us/sharepoint/manage-lock-status + // This makes the resource inaccessible for any Corso operations. + ErrResourceLocked = clues.New("resource has been locked and must be unlocked by an administrator") + // ErrServiceNotEnabled identifies that a resource owner does not have // access to a given service. ErrServiceNotEnabled = clues.New("service is not enabled for that resource owner") @@ -267,6 +279,12 @@ func IsErrSiteNotFound(err error) bool { return hasErrorMessage(err, requestedSiteCouldNotBeFound) } +func IsErrResourceLocked(err error) bool { + return errors.Is(err, ErrResourceLocked) || + hasInnerErrorCode(err, ResourceLocked) || + hasErrorCode(err, NotAllowed) +} + // --------------------------------------------------------------------------- // error parsers // --------------------------------------------------------------------------- @@ -294,6 +312,34 @@ func hasErrorCode(err error, codes ...errorCode) bool { return filters.Equal(cs).Compare(code) } +func hasInnerErrorCode(err error, codes ...errorCode) bool { + if err == nil { + return false + } + + var oDataError odataerrors.ODataErrorable + if !errors.As(err, &oDataError) { + return false + } + + inner := oDataError.GetErrorEscaped().GetInnerError() + if inner == nil { + return false + } + + code, err := str.AnyValueToString("code", inner.GetAdditionalData()) + if err != nil { + return false + } + + cs := make([]string, len(codes)) + for i, c := range codes { + cs[i] = string(c) + } + + return filters.Equal(cs).Compare(code) +} + // only use this as a last resort. Prefer the code or statuscode if possible. func hasErrorMessage(err error, msgs ...errorMessage) bool { if err == nil { diff --git a/src/internal/m365/graph/errors_test.go b/src/internal/m365/graph/errors_test.go index 7921b2b64..e46955035 100644 --- a/src/internal/m365/graph/errors_test.go +++ b/src/internal/m365/graph/errors_test.go @@ -813,3 +813,57 @@ func (suite *GraphErrorsUnitSuite) TestIsErrItemNotFound() { }) } } + +func (suite *GraphErrorsUnitSuite) TestIsErrResourceLocked() { + innerMatch := odErr("not-match") + merr := odataerrors.NewMainError() + inerr := odataerrors.NewInnerError() + inerr.SetAdditionalData(map[string]any{ + "code": string(ResourceLocked), + }) + merr.SetInnerError(inerr) + merr.SetCode(ptr.To("not-match")) + innerMatch.SetErrorEscaped(merr) + + table := []struct { + name string + err error + expect assert.BoolAssertionFunc + }{ + { + name: "nil", + err: nil, + expect: assert.False, + }, + { + name: "non-matching", + err: assert.AnError, + expect: assert.False, + }, + { + name: "non-matching oDataErr", + err: odErrMsg("InvalidRequest", "resource is locked"), + expect: assert.False, + }, + { + name: "matching oDataErr code", + err: odErr(string(NotAllowed)), + expect: assert.True, + }, + { + name: "matching oDataErr inner code", + err: innerMatch, + expect: assert.True, + }, + { + name: "matching err sentinel", + err: ErrResourceLocked, + expect: assert.True, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + test.expect(suite.T(), IsErrResourceLocked(test.err)) + }) + } +} diff --git a/src/internal/m365/service/onedrive/enabled.go b/src/internal/m365/service/onedrive/enabled.go index cd29c8870..850322c06 100644 --- a/src/internal/m365/service/onedrive/enabled.go +++ b/src/internal/m365/service/onedrive/enabled.go @@ -30,6 +30,10 @@ func IsServiceEnabled( return false, clues.Stack(graph.ErrResourceOwnerNotFound, err) } + if graph.IsErrResourceLocked(err) { + return false, clues.Stack(graph.ErrResourceLocked, err) + } + return false, clues.Stack(err) } diff --git a/src/internal/m365/service/onedrive/enabled_test.go b/src/internal/m365/service/onedrive/enabled_test.go index 4ce77c3aa..81a0fcc2f 100644 --- a/src/internal/m365/service/onedrive/enabled_test.go +++ b/src/internal/m365/service/onedrive/enabled_test.go @@ -105,6 +105,17 @@ func (suite *EnabledUnitSuite) TestIsServiceEnabled() { assert.Error(t, err, clues.ToCore(err)) }, }, + { + name: "resource locked", + mock: func(ctx context.Context) getDefaultDriver { + odErr := odErrMsg(string(graph.NotAllowed), "resource") + return mockDGDD{nil, graph.Stack(ctx, odErr)} + }, + expect: assert.False, + expectErr: func(t *testing.T, err error) { + assert.Error(t, err, clues.ToCore(err)) + }, + }, { name: "arbitrary error", mock: func(ctx context.Context) getDefaultDriver { diff --git a/src/pkg/errs/errs.go b/src/pkg/errs/errs.go index 8d5d38edd..68f3df0b0 100644 --- a/src/pkg/errs/errs.go +++ b/src/pkg/errs/errs.go @@ -16,6 +16,7 @@ const ( ApplicationThrottled errEnum = "application-throttled" BackupNotFound errEnum = "backup-not-found" RepoAlreadyExists errEnum = "repository-already-exists" + ResourceNotAccessible errEnum = "resource-not-accesible" ResourceOwnerNotFound errEnum = "resource-owner-not-found" ServiceNotEnabled errEnum = "service-not-enabled" ) @@ -27,6 +28,7 @@ var internalToExternal = map[errEnum][]error{ ApplicationThrottled: {graph.ErrApplicationThrottled}, BackupNotFound: {repository.ErrorBackupNotFound}, RepoAlreadyExists: {repository.ErrorRepoAlreadyExists}, + ResourceNotAccessible: {graph.ErrResourceLocked}, ResourceOwnerNotFound: {graph.ErrResourceOwnerNotFound}, ServiceNotEnabled: {graph.ErrServiceNotEnabled}, } diff --git a/src/pkg/errs/errs_test.go b/src/pkg/errs/errs_test.go index 50b583143..d5d6d5a37 100644 --- a/src/pkg/errs/errs_test.go +++ b/src/pkg/errs/errs_test.go @@ -29,6 +29,7 @@ func (suite *ErrUnitSuite) TestInternal() { {BackupNotFound, []error{repository.ErrorBackupNotFound}}, {ServiceNotEnabled, []error{graph.ErrServiceNotEnabled}}, {ResourceOwnerNotFound, []error{graph.ErrResourceOwnerNotFound}}, + {ResourceNotAccessible, []error{graph.ErrResourceLocked}}, } for _, test := range table { suite.Run(string(test.get), func() { @@ -46,6 +47,7 @@ func (suite *ErrUnitSuite) TestIs() { {BackupNotFound, repository.ErrorBackupNotFound}, {ServiceNotEnabled, graph.ErrServiceNotEnabled}, {ResourceOwnerNotFound, graph.ErrResourceOwnerNotFound}, + {ResourceNotAccessible, graph.ErrResourceLocked}, } for _, test := range table { suite.Run(string(test.target), func() { diff --git a/src/pkg/services/m365/api/users.go b/src/pkg/services/m365/api/users.go index 15c3a46da..ccd3f22af 100644 --- a/src/pkg/services/m365/api/users.go +++ b/src/pkg/services/m365/api/users.go @@ -184,6 +184,10 @@ func EvaluateMailboxError(err error) error { return clues.Stack(graph.ErrResourceOwnerNotFound, err) } + if graph.IsErrResourceLocked(err) { + return clues.Stack(graph.ErrResourceLocked, err) + } + if graph.IsErrExchangeMailFolderNotFound(err) || graph.IsErrAuthenticationError(err) { return nil } diff --git a/src/pkg/services/m365/api/users_test.go b/src/pkg/services/m365/api/users_test.go index 007693448..1a4b250fd 100644 --- a/src/pkg/services/m365/api/users_test.go +++ b/src/pkg/services/m365/api/users_test.go @@ -85,6 +85,13 @@ func (suite *UsersUnitSuite) TestEvaluateMailboxError() { assert.ErrorIs(t, err, graph.ErrResourceOwnerNotFound, clues.ToCore(err)) }, }, + { + name: "mail inbox err - resoruceLocked", + err: odErr(string(graph.NotAllowed)), + expect: func(t *testing.T, err error) { + assert.ErrorIs(t, err, graph.ErrResourceLocked, clues.ToCore(err)) + }, + }, { name: "mail inbox err - user not found", err: odErr(string(graph.MailboxNotEnabledForRESTAPI)),