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 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 a...

Popular posts