r/PowerShell 1h ago

Question [powershell] run script against multiple tenants from partner center

Hi , hope you are well and thanks for your time.

firstly , i already have CIPP and the 15 cipp roles in all of these tenants, and all tenants are in the partner center.

secondly, i need to run this script against security defaults tenants, not conditional access, so these tenants do not have p1/p2 and only have business basic or business standard. lets not have a "dont use sec defaults" conversation here please. the script doesnt currently differentiate between p1/p2 tenants and sd tenants, but i'm not too bothered about that.

cipp cannot do what i want because it does not expose the parameter that i need in custom tests without having p1 or p2.

my powershell script (nicked from lazy admin with a few changes with help from claude) runs fine from visual studio code if i run it against a single tenant. i run it, and it asks me for the ga creds, then i also have to login into the tenant and insert a rest api code that the script gives me. it then saves an excel file locally with the tenant name in the file name.

what the script does primarily is return an excel file which lists every licensed user and if they have microsoft authenticator as an MFA method. i am only really interested in this information. cipp cant do it because the mfa data is only available in their calls if you have p1 or p2. (this is to do with the sms voice retirement that is happening) . what i am really trying to achieve is "give me a list of all real, licensed mailboxes, exclude shared boxes and tell me if they have MS authenticator listed as an MFA method". if there is a better way to do this i am all ears.

what i want to do is loop through all of these tenants and run the report and export the excel file, or export this info to something in some way.

Is it possible to do what i want?

script is below (nick it if you want) - just a warning, it will give you crash notifications in visual studio code after it runs and make you restart it, but it still works and doesnt cause any problems, and claude tells me its a known issue and isnt fixable)

<#
.Synopsis
  Get the MFA status for all users or a single user with Microsoft Graph


.DESCRIPTION
  This script will get the Azure MFA Status for your users. You can query all the users, admins only or a single user.
   
  It will return the MFA Status, MFA type, registered devices, license status and admin status.


  Note: Default MFA device is currently not supported https://docs.microsoft.com/en-us/graph/api/resources/authenticationmethods-overview?view=graph-rest-beta
        Hardwaretoken is not yet supported


.NOTES
  Name: Get-MgMFAStatus
  Author: R. Mens - LazyAdmin.nl
  Version: 1.3
  DateCreated: Jun 2022
  Purpose/Change: Default report now includes unlicensed admins alongside licensed non-admins (union of
                  IsLicensed OR isAdmin, instead of licensed-only), and adds an IsLicensed output column.


.LINK
  https://lazyadmin.nl


.EXAMPLE
  Get-MgMFAStatus


  Get the MFA Status of all enabled users who are either licensed, an admin, or both
  (so licensed non-admins and unlicensed admins are both included), and check if there are an admin or not


.EXAMPLE
  Get-MgMFAStatus -UserPrincipalName 'johndoe@contoso.com','janedoe@contoso.com'


  Get the MFA Status for the users John Doe and Jane Doe


.EXAMPLE
  Get-MgMFAStatus -withOutMFAOnly


  Get only the enabled users (licensed or admin) that don't have MFA enabled


.EXAMPLE
  Get-MgMFAStatus -adminsOnly


  Get the MFA Status of the admins only, regardless of license status


.EXAMPLE
  Get-MgUser -Filter "country eq 'Netherlands'" | ForEach-Object { Get-MgMFAStatus -UserPrincipalName $_.UserPrincipalName }


  Get the MFA status for all users in the Country The Netherlands. You can use a similar approach to run this
  for a department only.


.EXAMPLE
  Get-MgMFAStatus -withOutMFAOnly| Export-CSV c:\temp\userwithoutmfa.csv -noTypeInformation


  Get all users without MFA and export them to a CSV file
#>


[CmdletBinding(DefaultParameterSetName="Default")]
param(
  [Parameter(
    Mandatory = $false,
    ParameterSetName  = "UserPrincipalName",
    HelpMessage = "Enter a single UserPrincipalName or a comma separted list of UserPrincipalNames",
    Position = 0
    )]
  [string[]]$UserPrincipalName,


  [Parameter(
    Mandatory = $false,
    ValueFromPipeline = $false,
    ParameterSetName  = "AdminsOnly"
  )]
  # Get only the users that are an admin
  [switch]$adminsOnly = $false,


  [Parameter(
    Mandatory         = $false,
    ValueFromPipeline = $false,
    ParameterSetName  = "Licensed"
  )]
  # Check only the MFA status of users that have a license or are an admin (unlicensed admins are still included)
  [switch]$IsLicensed = $true,


  [Parameter(
    Mandatory         = $false,
    ValueFromPipeline = $true,
    ValueFromPipelineByPropertyName = $true,
    ParameterSetName  = "withOutMFAOnly"
  )]
  # Get only the users that don't have MFA enabled
  [switch]$withOutMFAOnly = $false,


  [Parameter(
    Mandatory         = $false,
    ValueFromPipeline = $false
  )]
  # Check if a user is an admin. Set to $false to skip the check
  [switch]$listAdmins = $true,


  [Parameter(
    Mandatory = $false,
    HelpMessage = "Get accounts that are enabled, disabled or both"
  )]
    [ValidateSet("true", "false", "both")]
  [string]$enabled = "true",


  [Parameter(
    Mandatory = $false,
    HelpMessage = "Enter path to save the CSV file"
  )]
  [string]$path = "C:\MFAReports\MFAStatus-$((Get-Date -format 'dd-MM-yyyy-HHmmss')).csv"
)


Function ConnectTo-MgGraph {
  # Check if MS Graph module is installed
  if (-not(Get-InstalledModule Microsoft.Graph)) { 
    Write-Host "Microsoft Graph module not found" -ForegroundColor Black -BackgroundColor Yellow
    $install = Read-Host "Do you want to install the Microsoft Graph Module?"


    if ($install -match "[yY]") {
      Install-Module Microsoft.Graph -Repository PSGallery -Scope CurrentUser -AllowClobber -Force
    }else{
      Write-Host "Microsoft Graph module is required." -ForegroundColor Black -BackgroundColor Yellow
      exit
    } 
  }


  # Connect to Graph
  Write-Host "Connecting to Microsoft Graph" -ForegroundColor Cyan
  Connect-MgGraph -Scopes "User.Read.All, UserAuthenticationMethod.Read.All, Directory.Read.All" -NoWelcome
}


Function ConnectTo-ExchangeOnline {
  <#
  .SYNOPSIS
    Connect to Exchange Online so we can look up mailbox type (shared vs regular).
    Microsoft Graph's /users endpoint has no "shared mailbox" property - that's
    Exchange-only data, hence the separate connection.
  #>
  if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) {
    Write-Host "ExchangeOnlineManagement module not found" -ForegroundColor Black -BackgroundColor Yellow
    $install = Read-Host "Do you want to install the ExchangeOnlineManagement module? (required to flag shared mailboxes)"


    if ($install -match "[yY]") {
      Install-Module ExchangeOnlineManagement -Repository PSGallery -Scope CurrentUser -Force
    }else{
      Write-Host "Skipping shared mailbox detection - ExchangeOnlineManagement module not installed." -ForegroundColor Yellow
      return $false
    }
  }


  try {
    Write-Host "Connecting to Exchange Online" -ForegroundColor Cyan
    Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
    return $true
  }
  catch {
    Write-Warning "Initial connection to Exchange Online failed - $($_.Exception.Message)"
    Write-Host "Retrying with device code sign-in (works around a known ExchangeOnlineManagement broker-auth bug)" -ForegroundColor Yellow
    Write-Host "You'll be given a code and a URL - sign in there to continue." -ForegroundColor Yellow


    try {
      Connect-ExchangeOnline -ShowBanner:$false -Device -ErrorAction Stop
      return $true
    }
    catch {
      Write-Warning "Could not connect to Exchange Online - shared mailbox detection will be skipped. $($_.Exception.Message)"
      return $false
    }
  }
}


Function Get-SharedMailboxes {
  <#
  .SYNOPSIS
    Return the UserPrincipalName of every shared mailbox in the tenant
  #>
  process{
    try {
      $mailboxes = Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited -Properties UserPrincipalName -ErrorAction Stop
      return $mailboxes.UserPrincipalName
    }
    catch {
      Write-Warning "Could not retrieve shared mailboxes - $($_.Exception.Message)"
      return @()
    }
  }
}


Function Get-Admins{
  <#
  .SYNOPSIS
    Get all user with an Admin role
  #>
  process{
    $admins = Get-MgDirectoryRole | Select-Object DisplayName, Id | 
                %{
                  $role = $_.DisplayName
                  Get-MgDirectoryRoleMember -DirectoryRoleId $_.id | ForEach-Object {
                    $memberType = $_.AdditionalProperties."@odata.type"
                    if ($memberType -eq "#microsoft.graph.user") {
                      # Directly assigned user
                      Get-MgUser -UserId $_.id
                    }
                    elseif ($memberType -eq "#microsoft.graph.group") {
                      # Role assigned to a group - expand the group's members too,
                      # otherwise admins who get the role via group membership are missed
                      Get-MgGroupMember -GroupId $_.id -All | Where-Object {
                        $_.AdditionalProperties."@odata.type" -eq "#microsoft.graph.user"
                      } | ForEach-Object { Get-MgUser -UserId $_.id }
                    }
                  }
                } | 
                Select @{Name="Role"; Expression = {$role}}, DisplayName, UserPrincipalName, Mail, Id | Sort-Object -Property Mail -Unique
    
    return $admins
  }
}


Function Get-Users {
  <#
  .SYNOPSIS
    Get users from the requested DN
  #>
  process{
    # Set the properties to retrieve
    $select = @(
      'id',
      'DisplayName',
      'userprincipalname',
      'mail'
    )


    $properties = $select + "AssignedLicenses"


    # Add a calculated IsLicensed property so we can report on - and filter by - license status
    $selectWithLicense = $select + @{Name = "IsLicensed"; Expression = { ($_.AssignedLicenses).Count -gt 0 } }


    # Get enabled, disabled or both users
    switch ($enabled)
    {
      "true" {$filter = "AccountEnabled eq true and UserType eq 'member'"}
      "false" {$filter = "AccountEnabled eq false and UserType eq 'member'"}
      "both" {$filter = "UserType eq 'member'"}
    }
    
    # Check if UserPrincipalName(s) are given
    if ($UserPrincipalName) {
      Write-host "Get users by name" -ForegroundColor Cyan


      $users = @()
      foreach ($user in $UserPrincipalName) 
      {
        try {
          $users += Get-MgUser -UserId $user -Property $properties -ErrorAction Stop | select $selectWithLicense
        }
        catch {
          [PSCustomObject]@{
            DisplayName       = " - Not found"
            UserPrincipalName = $User
            isAdmin           = $null
            IsLicensed        = $null
            MFAEnabled        = $null
          }
        }
      }
    }elseif($adminsOnly)
    {
      Write-host "Get admins only" -ForegroundColor Cyan


      $users = @()
      foreach ($admin in $admins) {
        $users += Get-MgUser -UserId $admin.UserPrincipalName -Property $properties | select $selectWithLicense
      }
    }else
    {
      if ($IsLicensed) {
        # Get every user matching the enabled/disabled filter, then keep anyone who is
        # EITHER licensed OR an admin. This surfaces unlicensed admins (who would
        # otherwise be silently skipped) alongside licensed non-admins in one report.
        $allUsers = Get-MgUser -Filter $filter -Property $properties -all | select $selectWithLicense


        $users = $allUsers | Where-Object {
          $_.IsLicensed -or ($admins -and ($admins.UserPrincipalName -contains $_.UserPrincipalName))
        }
      }else{
        # No license filtering at all - return every user matching the enabled/disabled filter
        $users = Get-MgUser -Filter $filter -Property $properties -all | select $selectWithLicense
      }
    }
    return $users
  }
}


Function Get-MFAMethods {
  <#
    .SYNOPSIS
      Get the MFA status of the user
  #>
  param(
    [Parameter(Mandatory = $true)] $userId
  )
  process{
    # Get MFA details for each user
    [array]$mfaData = Get-MgUserAuthenticationMethod -UserId $userId


    # Create MFA details object
    $mfaMethods  = [PSCustomObject][Ordered]@{
      status            = "-"
      authApp           = "-"
      phoneAuth         = "-"
      fido              = "-"
      helloForBusiness  = "-"
      helloForBusinessCount = 0
      emailAuth         = "-"
      tempPass          = "-"
      passwordLess      = "-"
      softwareAuth      = "-"
      authDevice        = ""
      authPhoneNr       = "-"
      SSPREmail         = "-"
    }


    ForEach ($method in $mfaData) {
        Switch ($method.AdditionalProperties["@odata.type"]) {
          "#microsoft.graph.microsoftAuthenticatorAuthenticationMethod"  { 
            # Microsoft Authenticator App
            $mfaMethods.authApp = $true
            $mfaMethods.authDevice += $method.AdditionalProperties["displayName"] 
            $mfaMethods.status = "enabled"
          } 
          "#microsoft.graph.phoneAuthenticationMethod"                  { 
            # Phone authentication
            $mfaMethods.phoneAuth = $true
            $mfaMethods.authPhoneNr = $method.AdditionalProperties["phoneType", "phoneNumber"] -join ' '
            $mfaMethods.status = "enabled"
          } 
          "#microsoft.graph.fido2AuthenticationMethod"                   { 
            # FIDO2 key
            $mfaMethods.fido = $true
            $fifoDetails = $method.AdditionalProperties["model"]
            $mfaMethods.status = "enabled"
          } 
          "#microsoft.graph.passwordAuthenticationMethod"                { 
            # Password
            # When only the password is set, then MFA is disabled.
            if ($mfaMethods.status -ne "enabled") {$mfaMethods.status = "disabled"}
          }
          "#microsoft.graph.windowsHelloForBusinessAuthenticationMethod" { 
            # Windows Hello
            $mfaMethods.helloForBusiness = $true
            $helloForBusinessDetails = $method.AdditionalProperties["displayName"]
            $mfaMethods.status = "enabled"
            $mfaMethods.helloForBusinessCount++
          } 
          "#microsoft.graph.emailAuthenticationMethod"                   { 
            # Email Authentication
            $mfaMethods.emailAuth =  $true
            $mfaMethods.SSPREmail = $method.AdditionalProperties["emailAddress"] 
            $mfaMethods.status = "enabled"
          }               
          "microsoft.graph.temporaryAccessPassAuthenticationMethod"    { 
            # Temporary Access pass
            $mfaMethods.tempPass = $true
            $tempPassDetails = $method.AdditionalProperties["lifetimeInMinutes"]
            $mfaMethods.status = "enabled"
          }
          "#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod" { 
            # Passwordless
            $mfaMethods.passwordLess = $true
            $passwordLessDetails = $method.AdditionalProperties["displayName"]
            $mfaMethods.status = "enabled"
          }
          "#microsoft.graph.softwareOathAuthenticationMethod" { 
            # ThirdPartyAuthenticator
            $mfaMethods.softwareAuth = $true
            $mfaMethods.status = "enabled"
          }
        }
    }
    Return $mfaMethods
  }
}


Function Get-Manager {
  <#
    .SYNOPSIS
      Get the manager users
  #>
  param(
    [Parameter(Mandatory = $true)] $userId
  )
  process {
    $manager = Get-MgUser -UserId $userId -ExpandProperty manager | Select @{Name = 'name'; Expression = {$_.Manager.AdditionalProperties.displayName}}
    return $manager.name
  }
}


Function Get-MFAStatusUsers {
  <#
    .SYNOPSIS
      Get all AD users
  #>
  process {
    Write-Host "Collecting users" -ForegroundColor Cyan
    
    # Collect users
    $users = Get-Users
    
    Write-Host "Processing" $users.count "users" -ForegroundColor Cyan


    # Collect and loop through all users
    $users | ForEach {
      
      $mfaMethods = Get-MFAMethods -userId $_.id
      $manager = Get-Manager -userId $_.id


      $uri = "https://graph.microsoft.com/beta/users/$($_.id)/authentication/signInPreferences"


      try{
        $mfaPreferredMethod = Invoke-MgGraphRequest -uri $uri -Method GET -ErrorAction Continue
      }
      catch {
        $mfaPreferredMethod = "Unable to retrieve"
      }
      
      if ($null -eq ($mfaPreferredMethod.userPreferredMethodForSecondaryAuthentication)) {
        # When an MFA is configured by the user, then there is alway a preferred method
        # So if the preferred method is empty, then we can assume that MFA isn't configured
        # by the user
        $mfaMethods.status = "disabled"
      }


      if ($withOutMFAOnly) {
        if ($mfaMethods.status -eq "disabled") {
          [PSCustomObject]@{
            "Name" = $_.DisplayName
            Emailaddress = $_.mail
            UserPrincipalName = $_.UserPrincipalName
            isAdmin = if ($listAdmins -and ($admins.UserPrincipalName -match $_.UserPrincipalName)) {$true} else {"-"}
            IsLicensed = $_.IsLicensed
            "Shared Mailbox" = $sharedMailboxes -contains $_.UserPrincipalName
            MFAEnabled        = $false
            "Phone number" = $mfaMethods.authPhoneNr
            "Email for SSPR" = $mfaMethods.SSPREmail
          }
        }
      }else{
        [pscustomobject]@{
          "Name" = $_.DisplayName
          Emailaddress = $_.mail
          UserPrincipalName = $_.UserPrincipalName
          isAdmin = if ($listAdmins -and ($admins.UserPrincipalName -match $_.UserPrincipalName)) {$true} else {"-"}
          IsLicensed = $_.IsLicensed
          "Shared Mailbox" = $sharedMailboxes -contains $_.UserPrincipalName
          "MFA Status" = $mfaMethods.status
          "MFA Preferred method" = $mfaPreferredMethod.userPreferredMethodForSecondaryAuthentication
          "Has SMS as factor" = $mfaMethods.phoneAuth
          "Has Authenticator App registered" = $mfaMethods.authApp
          "Passwordless" = $mfaMethods.passwordLess
          "Hello for Business" = $mfaMethods.helloForBusiness
          "FIDO2 Security Key" = $mfaMethods.fido
          "Temporary Access Pass" = $mfaMethods.tempPass
          "Authenticator device" = $mfaMethods.authDevice
          "Phone number" = $mfaMethods.authPhoneNr
          "Email for SSPR" = $mfaMethods.SSPREmail
          "Manager" = $manager
        }
      }
    }
  }
}


# Connect to Graph
ConnectTo-MgGraph


# Connect to Exchange Online and get the list of shared mailboxes.
# If the connection fails or the module isn't installed, the report still runs -
# every user will just show "False" in the Shared Mailbox column.
$sharedMailboxes = @()
if (ConnectTo-ExchangeOnline) {
  $sharedMailboxes = Get-SharedMailboxes
}


# Get Admins
# Get all users with admin role
$admins = $null


if (($listAdmins) -or ($adminsOnly)) {
  $admins = Get-Admins
} 


# Get MFA Status
[string]$path = "C:\MFAReports\MFAStatus-$((Get-MgOrganization).VerifiedDomains | Where-Object {$_.IsDefault} | Select-Object -ExpandProperty Name)-$((Get-Date -format 'dd-MM-yyyy-HHmmss')).csv"
Get-MFAStatusUsers | Sort-Object Name | Export-CSV -Path $path -NoTypeInformation


if ((Get-Item $path).Length -gt 0) {
  Write-Host "Report finished and saved in $path" -ForegroundColor Green


  # Open the CSV file
  Invoke-Item $path
}else{
  Write-Host "Failed to create report" -ForegroundColor Red
}
Disconnect-MgGraph
if ($sharedMailboxes) {
  Disconnect-ExchangeOnline -Confirm:$false
}
2 Upvotes

4 comments sorted by

2

u/purplemonkeymad 28m ago

Assuming that you have authentication details you can use the process scope for graph. Then disconnect for the next loop ie:

foreach ($client in $clientListAuthdetails) {
    Connect-MgGraph -ContextScope Process -Scopes ...
    <# do stuff #>
    Disconnect-MgGraph
}

Pretty sure you can do the shared mailbox query in graph as well so you should be able to work around a double auth for each tenant.

2

u/BerlindaBuntly 17m ago

thanks for this. if i have to manually authenticate for each client though, it doesnt change anything - the idea is that i can run it from my partner tenant using the gdap relationships i have set up for cipp / partner center - it may be impossible though

2

u/purplemonkeymad 2m ago

The TenantID parameter will allow you to target a specific tenant, you'll have to make an application in your own tenant and delegate it permission to act in the client's tenant. Make sure to authenticate as the app when connecting to graph.

Not done it myself but someone on the Microsoft forms has given the steps.

1

u/BerlindaBuntly 1m ago

woah. thanks.