only code movement, no logic changes --- #### Does this PR need a docs update or release note? - [x] ⛔ No #### Type of change - [x] 🧹 Tech Debt/Cleanup #### Issue(s) * #3562 #### Test Plan - [x] ⚡ Unit test - [x] 💚 E2E
370 lines
9.9 KiB
Go
370 lines
9.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/alcionai/clues"
|
|
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/users"
|
|
|
|
"github.com/alcionai/corso/src/internal/common/idname"
|
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
|
"github.com/alcionai/corso/src/internal/m365/graph"
|
|
"github.com/alcionai/corso/src/pkg/fault"
|
|
"github.com/alcionai/corso/src/pkg/logger"
|
|
"github.com/alcionai/corso/src/pkg/path"
|
|
)
|
|
|
|
// Variables
|
|
var (
|
|
ErrMailBoxSettingsNotFound = clues.New("mailbox settings not found")
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (c Client) Users() Users {
|
|
return Users{c}
|
|
}
|
|
|
|
// Users is an interface-compliant provider of the client.
|
|
type Users struct {
|
|
Client
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Filter out both guest users, and (for on-prem installations) non-synced users.
|
|
// The latter filter makes an assumption that no on-prem users are guests; this might
|
|
// require more fine-tuned controls in the future.
|
|
// https://stackoverflow.com/questions/64044266/error-message-unsupported-or-invalid-query-filter-clause-specified-for-property
|
|
//
|
|
// ne 'Guest' ensures we don't filter out users where userType = null, which can happen
|
|
// for user accounts created prior to 2014. In order to use the `ne` comparator, we
|
|
// MUST include $count=true and the ConsistencyLevel: eventual header.
|
|
// https://stackoverflow.com/questions/49340485/how-to-filter-users-by-usertype-null
|
|
//
|
|
//nolint:lll
|
|
var userFilterNoGuests = "onPremisesSyncEnabled eq true OR userType ne 'Guest'"
|
|
|
|
// GetAll retrieves all users.
|
|
func (c Users) GetAll(
|
|
ctx context.Context,
|
|
errs *fault.Bus,
|
|
) ([]models.Userable, error) {
|
|
service, err := c.Service()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
config := &users.UsersRequestBuilderGetRequestConfiguration{
|
|
Headers: newEventualConsistencyHeaders(),
|
|
QueryParameters: &users.UsersRequestBuilderGetQueryParameters{
|
|
Select: idAnd(userPrincipalName, displayName),
|
|
Filter: &userFilterNoGuests,
|
|
Count: ptr.To(true),
|
|
},
|
|
}
|
|
|
|
resp, err := service.Client().Users().Get(ctx, config)
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting all users")
|
|
}
|
|
|
|
iter, err := msgraphgocore.NewPageIterator[models.Userable](
|
|
resp,
|
|
service.Adapter(),
|
|
models.CreateUserCollectionResponseFromDiscriminatorValue)
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "creating users iterator")
|
|
}
|
|
|
|
var (
|
|
us = make([]models.Userable, 0)
|
|
el = errs.Local()
|
|
)
|
|
|
|
iterator := func(item models.Userable) bool {
|
|
if el.Failure() != nil {
|
|
return false
|
|
}
|
|
|
|
err := ValidateUser(item)
|
|
if err != nil {
|
|
el.AddRecoverable(ctx, graph.Wrap(ctx, err, "validating user"))
|
|
} else {
|
|
us = append(us, item)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
if err := iter.Iterate(ctx, iterator); err != nil {
|
|
return nil, graph.Wrap(ctx, err, "iterating all users")
|
|
}
|
|
|
|
return us, el.Failure()
|
|
}
|
|
|
|
// GetByID looks up the user matching the given identifier. The identifier can be either a
|
|
// canonical user id or a princpalName.
|
|
func (c Users) GetByID(ctx context.Context, identifier string) (models.Userable, error) {
|
|
var (
|
|
resp models.Userable
|
|
err error
|
|
)
|
|
|
|
resp, err = c.Stable.Client().Users().ByUserId(identifier).Get(ctx, nil)
|
|
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting user")
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
// GetIDAndName looks up the user matching the given ID, and returns
|
|
// its canonical ID and the PrincipalName as the name.
|
|
func (c Users) GetIDAndName(ctx context.Context, userID string) (string, string, error) {
|
|
u, err := c.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return ptr.Val(u.GetId()), ptr.Val(u.GetUserPrincipalName()), nil
|
|
}
|
|
|
|
// GetAllIDsAndNames retrieves all users in the tenant and returns them in an idname.Cacher
|
|
func (c Users) GetAllIDsAndNames(ctx context.Context, errs *fault.Bus) (idname.Cacher, error) {
|
|
all, err := c.GetAll(ctx, errs)
|
|
if err != nil {
|
|
return nil, clues.Wrap(err, "getting all users")
|
|
}
|
|
|
|
idToName := make(map[string]string, len(all))
|
|
|
|
for _, u := range all {
|
|
id := strings.ToLower(ptr.Val(u.GetId()))
|
|
name := strings.ToLower(ptr.Val(u.GetUserPrincipalName()))
|
|
|
|
idToName[id] = name
|
|
}
|
|
|
|
return idname.NewCache(idToName), nil
|
|
}
|
|
|
|
func appendIfErr(errs []error, err error) []error {
|
|
if err == nil {
|
|
return errs
|
|
}
|
|
|
|
return append(errs, err)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Info
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (c Users) GetInfo(ctx context.Context, userID string) (*UserInfo, error) {
|
|
var (
|
|
// Assume all services are enabled
|
|
// then filter down to only services the user has enabled
|
|
userInfo = newUserInfo()
|
|
mailFolderFound = true
|
|
)
|
|
|
|
// check whether the user is able to access their onedrive drive.
|
|
// if they cannot, we can assume they are ineligible for onedrive backups.
|
|
if _, err := c.GetDefaultDrive(ctx, userID); err != nil {
|
|
if !clues.HasLabel(err, graph.LabelsMysiteNotFound) && !clues.HasLabel(err, graph.LabelsNoSharePointLicense) {
|
|
logger.CtxErr(ctx, err).Error("getting user's default drive")
|
|
return nil, graph.Wrap(ctx, err, "getting user's default drive info")
|
|
}
|
|
|
|
logger.Ctx(ctx).Info("resource owner does not have a drive")
|
|
delete(userInfo.ServicesEnabled, path.OneDriveService)
|
|
}
|
|
|
|
// check whether the user is able to access their inbox.
|
|
// if they cannot, we can assume they are ineligible for exchange backups.
|
|
inbx, err := c.GetMailInbox(ctx, userID)
|
|
if err != nil {
|
|
if err := EvaluateMailboxError(graph.Stack(ctx, err)); err != nil {
|
|
logger.CtxErr(ctx, err).Error("getting user's mail folder")
|
|
return nil, err
|
|
}
|
|
|
|
logger.Ctx(ctx).Info("resource owner does not have a mailbox enabled")
|
|
delete(userInfo.ServicesEnabled, path.ExchangeService)
|
|
|
|
mailFolderFound = false
|
|
}
|
|
|
|
// check whether the user has accessible mailbox settings.
|
|
// if they do, aggregate them in the MailboxInfo
|
|
mi := MailboxInfo{
|
|
ErrGetMailBoxSetting: []error{},
|
|
}
|
|
|
|
if !mailFolderFound {
|
|
mi.ErrGetMailBoxSetting = append(mi.ErrGetMailBoxSetting, ErrMailBoxSettingsNotFound)
|
|
userInfo.Mailbox = mi
|
|
|
|
return userInfo, nil
|
|
}
|
|
|
|
mboxSettings, err := c.getMailboxSettings(ctx, userID)
|
|
if err != nil {
|
|
logger.CtxErr(ctx, err).Info("err getting user's mailbox settings")
|
|
|
|
if !graph.IsErrAccessDenied(err) {
|
|
return nil, graph.Wrap(ctx, err, "getting user's mailbox settings")
|
|
}
|
|
|
|
mi.ErrGetMailBoxSetting = append(mi.ErrGetMailBoxSetting, clues.New("access denied"))
|
|
} else {
|
|
mi = parseMailboxSettings(mboxSettings, mi)
|
|
}
|
|
|
|
err = c.getFirstInboxMessage(ctx, userID, ptr.Val(inbx.GetId()))
|
|
if err != nil {
|
|
if !graph.IsErrQuotaExceeded(err) {
|
|
return nil, clues.Stack(err)
|
|
}
|
|
|
|
userInfo.Mailbox.QuotaExceeded = graph.IsErrQuotaExceeded(err)
|
|
}
|
|
|
|
userInfo.Mailbox = mi
|
|
|
|
return userInfo, nil
|
|
}
|
|
|
|
// EvaluateMailboxError checks whether the provided error can be interpreted
|
|
// as "user does not have a mailbox", or whether it is some other error. If
|
|
// the former (no mailbox), returns nil, otherwise returns an error.
|
|
func EvaluateMailboxError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// must occur before MailFolderNotFound, due to overlapping cases.
|
|
if graph.IsErrUserNotFound(err) {
|
|
return clues.Stack(graph.ErrResourceOwnerNotFound, err)
|
|
}
|
|
|
|
if graph.IsErrExchangeMailFolderNotFound(err) || graph.IsErrAuthenticationError(err) {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (c Users) getMailboxSettings(
|
|
ctx context.Context,
|
|
userID string,
|
|
) (models.Userable, error) {
|
|
settings, err := users.
|
|
NewUserItemRequestBuilder(
|
|
fmt.Sprintf("https://graph.microsoft.com/v1.0/users/%s/mailboxSettings", userID),
|
|
c.Stable.Adapter(),
|
|
).
|
|
Get(ctx, nil)
|
|
if err != nil {
|
|
return nil, graph.Stack(ctx, err)
|
|
}
|
|
|
|
return settings, nil
|
|
}
|
|
|
|
func (c Users) GetMailInbox(
|
|
ctx context.Context,
|
|
userID string,
|
|
) (models.MailFolderable, error) {
|
|
inbox, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(MailInbox).
|
|
Get(ctx, nil)
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting MailFolders")
|
|
}
|
|
|
|
return inbox, nil
|
|
}
|
|
|
|
func (c Users) GetDefaultDrive(
|
|
ctx context.Context,
|
|
userID string,
|
|
) (models.Driveable, error) {
|
|
d, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
Drive().
|
|
Get(ctx, nil)
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting user's drive")
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
// TODO: This tries to determine if the user has hit their mailbox
|
|
// limit by trying to fetch an item and seeing if we get the quota
|
|
// exceeded error. Ideally(if available) we should convert this to
|
|
// pull the user's usage via an api and compare if they have used
|
|
// up their quota.
|
|
func (c Users) getFirstInboxMessage(
|
|
ctx context.Context,
|
|
userID, inboxID string,
|
|
) error {
|
|
config := &users.ItemMailFoldersItemMessagesDeltaRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMailFoldersItemMessagesDeltaRequestBuilderGetQueryParameters{
|
|
Select: idAnd(),
|
|
},
|
|
Headers: newPreferHeaders(preferPageSize(1)),
|
|
}
|
|
|
|
_, err := c.Stable.
|
|
Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(inboxID).
|
|
Messages().
|
|
Delta().
|
|
Get(ctx, config)
|
|
if err != nil {
|
|
return graph.Stack(ctx, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ValidateUser ensures the item is a Userable, and contains the necessary
|
|
// identifiers that we handle with all users.
|
|
func ValidateUser(item models.Userable) error {
|
|
if item.GetId() == nil {
|
|
return clues.New("missing ID")
|
|
}
|
|
|
|
if item.GetUserPrincipalName() == nil {
|
|
return clues.New("missing principalName")
|
|
}
|
|
|
|
return nil
|
|
}
|