Thursday, April 9, 2026

How to Get, Remove Owner & Remove Member from Exchange Online Groups Using PowerShell

How to Get, Remove Owner & Remove Member from Exchange Online Groups Using PowerShell

As an M365 administrator, you often need to audit or clean up group ownership and membership — especially during employee offboarding or role transitions. The challenge? Microsoft 365 has three different group types, each managed by different cmdlets.

In this post, I'll share three ready-to-use PowerShell scripts that cover:

  • Get owners of a group (all three types)
  • Remove a user as an owner
  • Remove a user as a member

All scripts read group names from a CSV file, making them ideal for bulk operations.


The Three Group Types in Exchange Online

Group TypeRecipientTypeDetailsCmdlet Used
Microsoft 365 GroupGroupMailboxGet-UnifiedGroup
Distribution GroupMailUniversalDistributionGroupGet-DistributionGroup
Mail-enabled Security GroupMailUniversalSecurityGroupGet-DistributionGroup

Each type stores ownership and membership differently, which is why a single cmdlet won't work for all.


Prerequisites

Install the required modules if not already installed:

Install-Module -Name ExchangeOnlineManagement -Force -AllowClobber
Install-Module -Name Microsoft.Graph -Force -AllowClobber

Connect before running any script:

Set-MgGraphOption -DisableLoginByWAM $true   # Fix WAM broker error on Windows
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com
Note: Set-MgGraphOption -DisableLoginByWAM $true is required on some Windows environments to avoid a known MSAL authentication error.

Input: groups.csv

All three scripts read from a CSV with one column — GroupEmail:

GroupEmail
hr-team@contoso.com
all-staff@contoso.com
it-security@contoso.com

Script 1: Get Group Owners

This script detects the group type automatically and lists all current owners.

# ============================================================
# Get Group Owners from CSV
# ============================================================

Set-MgGraphOption -DisableLoginByWAM $true
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com

$CsvPath = "C:\Scripts\groups.csv"

foreach ($row in (Import-Csv $CsvPath)) {
    $GroupEmail = $row.GroupEmail

    Write-Host "`n========== Detecting Group Type: $GroupEmail ==========`n" -ForegroundColor Cyan

    # Detect group type
    $unifiedGroup = Get-UnifiedGroup      -Identity $GroupEmail -ErrorAction SilentlyContinue
    $distGroup    = Get-DistributionGroup -Identity $GroupEmail -ErrorAction SilentlyContinue

    $groupType = if ($unifiedGroup) { "M365Group" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalDistributionGroup") { "DistributionGroup" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalSecurityGroup")     { "SecurityGroup" }
                 else { "Unknown" }

    Write-Host "Detected Type: $groupType" -ForegroundColor Yellow

    # Get owners based on type
    $owners = @()

    if ($groupType -eq "M365Group") {
        $owners = Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -ResultSize Unlimited |
                  Select-Object DisplayName, PrimarySmtpAddress,
                                @{N="GroupType"; E={"M365Group"}},
                                @{N="GroupEmail"; E={$GroupEmail}}
    }
    elseif ($groupType -in "DistributionGroup", "SecurityGroup") {
        $owners = $distGroup.ManagedBy | ForEach-Object {
            $recipient = Get-Recipient $_ -ErrorAction SilentlyContinue
            [PSCustomObject]@{
                DisplayName        = $recipient.DisplayName
                PrimarySmtpAddress = $recipient.PrimarySmtpAddress
                GroupType          = $groupType
                GroupEmail         = $GroupEmail
            }
        }
    }

    if ($owners) {
        Write-Host "`nOwners found:" -ForegroundColor Green
        $owners | Format-Table DisplayName, PrimarySmtpAddress, GroupType, GroupEmail -AutoSize
    } else {
        Write-Host "No owners found or group type is Unknown." -ForegroundColor Red
    }
}

Script 2: Remove a User as Owner

This script removes a specific user from the owners list only — membership is not affected. It also includes safety checks: skips if the user is not an owner, and blocks removal if they are the only owner.

Important: Exchange Online does not allow removing the last owner of a group. You must assign another owner first.
# ============================================================
# Remove Owner from Groups (CSV)
# ============================================================

Set-MgGraphOption -DisableLoginByWAM $true
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com

$CsvPath     = "C:\Scripts\groups.csv"
$RemoveOwner = "john.doe@contoso.com"

foreach ($row in (Import-Csv $CsvPath)) {
    $GroupEmail = $row.GroupEmail

    Write-Host "`n========== Detecting Group Type: $GroupEmail ==========`n" -ForegroundColor Cyan

    $unifiedGroup = Get-UnifiedGroup      -Identity $GroupEmail -ErrorAction SilentlyContinue
    $distGroup    = Get-DistributionGroup -Identity $GroupEmail -ErrorAction SilentlyContinue

    $groupType = if ($unifiedGroup) { "M365Group" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalDistributionGroup") { "DistributionGroup" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalSecurityGroup")     { "SecurityGroup" }
                 else { "Unknown" }

    Write-Host "Detected Type: $groupType" -ForegroundColor Yellow

    # Get owners
    $owners = @()
    if ($groupType -eq "M365Group") {
        $owners = Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -ResultSize Unlimited |
                  Select-Object DisplayName, PrimarySmtpAddress,
                                @{N="GroupType"; E={"M365Group"}},
                                @{N="GroupEmail"; E={$GroupEmail}}
    }
    elseif ($groupType -in "DistributionGroup", "SecurityGroup") {
        $owners = $distGroup.ManagedBy | ForEach-Object {
            $recipient = Get-Recipient $_ -ErrorAction SilentlyContinue
            [PSCustomObject]@{
                DisplayName        = $recipient.DisplayName
                PrimarySmtpAddress = $recipient.PrimarySmtpAddress
                GroupType          = $groupType
                GroupEmail         = $GroupEmail
            }
        }
    }

    if ($owners) {
        Write-Host "`nOwners found:" -ForegroundColor Green
        $owners | Format-Table DisplayName, PrimarySmtpAddress, GroupType, GroupEmail -AutoSize
    } else {
        Write-Host "No owners found or group type is Unknown." -ForegroundColor Red
    }

    # --------------------------------------------------------
    # Remove Owner
    # --------------------------------------------------------
    Write-Host "`n========== Removing Owner: $RemoveOwner ==========`n" -ForegroundColor Cyan

    if ($groupType -eq "M365Group") {
        $currentOwners = Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -ResultSize Unlimited
        $isOwner       = $currentOwners | Where-Object { $_.PrimarySmtpAddress -eq $RemoveOwner }

        if (-not $isOwner) {
            Write-Host "$RemoveOwner is not an owner of this group. Skipping." -ForegroundColor Yellow
        }
        elseif ($currentOwners.Count -eq 1) {
            Write-Host "Cannot remove $RemoveOwner - they are the only owner. Assign another owner first." -ForegroundColor Red
        }
        else {
            Remove-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -Links $RemoveOwner -Confirm:$false
            Write-Host "Owner removed successfully from M365 Group." -ForegroundColor Green
        }
    }
    elseif ($groupType -in "DistributionGroup", "SecurityGroup") {
        $isOwner = $distGroup.ManagedBy | Where-Object { $_ -like "*$($RemoveOwner.Split('@')[0])*" }

        if (-not $isOwner) {
            Write-Host "$RemoveOwner is not an owner of this group. Skipping." -ForegroundColor Yellow
        }
        elseif ($distGroup.ManagedBy.Count -eq 1) {
            Write-Host "Cannot remove $RemoveOwner - they are the only owner. Assign another owner first." -ForegroundColor Red
        }
        else {
            Set-DistributionGroup -Identity $GroupEmail -ManagedBy @{Remove = $RemoveOwner}
            Write-Host "Owner removed successfully from $groupType." -ForegroundColor Green
        }
    }
    else {
        Write-Host "Cannot remove owner - Group type is Unknown." -ForegroundColor Red
    }
}

Script 3: Remove a User as Member

This script removes a specific user from the members list only — ownership is not affected. It checks if the user is actually a member before attempting removal, so no other members are touched.

Tip: For on-premises synced groups, Exchange Online will throw a permissions error. Those groups must be managed via Active Directory or on-prem Exchange Admin Center.
# ============================================================
# Remove Member from Groups (CSV)
# ============================================================

Set-MgGraphOption -DisableLoginByWAM $true
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com

$CsvPath      = "C:\Scripts\groups.csv"
$RemoveMember = "john.doe@contoso.com"

foreach ($row in (Import-Csv $CsvPath)) {
    $GroupEmail = $row.GroupEmail

    Write-Host "`n========== Detecting Group Type: $GroupEmail ==========`n" -ForegroundColor Cyan

    $unifiedGroup = Get-UnifiedGroup      -Identity $GroupEmail -ErrorAction SilentlyContinue
    $distGroup    = Get-DistributionGroup -Identity $GroupEmail -ErrorAction SilentlyContinue

    $groupType = if ($unifiedGroup) { "M365Group" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalDistributionGroup") { "DistributionGroup" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalSecurityGroup")     { "SecurityGroup" }
                 else { "Unknown" }

    Write-Host "Detected Type: $groupType" -ForegroundColor Yellow

    # Get owners
    $owners = @()
    if ($groupType -eq "M365Group") {
        $owners = Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -ResultSize Unlimited |
                  Select-Object DisplayName, PrimarySmtpAddress,
                                @{N="GroupType"; E={"M365Group"}},
                                @{N="GroupEmail"; E={$GroupEmail}}
    }
    elseif ($groupType -in "DistributionGroup", "SecurityGroup") {
        $owners = $distGroup.ManagedBy | ForEach-Object {
            $recipient = Get-Recipient $_ -ErrorAction SilentlyContinue
            [PSCustomObject]@{
                DisplayName        = $recipient.DisplayName
                PrimarySmtpAddress = $recipient.PrimarySmtpAddress
                GroupType          = $groupType
                GroupEmail         = $GroupEmail
            }
        }
    }

    if ($owners) {
        Write-Host "`nOwners found:" -ForegroundColor Green
        $owners | Format-Table DisplayName, PrimarySmtpAddress, GroupType, GroupEmail -AutoSize
    } else {
        Write-Host "No owners found or group type is Unknown." -ForegroundColor Red
    }

    # --------------------------------------------------------
    # Remove Member
    # --------------------------------------------------------
    Write-Host "`n========== Removing Member: $RemoveMember ==========`n" -ForegroundColor Cyan

    if ($groupType -eq "M365Group") {
        $isMember = Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Members -ResultSize Unlimited |
                    Where-Object { $_.PrimarySmtpAddress -eq $RemoveMember }

        if ($isMember) {
            try {
                Remove-UnifiedGroupLinks -Identity $GroupEmail -LinkType Members -Links $RemoveMember -Confirm:$false -ErrorAction Stop
                Write-Host "Member removed successfully from M365 Group." -ForegroundColor Green
            } catch {
                Write-Host "ERROR removing member from M365 Group: $_" -ForegroundColor Red
            }
        } else {
            Write-Host "$RemoveMember is not a member of this group. Skipping." -ForegroundColor Yellow
        }
    }
    elseif ($groupType -in "DistributionGroup", "SecurityGroup") {
        $isMember = Get-DistributionGroupMember -Identity $GroupEmail -ResultSize Unlimited |
                    Where-Object { $_.PrimarySmtpAddress -eq $RemoveMember }

        if ($isMember) {
            try {
                Remove-DistributionGroupMember -Identity $GroupEmail -Member $RemoveMember -Confirm:$false -ErrorAction Stop
                Write-Host "Member removed successfully from $groupType." -ForegroundColor Green
            } catch {
                Write-Host "ERROR removing member from $groupType : $_" -ForegroundColor Red
            }
        } else {
            Write-Host "$RemoveMember is not a member of this group. Skipping." -ForegroundColor Yellow
        }
    }
    else {
        Write-Host "Cannot remove member - Group type is Unknown." -ForegroundColor Red
    }
}

Key Points to Remember

ScenarioWhat Happens
M365 Group — Remove OwnerUses Remove-UnifiedGroupLinks -LinkType Owners. Member link is kept.
M365 Group — Remove MemberUses Remove-UnifiedGroupLinks -LinkType Members. Owner link is kept.
DG / Security Group — Remove OwnerUses Set-DistributionGroup -ManagedBy @{Remove=...}
DG / Security Group — Remove MemberUses Remove-DistributionGroupMember
Only one owner leftScript blocks removal and shows a red warning
User not in groupScript skips with a yellow message — no error thrown
On-premises synced groupMust be managed via Active Directory, not Exchange Online

Conclusion

Managing Exchange Online group ownership and membership across multiple group types doesn't have to be complex. By detecting the group type dynamically and applying the right cmdlet per type, these scripts handle all three group types reliably from a simple CSV input. The built-in safety checks — solo owner protection, membership validation, and skip-on-not-found logic — make them safe to run in bulk during offboarding or access reviews.

#PowerShell #ExchangeOnline #Microsoft365 #M365Admin #MicrosoftTeams #M365Groups #DistributionGroups #SecurityGroups #ITAdmin #CloudAdmin #Office365 #M365 #PowerShellAutomation #ExchangeAdmin

Wednesday, April 8, 2026

How to Check User Membership & Ownership Across All Exchange Online Group Types Using PowerShell

How to Check User Membership & Ownership Across All Exchange Online Group Types Using PowerShell

Introduction

As an M365 administrator, one of the recurring tasks is auditing user membership and ownership across various group types in Exchange Online. The challenge? Microsoft 365 has three distinct group types, each managed by different cmdlets — and a one-size-fits-all script simply won't work.

In this post, I'll walk you through a PowerShell script that:

  • Detects group type automatically (M365 Group, Distribution Group, or Mail-enabled Security Group)
  • Checks whether a user is an Owner, Member, Both, or None
  • Reads groups from a CSV file for bulk processing
  • Writes results to a timestamped CSV dynamically (row-by-row, not at the end)

The Three Group Types in Exchange Online

Before diving into the script, it's important to understand what we're dealing with:

Group Type RecipientTypeDetails Managed By Cmdlet
Microsoft 365 Group GroupMailbox Get-UnifiedGroup
Distribution Group MailUniversalDistributionGroup Get-DistributionGroup
Mail-enabled Security Group MailUniversalSecurityGroup Get-DistributionGroup

Each type stores ownership and membership differently, which is why a single cmdlet can't cover all scenarios.


Prerequisites

  • Exchange Online Management module installed:
    Install-Module -Name ExchangeOnlineManagement -Force -AllowClobber
    
  • Connected to Exchange Online:
    Connect-ExchangeOnline -UserPrincipalName admin@contoso.com
    

Input: groups.csv

Create a simple CSV with one column — the group email addresses you want to audit:

GroupEmail
hr-team@contoso.com
all-staff@contoso.com
it-security@contoso.com

The Script

# ─────────────────────────────────────────────
# Config
# ─────────────────────────────────────────────
$CsvPath   = "C:\Scripts\groups.csv"
$UserEmail = "john.doe@contoso.com"

# Dynamic timestamped output file
$timestamp  = Get-Date -Format "yyyyMMdd_HHmmss"
$OutputPath = "C:\Scripts\output_$timestamp.csv"

Connect-ExchangeOnline -UserPrincipalName admin@contoso.com

# ─────────────────────────────────────────────
# Clear output file if it exists from a prior run
# ─────────────────────────────────────────────
if (Test-Path $OutputPath) { Remove-Item $OutputPath }

# ─────────────────────────────────────────────
# Process each group
# ─────────────────────────────────────────────
$results = foreach ($row in (Import-Csv $CsvPath)) {
    $GroupEmail = $row.GroupEmail
    $isOwner = $isMember = $false

    # Detect group type
    $unifiedGroup = Get-UnifiedGroup      -Identity $GroupEmail -ErrorAction SilentlyContinue
    $distGroup    = Get-DistributionGroup -Identity $GroupEmail -ErrorAction SilentlyContinue

    $groupType = if ($unifiedGroup) { "M365Group" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalDistributionGroup") { "DistributionGroup" }
                 elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalSecurityGroup")     { "SecurityGroup" }
                 else { "Unknown" }

    Write-Host "[$GroupEmail] Detected Type: $groupType" -ForegroundColor Cyan

    # Check ownership & membership based on group type
    if ($groupType -eq "M365Group") {
        $isOwner  = [bool](Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners  -ResultSize Unlimited |
                           Where-Object { $_.PrimarySmtpAddress -eq $UserEmail })
        $isMember = [bool](Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Members -ResultSize Unlimited |
                           Where-Object { $_.PrimarySmtpAddress -eq $UserEmail })
    }
    elseif ($groupType -in "DistributionGroup", "SecurityGroup") {
        $isMember = [bool](Get-DistributionGroupMember -Identity $GroupEmail -ResultSize Unlimited |
                           Where-Object { $_.PrimarySmtpAddress -eq $UserEmail })
        $isOwner  = [bool]($distGroup.ManagedBy |
                           Where-Object { $_ -like "*$($UserEmail.Split('@')[0])*" })
    }

    # Determine role
    $role = switch ($true) {
        ($isOwner -and $isMember) { "Owner & Member" }
        ($isOwner)                { "Owner only"     }
        ($isMember)               { "Member only"    }
        default                   { "None"           }
    }

    # Build result object
    $record = [PSCustomObject]@{
        GroupEmail = $GroupEmail
        User       = $UserEmail
        GroupType  = $groupType
        IsOwner    = $isOwner
        IsMember   = $isMember
        Role       = $role
    }

    # Write to CSV immediately (not at the end)
    $record | Export-Csv $OutputPath -Append -NoTypeInformation
    Write-Host "  → Role: $role" -ForegroundColor Green

    $record  # Feed into $results for Format-Table
}

# ─────────────────────────────────────────────
# Console summary
# ─────────────────────────────────────────────
Write-Host "`n=== Summary ===" -ForegroundColor Yellow
$results | Format-Table -AutoSize

Write-Host "`nOutput saved to: $OutputPath" -ForegroundColor Green

Sample Output

GroupEmail               User                    GroupType          IsOwner  IsMember  Role
---------                ----                    ---------          -------  --------  ----
hr-team@contoso.com      john.doe@contoso.com    M365Group          True     True      Owner & Member
all-staff@contoso.com    john.doe@contoso.com    DistributionGroup  False    True      Member only
it-security@contoso.com  john.doe@contoso.com    SecurityGroup      False    False     None

Conclusion

Exchange Online group auditing doesn't need to be painful. By detecting group type dynamically and merging the overlapping DG/SG logic, this script stays concise while covering all three group types reliably. The dynamic CSV write and timestamped output make it production-ready for bulk audits.

Friday, April 3, 2026

How to Get Table → Security Role Mapping in Power Platform (PowerShell + Web API)

How to Get Table → Security Role Mapping in Power Platform

Power Platform  |  Dataverse  |  Security & Governance

 

When auditing your Dataverse environment, one of the most common questions is: "Which security roles have access to this table?" The Power Platform UI doesn't give you a direct answer — you'd have to open each role one by one. This post shows you how to do it properly using PowerShell and the Dataverse Web API.

 

Why This Matters

Security role misconfiguration is one of the top causes of data exposure in Dataverse-based apps. Before going live with any Power Pages or model-driven app, you should know exactly which roles can read, write, create, or delete records in every table — especially custom ones.

The default Admin Center view only shows you “what tables does this role access?” — not the reverse. For governance audits, you need the opposite: given a table, which roles touch it?

 

Prerequisites

       Windows PowerShell 5.1 (not PowerShell Core/7 — the module is incompatible)

       Microsoft.Xrm.Data.PowerShell module installed

       System Administrator or equivalent role in the target environment

 

⚠️  Run all commands in Windows PowerShell 5.1. If you open PowerShell 7 / pwsh, you will get: "module is not compatible with PowerShell Core".

 

Step 1 — Install and Connect

Open Windows PowerShell as Administrator and run:

 

Install-Module -Name Microsoft.Xrm.Data.PowerShell -Scope CurrentUser -Force -AllowClobber

Import-Module Microsoft.Xrm.Data.PowerShell

 

$conn = Get-CrmConnection -InteractiveMode

 

This opens an interactive login window. Sign in with your Dataverse admin account. After login, verify the connection:

 

$conn.ConnectedOrgFriendlyName

 

Step 2 — Full Mapping Script

The module's cmdlets don't include a direct Get-CrmEntityPrivileges function, so we use the Dataverse Web API via Invoke-RestMethod. The script below does a complete table → role lookup in four stages.

 

$tableName = "account"   # Change to your table logical name

$orgUrl    = "https://<yourorg>.crm.dynamics.com"

 

Import-Module Microsoft.Xrm.Data.PowerShell

$conn    = Get-CrmConnection -InteractiveMode

$token   = $conn.CurrentAccessToken

 

$headers = @{

    "Authorization"    = "Bearer $token"

    "OData-MaxVersion" = "4.0"

    "OData-Version"    = "4.0"

    "Accept"           = "application/json"

}

 

# STEP 1 - Get privileges for the table

$privUrl    = "$orgUrl/api/data/v9.2/privileges?`$filter=contains(name,'$tableName')&`$select=name,privilegeid"

$privileges = (Invoke-RestMethod -Uri $privUrl -Headers $headers -Method Get).value

Write-Host "Privileges found: $($privileges.Count)"

 

# STEP 2 - Get all security roles

$roles = (Invoke-RestMethod -Uri "$orgUrl/api/data/v9.2/roles?`$select=name,roleid" -Headers $headers -Method Get).value

Write-Host "Roles found: $($roles.Count)"

 

# STEP 3 - Check each role for table privileges

$results = @()

foreach ($role in $roles) {

    $detail       = Invoke-RestMethod -Uri "$orgUrl/api/data/v9.2/roles($($role.roleid))?`$expand=roleprivileges_association(`$select=name)" -Headers $headers -Method Get

    $matchedPrivs = $detail.roleprivileges_association | Where-Object { $_.name -like "*$tableName*" }

    foreach ($priv in $matchedPrivs) {

        $results += [PSCustomObject]@{

            Table     = $tableName

            Role      = $role.name

            Privilege = $priv.name

        }

    }

}

 

# STEP 4 - Display and export

$results | Sort-Object Role | Format-Table -AutoSize

$results | Export-Csv -Path ".\RoleMapping_$tableName.csv" -NoTypeInformation

Write-Host "Exported to RoleMapping_$tableName.csv"

 

Sample Output

 

Table

Role

Privilege

account

Sales Manager

prvReadaccount

account

Sales Manager

prvWriteaccount

account

System Administrator

prvReadaccount

account

System Administrator

prvCreateaccount

account

Custom Role A

prvDeleteaccount

 

Alternative — FetchXML (Faster for Large Environments)

If you have many security roles, the loop above can be slow. Use FetchXML instead for a single-query approach:

 

$fetchXml = @"

<fetch>

  <entity name="role">

    <attribute name="name" />

    <link-entity name="roleprivileges" from="roleid" to="roleid" intersect="true">

      <link-entity name="privilege" from="privilegeid" to="privilegeid">

        <attribute name="name" />

        <filter>

          <condition attribute="name" operator="like" value="%account%" />

        </filter>

      </link-entity>

    </link-entity>

  </entity>

</fetch>

"@

 

$encoded = [System.Web.HttpUtility]::UrlEncode($fetchXml)

$result  = Invoke-RestMethod -Uri "$orgUrl/api/data/v9.2/roles?fetchXml=$encoded" -Headers $headers -Method Get

$result.value | Select-Object name | Sort-Object name | Format-Table -AutoSize

 

💡  Tip: You can also paste this FetchXML directly into XrmToolBox → FetchXML Builder for a no-code alternative.

 

Troubleshooting

 

Error

Fix

Module not compatible with PowerShell Core

Switch to Windows PowerShell 5.1

CurrentAccessToken is null

Reconnect using Get-CrmConnection -InteractiveMode

401 Unauthorized on API calls

Token expired — reconnect and re-run

Empty results

Use the table's logical name, e.g. cr123_mytable not display name

Slow execution

Switch to the FetchXML approach

 

Wrapping Up

The Power Platform Admin Center gives you role-centric views, but for governance audits you often need a table-centric view. By combining the Microsoft.Xrm.Data.PowerShell module for authentication with Dataverse Web API calls, you can build a clean, exportable mapping of every security role that has privileges on any given table.

The exported CSV is also useful as documentation for ALM pipelines, change management reviews, and client handoffs — especially in enterprise projects where compliance teams need to sign off on data access controls.

 

#PowerPlatform  #Dataverse  #PowerShell  #SecurityRoles  #MicrosoftDataverse  #PowerApps  #ALM  #Governance  #Microsoft365  #LowCode  #EnterpriseArchitecture  #PowerPlatformDev  #DataverseWebAPI  #PowerPlatformAdmin

Saturday, March 28, 2026

Managing All Group Types with CRUD Operations

Managing All Group Types with CRUD Operations

Introduction

Managing groups in Microsoft 365 is a routine task for administrators and developers — but the challenge is that M365 exposes four distinct group types, each managed through different Exchange Online cmdlets. A script that works on a Unified Group will silently fail on a Distribution List.

 

In this post, we will build a single PowerShell script that auto-detects the group type and exposes a clean CRUD interface — Read, Add, Remove, and Modify — for both owners and members across all four group types:

 

       Microsoft 365 Groups (Unified Groups)

       Distribution Lists

       Dynamic Distribution Lists

       Mail-Enabled Security Groups

 

Prerequisites

No Admin Rights Required The ExchangeOnlineManagement module installs per-user with -Scope CurrentUser.

 

Before running the script, ensure you have:

 

       PowerShell 5.1 or PowerShell 7+

       An Exchange Online account with sufficient permissions to manage groups

       Internet connectivity (the script will install the module automatically)

 

Understanding the Four Group Types

 

Group Type

Use Case

Owners Stored In

Member Control

Microsoft 365 Group

Teams, SharePoint, shared mailbox

UnifiedGroupLinks

Manual

Distribution List

Email broadcast lists

ManagedBy property

Manual

Mail-Enabled Security

Email + permissions

ManagedBy property

Manual

Dynamic Distribution List

Auto-membership via OPATH filter

ManagedBy property

Automatic (filter)

 

Note on Dynamic DLs: Members are computed from an OPATH filter and cannot be added or removed manually. The script handles this gracefully with a clear warning.

 

CRUD Operations Overview

 

Operation

$Operation Value

What It Does

Read

Read

Lists all owners and members with count

Add Owner

AddOwner

Adds $TargetUPN as a group owner

Remove Owner

RemoveOwner

Removes $TargetUPN; skips if only 1 owner

Add Member

AddMember

Adds $TargetUPN as a group member

Remove Member

RemoveMember

Removes $TargetUPN from members

Modify

Modify

Updates DisplayName and/or Notes

 

Script Configuration

At the top of the script, set these four variables before running:

 

# ── Required ──────────────────────────────────────────────────────

$GroupEmail  = "group@yourdomain.com"      # Target group email address

$TargetUPN   = "user@yourdomain.com"       # User UPN for add/remove ops

$Operation   = "Read"                      # Read | AddOwner | RemoveOwner

                                            # AddMember | RemoveMember | Modify

 

# ── For Modify only ───────────────────────────────────────────────

$NewDisplayName = "New Group Name"         # Leave blank ("") to skip

$NewNotes       = "Updated notes"          # Leave blank ("") to skip

 

Full PowerShell Script

 

# ═══════════════════════════════════════════════════════════════════════════

#  M365 All Group Types - CRUD Operations Script

#  Author  : Sreekantha Reddy Udayagiri

#  Blog    : udayagirisreekanthreddy.com

#  Supports: Microsoft 365 | Distribution List |

#            Dynamic Distribution List | Mail-Enabled Security

#  Operations: Read | Add Owner/Member | Remove Owner/Member | Modify Group

# ═══════════════════════════════════════════════════════════════════════════

 

# ── Inputs ────────────────────────────────────────────────────────────────

$GroupEmail  = "group@yourdomain.com"      # Group email address

$TargetUPN   = "user@yourdomain.com"       # UPN for add/remove operations

$Operation   = "Read"                      # Read | AddOwner | RemoveOwner |

                                            # AddMember | RemoveMember | Modify

 

# ── For Modify operation only ─────────────────────────────────────────────

$NewDisplayName    = ""    # Leave blank to skip

$NewNotes          = ""    # Leave blank to skip

 

# ── Step 1: Install ExchangeOnlineManagement if not present ───────────────

$moduleName = "ExchangeOnlineManagement"

if (-not (Get-Module -ListAvailable -Name $moduleName)) {

    Write-Host "Installing $moduleName ..." -ForegroundColor Yellow

    Install-Module -Name $moduleName -Scope CurrentUser -Force -AllowClobber

    Write-Host "$moduleName installed." -ForegroundColor Green

} else {

    Write-Host "$moduleName already present." -ForegroundColor Cyan

}

 

# ── Step 2: Connect ───────────────────────────────────────────────────────

Connect-ExchangeOnline -ShowBanner:`$false

 

# ── Step 3: Auto-detect group type ───────────────────────────────────────

$groupType   = $null

$groupObject = $null

 

$groupObject = Get-UnifiedGroup -Identity $GroupEmail -ErrorAction SilentlyContinue

if ($groupObject) { $groupType = "M365" }

 

if (-not $groupType) {

    $groupObject = Get-DistributionGroup -Identity $GroupEmail -ErrorAction SilentlyContinue

    if ($groupObject) {

        $groupType = ($groupObject.GroupType -match "SecurityEnabled") ? "MailEnabledSecurity" : "DistributionList"

    }

}

 

if (-not $groupType) {

    $groupObject = Get-DynamicDistributionGroup -Identity $GroupEmail -ErrorAction SilentlyContinue

    if ($groupObject) { $groupType = "DynamicDistributionList" }

}

 

if (-not $groupType) {

    Write-Warning "Group not found: $GroupEmail"; Disconnect-ExchangeOnline -Confirm:$false; exit 1

}

 

Write-Host "`nGroup   : $($groupObject.DisplayName)" -ForegroundColor Green

Write-Host "Type    : $groupType"                    -ForegroundColor Green

 

# ── Helper: Get Owners ────────────────────────────────────────────────────

function Get-GroupOwners($email, $type, $obj) {

    if ($type -eq "M365") {

        return Get-UnifiedGroupLinks -Identity $email -LinkType Owners

    }

    return $obj.ManagedBy | ForEach-Object {

        Get-Recipient -Identity $_ -ErrorAction SilentlyContinue

    }

}

 

# ── Helper: Get Members ───────────────────────────────────────────────────

function Get-GroupMembers($email, $type) {

    switch ($type) {

        "M365"                  { return Get-UnifiedGroupLinks -Identity $email -LinkType Members }

        "DistributionList"      { return Get-DistributionGroupMember -Identity $email }

        "MailEnabledSecurity"   { return Get-DistributionGroupMember -Identity $email }

        "DynamicDistributionList" {

            Write-Warning 'Dynamic DLs use OPATH filters — members are computed dynamically.'

            return Get-DynamicDistributionGroupMember -Identity $email -ErrorAction SilentlyContinue

        }

    }

}

 

# ══════════════════════════════════════════════════════════

# ── CRUD SWITCH ───────────────────────────────────────────

# ══════════════════════════════════════════════════════════

switch ($Operation) {

 

    # ── READ ──────────────────────────────────────────────

    "Read" {

        Write-Host "`n── OWNERS ──────────────────────────" -ForegroundColor Cyan

        $owners = Get-GroupOwners $GroupEmail $groupType $groupObject

        $owners | ForEach-Object {

            Write-Host "  Owner : $($_.PrimarySmtpAddress)" -ForegroundColor White

        }

        Write-Host "  Total owners: $(($owners | Measure-Object).Count)" -ForegroundColor Yellow

 

        Write-Host "`n── MEMBERS ─────────────────────────" -ForegroundColor Cyan

        $members = Get-GroupMembers $GroupEmail $groupType

        $members | ForEach-Object {

            Write-Host "  Member: $($_.PrimarySmtpAddress)" -ForegroundColor White

        }

        Write-Host "  Total members: $(($members | Measure-Object).Count)" -ForegroundColor Yellow

    }

 

    # ── ADD OWNER ─────────────────────────────────────────

    "AddOwner" {

        switch ($groupType) {

            "M365" {

                Add-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -Links $TargetUPN

            }

            { $_ -in "DistributionList","MailEnabledSecurity" } {

                $existing = Get-GroupOwners $GroupEmail $groupType $groupObject |

                    Select-Object -ExpandProperty DistinguishedName

                $newOwner = (Get-Recipient -Identity $TargetUPN).DistinguishedName

                Set-DistributionGroup -Identity $GroupEmail -ManagedBy ($existing + $newOwner) -BypassSecurityGroupManagerCheck

            }

            "DynamicDistributionList" {

                $existing = Get-GroupOwners $GroupEmail $groupType $groupObject |

                    Select-Object -ExpandProperty DistinguishedName

                $newOwner = (Get-Recipient -Identity $TargetUPN).DistinguishedName

                Set-DynamicDistributionGroup -Identity $GroupEmail -ManagedBy ($existing + $newOwner)

            }

        }

        Write-Host "$TargetUPN added as owner." -ForegroundColor Green

    }

 

    # ── REMOVE OWNER ──────────────────────────────────────

    "RemoveOwner" {

        $owners = Get-GroupOwners $GroupEmail $groupType $groupObject

        if (($owners | Measure-Object).Count -le 1) {

            Write-Warning "Only 1 owner exists. Removal skipped to avoid orphaned group."

            break

        }

        $target = $owners | Where-Object { $_.PrimarySmtpAddress -eq $TargetUPN }

        if (-not $target) { Write-Warning "$TargetUPN is not an owner."; break }

 

        switch ($groupType) {

            "M365" {

                Remove-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -Links $TargetUPN -Confirm:`$false

            }

            { $_ -in "DistributionList","MailEnabledSecurity" } {

                $updated = $owners | Where-Object { $_.PrimarySmtpAddress -ne $TargetUPN } |

                    Select-Object -ExpandProperty DistinguishedName

                Set-DistributionGroup -Identity $GroupEmail -ManagedBy $updated -BypassSecurityGroupManagerCheck

            }

            "DynamicDistributionList" {

                $updated = $owners | Where-Object { $_.PrimarySmtpAddress -ne $TargetUPN } |

                    Select-Object -ExpandProperty DistinguishedName

                Set-DynamicDistributionGroup -Identity $GroupEmail -ManagedBy $updated

            }

        }

        Write-Host "$TargetUPN removed from owners." -ForegroundColor Green

    }

 

    # ── ADD MEMBER ────────────────────────────────────────

    "AddMember" {

        switch ($groupType) {

            "M365" {

                Add-UnifiedGroupLinks -Identity $GroupEmail -LinkType Members -Links $TargetUPN

            }

            { $_ -in "DistributionList","MailEnabledSecurity" } {

                Add-DistributionGroupMember -Identity $GroupEmail -Member $TargetUPN

            }

            "DynamicDistributionList" {

                Write-Warning 'Dynamic DLs use OPATH filters — members cannot be added manually.'

            }

        }

        Write-Host "$TargetUPN added as member." -ForegroundColor Green

    }

 

    # ── REMOVE MEMBER ─────────────────────────────────────

    "RemoveMember" {

        switch ($groupType) {

            "M365" {

                Remove-UnifiedGroupLinks -Identity $GroupEmail -LinkType Members -Links $TargetUPN -Confirm:`$false

            }

            { $_ -in "DistributionList","MailEnabledSecurity" } {

                Remove-DistributionGroupMember -Identity $GroupEmail -Member $TargetUPN -Confirm:`$false

            }

            "DynamicDistributionList" {

                Write-Warning 'Dynamic DLs use OPATH filters — members cannot be removed manually.'

            }

        }

        Write-Host "$TargetUPN removed from members." -ForegroundColor Green

    }

 

    # ── MODIFY GROUP ──────────────────────────────────────

    "Modify" {

        switch ($groupType) {

            "M365" {

                $params = @{ Identity = $GroupEmail }

                if ($NewDisplayName) { $params["DisplayName"] = $NewDisplayName }

                if ($NewNotes)       { $params["Notes"] = $NewNotes }

                Set-UnifiedGroup @params

            }

            { $_ -in "DistributionList","MailEnabledSecurity" } {

                $params = @{ Identity = $GroupEmail }

                if ($NewDisplayName) { $params["DisplayName"] = $NewDisplayName }

                if ($NewNotes)       { $params["Notes"] = $NewNotes }

                Set-DistributionGroup @params

            }

            "DynamicDistributionList" {

                $params = @{ Identity = $GroupEmail }

                if ($NewDisplayName) { $params["DisplayName"] = $NewDisplayName }

                if ($NewNotes)       { $params["Notes"] = $NewNotes }

                Set-DynamicDistributionGroup @params

            }

        }

        Write-Host "Group updated successfully." -ForegroundColor Green

    }

 

    default {

        Write-Warning "Unknown operation: $Operation"

        Write-Host "Valid values: Read | AddOwner | RemoveOwner | AddMember | RemoveMember | Modify"

    }

}

 

# ── Disconnect ────────────────────────────────────────────

Disconnect-ExchangeOnline -Confirm:`$false

Write-Host "`nDone." -ForegroundColor Green

 

How It Works — Step by Step

Step 1: Auto-Install the Module

The script checks for ExchangeOnlineManagement using Get-Module -ListAvailable and installs it with -Scope CurrentUser if absent — no admin rights needed.

Step 2: Auto-Detect Group Type

Instead of asking you to specify the group type, the script probes in order: Unified Group → Distribution Group (then checks for SecurityEnabled flag) → Dynamic Distribution Group. The first successful match sets the $groupType variable used throughout.

Step 3: CRUD Switch Block

A switch statement routes execution based on $Operation. Inside each operation, a nested switch handles the group-type-specific cmdlets so the logic remains clean and readable.

Step 4: Owner Safety Guard

For RemoveOwner, the script first counts owners. If only one owner exists, removal is skipped with a warning to prevent an orphaned group — regardless of group type.

Step 5: ManagedBy Rebuild Pattern

For Distribution Lists, Mail-Enabled Security Groups, and Dynamic DLs, owners are stored in the ManagedBy property. Adding or removing requires rebuilding the full array and writing it back with Set-DistributionGroup or Set-DynamicDistributionGroup.

 

Example Console Output

Read Operation

Group   : Engineering Team

Type    : M365

 

── OWNERS ──────────────────────────

  Owner : alice@contoso.com

  Owner : bob@contoso.com

  Total owners: 2

 

── MEMBERS ─────────────────────────

  Member: alice@contoso.com

  Member: bob@contoso.com

  Member: carol@contoso.com

  Total members: 3

 

RemoveOwner with 1 Owner Guard

Group   : Finance DL

Type    : DistributionList

 

WARNING: Only 1 owner exists. Removal skipped to avoid orphaned group.

 

Dynamic DL Member Add Warning

Group   : All Employees

Type    : DynamicDistributionList

 

WARNING: Dynamic DLs use OPATH filters — members cannot be added manually.

 

CRUD Compatibility by Group Type

 

Operation

M365 Group

Distribution List

Mail-Enabled Security

Dynamic DL

Read Owners

Read Members

⚠️ Computed

Add Owner

Remove Owner

Add Member

❌ Filter-based

Remove Member

❌ Filter-based

Modify

 

Conclusion

This script gives you a single, reusable tool to manage all four Exchange Online group types without switching cmdlets or remembering which group uses which property. Key benefits:

 

       Zero-touch module installation with -Scope CurrentUser

       Auto group type detection — no manual configuration

       Owner safety guard prevents orphaned groups

       Graceful warnings for Dynamic DL filter-based membership

       Clean CRUD switch pattern — easy to extend

 

Featured Post

How to Get, Remove Owner & Remove Member from Exchange Online Groups Using PowerShell

How to Get, Remove Owner & Remove Member from Exchange Online Groups Using PowerShell As an M365 administrator, you often need to audit ...

Popular posts