diff --git a/pkg/accessreview/drivers/microsoft_365.go b/pkg/accessreview/drivers/microsoft_365.go index 7e9700d73..40e355a0d 100644 --- a/pkg/accessreview/drivers/microsoft_365.go +++ b/pkg/accessreview/drivers/microsoft_365.go @@ -28,6 +28,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" "go.probo.inc/probo/pkg/coredata" @@ -119,6 +120,17 @@ type microsoft365MembersPage struct { NextLink string `json:"@odata.nextLink"` } +type microsoft365UserRegistrationDetails struct { + ID string `json:"id"` + UserPrincipalName string `json:"userPrincipalName"` + IsMFARegistered *bool `json:"isMfaRegistered"` +} + +type microsoft365UserRegistrationDetailsPage struct { + Value []microsoft365UserRegistrationDetails `json:"value"` + NextLink string `json:"@odata.nextLink"` +} + func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { roles, err := d.listDirectoryRoles(ctx) if err != nil { @@ -147,6 +159,11 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord, return nil, fmt.Errorf("cannot list users: %w", err) } + mfaStatuses, err := d.listMFAStatuses(ctx) + if err != nil { + return nil, fmt.Errorf("cannot list MFA statuses: %w", err) + } + records := make([]AccountRecord, 0, len(users)) for _, u := range users { email := u.Mail @@ -183,7 +200,7 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord, JobTitle: u.JobTitle, Active: &active, IsAdmin: isAdmin, - MFAStatus: coredata.MFAStatusUnknown, + MFAStatus: microsoft365MFAStatus(u, mfaStatuses), AuthMethod: coredata.AccessReviewEntryAuthMethodSSO, AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: u.ID, @@ -201,6 +218,32 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord, return records, nil } +func microsoft365MFAStatus(u microsoft365User, statuses map[string]coredata.MFAStatus) coredata.MFAStatus { + if status, ok := statuses[u.ID]; ok { + return status + } + + if status, ok := statuses[strings.ToLower(u.UserPrincipalName)]; ok { + return status + } + + return coredata.MFAStatusUnknown +} + +func microsoft365RegistrationMFAStatus( + details microsoft365UserRegistrationDetails, +) coredata.MFAStatus { + if details.IsMFARegistered == nil { + return coredata.MFAStatusUnknown + } + + if *details.IsMFARegistered { + return coredata.MFAStatusEnabled + } + + return coredata.MFAStatusDisabled +} + func (d *Microsoft365Driver) listUsers(ctx context.Context) ([]microsoft365User, error) { pageURL, err := buildMicrosoft365UsersURL() if err != nil { @@ -246,6 +289,60 @@ func buildMicrosoft365UsersURL() (string, error) { return u.String(), nil } +func (d *Microsoft365Driver) listMFAStatuses(ctx context.Context) (map[string]coredata.MFAStatus, error) { + pageURL, err := buildMicrosoft365UserRegistrationDetailsURL() + if err != nil { + return nil, err + } + + statuses := make(map[string]coredata.MFAStatus) + + for range microsoft365MaxPaginationOK { + var page microsoft365UserRegistrationDetailsPage + if err := d.fetchJSON(ctx, pageURL, &page); err != nil { + return nil, err + } + + for _, details := range page.Value { + status := microsoft365RegistrationMFAStatus(details) + if details.ID != "" { + statuses[details.ID] = status + } + + if details.UserPrincipalName != "" { + statuses[strings.ToLower(details.UserPrincipalName)] = status + } + } + + if page.NextLink == "" { + return statuses, nil + } + + pageURL = page.NextLink + } + + return nil, fmt.Errorf("cannot list all microsoft 365 MFA statuses: %w", ErrPaginationLimitReached) +} + +func buildMicrosoft365UserRegistrationDetailsURL() (string, error) { + endpoint, err := url.JoinPath( + microsoft365GraphBaseURL, + "reports", + "authenticationMethods", + "userRegistrationDetails", + ) + if err != nil { + return "", fmt.Errorf("cannot build graph user registration details URL: %w", err) + } + + u, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("cannot parse graph user registration details URL: %w", err) + } + + return u.String(), nil +} + func (d *Microsoft365Driver) listDirectoryRoles(ctx context.Context) ([]microsoft365DirectoryRole, error) { endpoint, err := url.JoinPath(microsoft365GraphBaseURL, "directoryRoles") if err != nil { diff --git a/pkg/accessreview/drivers/microsoft_365_test.go b/pkg/accessreview/drivers/microsoft_365_test.go new file mode 100644 index 000000000..4192b0219 --- /dev/null +++ b/pkg/accessreview/drivers/microsoft_365_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package drivers + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestMicrosoft365DriverMFAStatus(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: microsoft365RoundTripFunc( + func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/v1.0/directoryRoles": + return microsoft365Response( + http.StatusOK, + `{"value":[{"id":"role-global","displayName":"Global Administrator"}]}`, + ), nil + case "/v1.0/directoryRoles/role-global/members": + return microsoft365Response( + http.StatusOK, + `{"value":[{"id":"user-enabled","@odata.type":"#microsoft.graph.user"}]}`, + ), nil + case "/v1.0/users": + assert.Equal(t, "userType eq 'Member'", req.URL.Query().Get("$filter")) + return microsoft365Response( + http.StatusOK, + `{"value":[{"id":"user-enabled","userPrincipalName":"enabled@example.com","mail":"enabled@example.com","displayName":"Enabled User","accountEnabled":true},{"id":"user-disabled","userPrincipalName":"disabled@example.com","mail":"disabled@example.com","displayName":"Disabled User","accountEnabled":true},{"id":"user-fallback","userPrincipalName":"fallback@example.com","mail":"fallback@example.com","displayName":"Fallback User","accountEnabled":true},{"id":"user-missing","userPrincipalName":"missing@example.com","mail":"missing@example.com","displayName":"Missing User","accountEnabled":true}]}`, + ), nil + case "/v1.0/reports/authenticationMethods/userRegistrationDetails": + assert.Empty(t, req.URL.RawQuery) + return microsoft365Response( + http.StatusOK, + `{"value":[{"id":"user-enabled","userPrincipalName":"enabled@example.com","isMfaRegistered":true},{"id":"user-disabled","userPrincipalName":"disabled@example.com","isMfaRegistered":false},{"id":"different-id","userPrincipalName":"FALLBACK@example.com","isMfaRegistered":true}]}`, + ), nil + default: + t.Fatalf("unexpected Microsoft Graph request: %s", req.URL.String()) + return nil, nil + } + }, + ), + } + + driver := NewMicrosoft365Driver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 4) + + recordsByEmail := make(map[string]AccountRecord, len(records)) + for _, record := range records { + recordsByEmail[record.Email] = record + } + + assert.Equal(t, coredata.MFAStatusEnabled, recordsByEmail["enabled@example.com"].MFAStatus) + assert.True(t, recordsByEmail["enabled@example.com"].IsAdmin) + assert.Equal(t, []string{"Global Administrator"}, recordsByEmail["enabled@example.com"].Roles) + assert.Equal(t, coredata.MFAStatusDisabled, recordsByEmail["disabled@example.com"].MFAStatus) + assert.Equal(t, coredata.MFAStatusEnabled, recordsByEmail["fallback@example.com"].MFAStatus) + assert.Equal(t, coredata.MFAStatusUnknown, recordsByEmail["missing@example.com"].MFAStatus) +} + +type microsoft365RoundTripFunc func(req *http.Request) (*http.Response, error) + +func (f microsoft365RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func microsoft365Response(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} diff --git a/pkg/connector/provider/microsoft_365.go b/pkg/connector/provider/microsoft_365.go index bc66c2ded..acb159adc 100644 --- a/pkg/connector/provider/microsoft_365.go +++ b/pkg/connector/provider/microsoft_365.go @@ -43,6 +43,7 @@ func microsoft365Registration() *Registration { "openid", "profile", "offline_access", + "https://graph.microsoft.com/AuditLog.Read.All", "https://graph.microsoft.com/User.Read.All", "https://graph.microsoft.com/Directory.Read.All", "https://graph.microsoft.com/RoleManagement.Read.Directory",