DevOops

Code, Bugs, and Coffee Mugs

Generating a Comprehensive Azure Entra ID User Report with Microsoft Graph API

Tags = [ go, api, azure ]

Introduction In Governance, Risk, and Compliance (GRC), having a detailed overview of user accounts in Azure Entra ID is crucial. This blog post will guide you through generating a user report using Microsoft Graph API in Go. The report includes essential details such as user authentication methods, role assignments, and password policies, making it useful for security assessments and compliance audits.

Prerequisites

Before we begin, ensure you have:

Step 1: Creating Graph Client

The first step is creating Graph API client:

creds, err := azidentity.NewClientSecretCredential(
	tenant.TenantId,
	tenant.ClientId,
	tenant.Secret,
	nil,
)
if err != nil {
	log.Fatalf("Error creating credentials for tenant %s: %v", tenant.TenantId, err)
}

client, err := msgraphsdk.NewGraphServiceClientWithCredentials(creds, []string{"https://graph.microsoft.com/.default"})
if err != nil {
	log.Fatalf("Error creating client for tenant %s: %v", tenant.TenantId, err)
}

Step 2: Fetch All Users

After that we need to retrieve user details from Azure Entra ID using the Graph API. We will request specific fields and expand the MemberOf property to identify group memberships. In order to get all the users we need to ask additional pages from API using GetOdataNextLink:

Note: The MemberOf property in Microsoft Graph API only provides direct group memberships for a user. If you need to retrieve nested group memberships, you will have to perform additional queries to resolve group >hierarchies.

userProps := []string{
	"Id",
	"Mail",
	"DisplayName",
	"UserType",
	"AccountEnabled",
	"SignInActivity",
	"PasswordPolicies",
	"LastPasswordChangeDateTime",
	"MemberOf",
	"AssignedLicenses",
	"JobTitle",
	"Department",
	"CreatedDateTime",
	"UserPrincipalName",
	"OnPremisesSyncEnabled",
}

filter := &users.UsersRequestBuilderGetRequestConfiguration{
	QueryParameters: &users.UsersRequestBuilderGetQueryParameters{
		Select: userProps,
		Expand: []string{"MemberOf"},
	},
}

usersResp, err := client.Users().Get(ctx, filter)
if err != nil {
	log.Fatalf("error getting users: %v", err)
}

users := usersResp.GetValue()
for {
	nextPageUrl := usersResp.GetOdataNextLink()
	if nextPageUrl != nil {
		usersResp, err = client.Users().WithUrl(*nextPageUrl).Get(ctx, filter)
		if err != nil {
			log.Printf("error getting users: %v", err)
			continue
		}
		users = append(users, usersResp.GetValue()...)
	} else {
		break
	}
}

return users

Step 3: Retrieve User Roles

Next, we need to get role assignments for each user.

roleReps, err := client.DirectoryRoles().Get(ctx, nil)
if err != nil {
	log.Fatalf("error getting roles: %v", err)
}

roles := roleReps.GetValue()
for {
	nextPageUrl := roleReps.GetOdataNextLink()
	if nextPageUrl != nil {
		rolesReps, err = client.DirectoryRoles().WithUrl(*nextPageUrl).Get(ctx, nil)
		if err != nil {
			log.Printf("error getting roles: %v", err)
			continue
		}
		roles = append(roles, roleReps.GetValue()...)
	} else {
		break
	}
}

return roles

Step 4: Fetch Authentication Reports

To enhance the report, we retrieve authentication methods and MFA details for all users.

authResp, err := client.Reports().AuthenticationMethods().UserRegistrationDetails().Get(ctx, nil)
if err != nil {
	log.Fatalf("error getting auth reports: %v", err)
}

authReports := authResp.GetValue()
for {
	nextPageUrl := authResp.GetOdataNextLink()
	if nextPageUrl != nil {
		authResp, err = client.Reports().AuthenticationMethods().UserRegistrationDetails().WithUrl(*nextPageUrl).Get(ctx, nil)
		if err != nil {
			log.Printf("error getting auth reports: %v", err)
			continue
		}
		authReports = append(authReports, authResp.GetValue()...)
	} else {
		break
	}
}

return authReports

Step 5: Assemble the Report

Now, we compile the retrieved data into a structured report. Skipping Guest users:

Why Skip Guest Users? In many compliance and security audits, Guest accounts (external users invited to the directory) are often excluded because they typically have limited permissions and do not require the same level of scrutiny as >internal users. However, if your organization uses Guest accounts extensively for external collaboration, you might want to include them in the report and analyze their permissions separately.

for _, userData := range users {
	userType := emptyIfNull(userData.GetUserType())
	if userType == "Guest" {
		continue
	}

	record := []string{
		*userData.GetUserPrincipalName(),
		*userData.GetDisplayName(),
	}

	user := findUser(usersAuthReport, userData.GetId())
	if user != nil {
		authReport := []string{
			strconv.FormatBool(*user.GetIsAdmin()),
			strconv.FormatBool(*user.GetIsMfaCapable()),
			user.GetDefaultMfaMethod().String(),
			strings.Join(user.GetMethodsRegistered(), "\n"),
			strconv.FormatBool(*user.GetIsMfaRegistered()),
			strconv.FormatBool(*user.GetIsPasswordlessCapable()),
			strconv.FormatBool(*user.GetIsSsprCapable()),
			strconv.FormatBool(*user.GetIsSsprEnabled()),
			strconv.FormatBool(*user.GetIsSsprRegistered()),
			strconv.FormatBool(*user.GetIsSystemPreferredAuthenticationMethodEnabled()),
			user.GetLastUpdatedDateTime().String(),
		}
		record = append(record, authReport...)
	} else {
		for i := 0; i < 11; i++ {
			record = append(record, "")
		}
	}

	record = append(record, emptyIfNull(userData.GetMail()))
	record = append(record, strconv.FormatBool(*userData.GetAccountEnabled()))

	record = append(record, userType)

	record = append(record, emptyIfNull(userData.GetJobTitle()))
	record = append(record, emptyIfNull(userData.GetDepartment()))
	record = append(record, emptyTimeIfNull(userData.GetCreatedDateTime()))
	record = append(record, emptyTimeIfNull(userData.GetLastPasswordChangeDateTime()))
	record = append(record, emptyIfNull(userData.GetPasswordPolicies()))

	if userData.GetSignInActivity() != nil {
		record = append(record, emptyTimeIfNull(userData.GetSignInActivity().GetLastSignInDateTime()))
		record = append(record, emptyTimeIfNull(userData.GetSignInActivity().GetLastNonInteractiveSignInDateTime()))
	} else {
		record = append(record, "")
		record = append(record, "")
	}

	record = append(record, strings.Join(findRoles(roles, userData.GetMemberOf()), "\n"))

	if mailbox, err := client.Users().ByUserId(*userData.GetId()).MailboxSettings().Get(ctx, nil); err == nil {
		record = append(record, mailbox.GetUserPurpose().String())
	} else {
		record = append(record, "")
	}

	if userData.GetOnPremisesSyncEnabled() != nil {
		record = append(record, strconv.FormatBool(*userData.GetOnPremisesSyncEnabled()))
	} else {
		record = append(record, strconv.FormatBool(false))
	}

	records = append(records, record)
}

return records

Step 6: Generate graphs from the gathered data using Apache Superset:

Report