diff --git a/pkg/accessreview/drivers/microsoft_365_test.go b/pkg/accessreview/drivers/microsoft_365_test.go index 4192b0219..595625a6f 100644 --- a/pkg/accessreview/drivers/microsoft_365_test.go +++ b/pkg/accessreview/drivers/microsoft_365_test.go @@ -22,9 +22,7 @@ package drivers import ( "context" - "io" - "net/http" - "strings" + "os" "testing" "github.com/stretchr/testify/assert" @@ -32,42 +30,11 @@ import ( "go.probo.inc/probo/pkg/coredata" ) -func TestMicrosoft365DriverMFAStatus(t *testing.T) { +func TestMicrosoft365Driver(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 - } - }, - ), - } + rec := newRecorder(t, "testdata/microsoft_365", "MICROSOFT_365_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("MICROSOFT_365_TOKEN"))) driver := NewMicrosoft365Driver(client) records, err := driver.ListAccounts(context.Background()) @@ -79,24 +46,40 @@ func TestMicrosoft365DriverMFAStatus(t *testing.T) { 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) -} + // Alice: Global Administrator with MFA registered (matched by user id). + alice := recordsByEmail["alice@example.com"] + assert.Equal(t, "Alice Admin", alice.FullName) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", alice.ExternalID) + assert.Equal(t, []string{"Global Administrator"}, alice.Roles) + assert.True(t, alice.IsAdmin) + assert.Equal(t, coredata.MFAStatusEnabled, alice.MFAStatus) + assert.Equal(t, "Security Engineer", alice.JobTitle) + require.NotNil(t, alice.Active) + assert.True(t, *alice.Active) + require.NotNil(t, alice.CreatedAt) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, alice.AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, alice.AccountType) -type microsoft365RoundTripFunc func(req *http.Request) (*http.Response, error) + // Bob: User Administrator with MFA not registered. + bob := recordsByEmail["bob@example.com"] + assert.Equal(t, []string{"User Administrator"}, bob.Roles) + assert.True(t, bob.IsAdmin) + assert.Equal(t, coredata.MFAStatusDisabled, bob.MFAStatus) + require.NotNil(t, bob.Active) + assert.True(t, *bob.Active) -func (f microsoft365RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} + // Carol: no directory role (defaults to User); MFA matched by + // case-insensitive UPN fallback when the report id differs. + carol := recordsByEmail["carol@example.com"] + assert.Equal(t, []string{"User"}, carol.Roles) + assert.False(t, carol.IsAdmin) + assert.Equal(t, coredata.MFAStatusEnabled, carol.MFAStatus) -func microsoft365Response(statusCode int, body string) *http.Response { - return &http.Response{ - StatusCode: statusCode, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), - } + // Dana: inactive, absent from the MFA registration report → Unknown. + dana := recordsByEmail["dana@example.com"] + assert.Equal(t, []string{"User"}, dana.Roles) + assert.False(t, dana.IsAdmin) + assert.Equal(t, coredata.MFAStatusUnknown, dana.MFAStatus) + require.NotNil(t, dana.Active) + assert.False(t, *dana.Active) } diff --git a/pkg/accessreview/drivers/testdata/microsoft_365.yaml b/pkg/accessreview/drivers/testdata/microsoft_365.yaml new file mode 100644 index 000000000..c202bdca5 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/microsoft_365.yaml @@ -0,0 +1,156 @@ +--- +# Synthetic Microsoft Graph cassette covering the Microsoft 365 access-review +# driver: directory roles + members, member users, and authentication-method +# registration details (MFA). Authorization is stripped by the recorder. +# Fixture covers MFA enabled (by user id), MFA disabled, MFA enabled via +# case-insensitive UPN fallback, MFA unknown when absent from the report, +# admin role mapping, and an inactive account. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: graph.microsoft.com + headers: + Accept: + - application/json + url: https://graph.microsoft.com/v1.0/directoryRoles + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 175 + body: '{"value":[{"id":"00000000-0000-0000-0000-0000000000a1","displayName":"Global Administrator"},{"id":"00000000-0000-0000-0000-0000000000a2","displayName":"User Administrator"}]}' + headers: + Content-Length: + - "175" + Content-Type: + - application/json + Date: + - Tue, 28 Jul 2026 09:55:21 GMT + status: 200 OK + code: 200 + duration: 2.932209ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: graph.microsoft.com + headers: + Accept: + - application/json + url: https://graph.microsoft.com/v1.0/directoryRoles/00000000-0000-0000-0000-0000000000a1/members + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 123 + body: '{"value":[{"id":"11111111-1111-1111-1111-111111111111","@odata.type":"#microsoft.graph.user","displayName":"Alice Admin"}]}' + headers: + Content-Length: + - "123" + Content-Type: + - application/json + Date: + - Tue, 28 Jul 2026 09:55:21 GMT + status: 200 OK + code: 200 + duration: 128.916µs + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: graph.microsoft.com + headers: + Accept: + - application/json + url: https://graph.microsoft.com/v1.0/directoryRoles/00000000-0000-0000-0000-0000000000a2/members + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 122 + body: '{"value":[{"id":"22222222-2222-2222-2222-222222222222","@odata.type":"#microsoft.graph.user","displayName":"Bob Member"}]}' + headers: + Content-Length: + - "122" + Content-Type: + - application/json + Date: + - Tue, 28 Jul 2026 09:55:21 GMT + status: 200 OK + code: 200 + duration: 138.583µs + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: graph.microsoft.com + form: + $filter: + - userType eq 'Member' + $select: + - id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,jobTitle,department,createdDateTime + $top: + - "999" + headers: + Accept: + - application/json + url: https://graph.microsoft.com/v1.0/users?%24filter=userType+eq+%27Member%27&%24select=id%2CuserPrincipalName%2Cmail%2CdisplayName%2CgivenName%2Csurname%2CaccountEnabled%2CjobTitle%2Cdepartment%2CcreatedDateTime&%24top=999 + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 1172 + body: '{"value":[{"id":"11111111-1111-1111-1111-111111111111","userPrincipalName":"alice@example.com","mail":"alice@example.com","displayName":"Alice Admin","givenName":"Alice","surname":"Admin","accountEnabled":true,"jobTitle":"Security Engineer","department":"Security","createdDateTime":"2024-01-15T10:00:00Z"},{"id":"22222222-2222-2222-2222-222222222222","userPrincipalName":"bob@example.com","mail":"bob@example.com","displayName":"Bob Member","givenName":"Bob","surname":"Member","accountEnabled":true,"jobTitle":"Engineer","department":"Engineering","createdDateTime":"2024-02-20T11:00:00Z"},{"id":"33333333-3333-3333-3333-333333333333","userPrincipalName":"carol@example.com","mail":"carol@example.com","displayName":"Carol Fallback","givenName":"Carol","surname":"Fallback","accountEnabled":true,"jobTitle":"Analyst","department":"Security","createdDateTime":"2024-03-01T12:00:00Z"},{"id":"44444444-4444-4444-4444-444444444444","userPrincipalName":"dana@example.com","mail":"dana@example.com","displayName":"Dana Missing","givenName":"Dana","surname":"Missing","accountEnabled":false,"jobTitle":"Contractor","department":"Ops","createdDateTime":"2024-04-10T09:00:00Z"}]}' + headers: + Content-Length: + - "1172" + Content-Type: + - application/json + Date: + - Tue, 28 Jul 2026 09:55:21 GMT + status: 200 OK + code: 200 + duration: 124.5µs + - id: 4 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: graph.microsoft.com + headers: + Accept: + - application/json + url: https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 337 + body: '{"value":[{"id":"11111111-1111-1111-1111-111111111111","userPrincipalName":"alice@example.com","isMfaRegistered":true},{"id":"22222222-2222-2222-2222-222222222222","userPrincipalName":"bob@example.com","isMfaRegistered":false},{"id":"99999999-9999-9999-9999-999999999999","userPrincipalName":"CAROL@example.com","isMfaRegistered":true}]}' + headers: + Content-Length: + - "337" + Content-Type: + - application/json + Date: + - Tue, 28 Jul 2026 09:55:21 GMT + status: 200 OK + code: 200 + duration: 97.625µs