Give a user the same Microsoft 365 and security groups as a colleague
Build a workflow action that adds a user to every group a colleague is in, so MSPs and IT teams can handle "same access as" requests for new starters and role changes by asking SuperIT.
| Runs in | Cloud sandbox (PowerShell) |
| Uses | Microsoft Graph credential |
| Suggested approval | Require approval from superit:admins for every run |
| Category | Identity and access |
Test it with SuperIT before you rely on it
This is an example, not a ready-made action, and its script hasn't been tested end to end in a SuperIT tenant. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, adjust it together, then test it on a test account or device. Test in the action editor runs the script for real.
Why use an action instead of asking SuperIT
SuperIT reads Microsoft 365 through your Microsoft 365 connection, but that connection is read-only, so SuperIT can't change anything in the tenant itself. Adding a user to groups is a write, which only an action with a delegated Microsoft Graph credential can make. This action makes "same access as" a reviewed bulk change: by default it only previews the groups it would add, and it changes nothing until someone runs it again with apply_changes on.
To compare two users' groups before you copy them, just ask SuperIT in chat.
When to use it
- "Give Alex the same access as Jane."
- "New starter Priya needs everything Tom in Finance has."
- "Copy Sam's groups to his replacement, [email protected]."
What the action does
- Checks the inputs and that both users exist.
- Lists the groups the source user is a direct member of.
- Skips dynamic groups, distribution lists and mail-enabled security groups, which Microsoft Graph can't change. It also skips groups that can be assigned Microsoft Entra admin roles and any group on the script's
$protectedGroupslist, so copying from an IT admin doesn't hand over privileged access. - Unless
apply_changesis on, stops there and prints PREVIEW: NOTHING CHANGED with the groups it would add. - With
apply_changeson, adds the target user to every remaining group they aren't already in. If one group fails, it carries on. - Prints the groups added (or that would be added), already held, skipped and failed.
What it doesn't do: it doesn't remove the target user from groups the source isn't in, copy licences, admin roles, mailbox or SharePoint permissions given outside groups, or distribution lists.
Before you begin
- A Microsoft Graph credential for user administration, named
M365 Graph, with the scopesGroupMember.ReadWrite.All User.Read.All. The admin account that signed in needs Groups Administrator or User Administrator. Use this credential only for user-administration actions, and give security, reporting and mail actions their own credentials. See Automate Microsoft 365 with workflows. - Your protected groups. Edit
$protectedGroupsat the top of the script to list every group that grants privileged access, such as groups excluded from Conditional Access policies, admin VPN groups and groups with Azure role assignments. - An authorization policy with one rule: Specific groups
superit:engineers,superit:admins→ Require approval fromsuperit:admins. Copying access in bulk can grant more than intended, so the approver should read the preview before approving the run that applies it. Don't add an Anyone rule: the script doesn't receive the requester's identity, so an end user could copy anyone's access to themselves. - The Engineer or Admin role to build the action in Workflows → Actions. Only admins can create actions in chat.
Build it with SuperIT
Create a workflow action called "Copy Microsoft 365 groups from another user".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Graph.
Inputs:
- source_upn (email, required) - the colleague to copy from
- target_upn (email, required) - the user to add
- apply_changes (boolean, optional) - only add groups when this is "true"; otherwise preview
It should list the source's direct groups, skip dynamic and mail-enabled
non-Microsoft 365 groups, role-assignable groups and a list of protected groups in
the script, and print the groups it would add. Only when apply_changes is "true",
add the target to them, continue past failures, and print added / already held /
skipped / failed lists.
Authorization policy: engineers and admins, every run approved by superit:admins.
SuperIT repeats the brief back, drafts the script, checks it, and creates the action as a draft. Review the script in Workflows → Actions, test it, then publish it.
Or build it yourself
| Name | Type | Required | Example |
|---|---|---|---|
source_upn |
Yes | [email protected] |
|
target_upn |
Yes | [email protected] |
|
apply_changes |
boolean | No | off |
# Goal: read the groups source_upn is directly in and list the ones target_upn would join. Only when
# apply_changes is on, add target_upn to them; print the groups added, already held, skipped and failed.
$token = $env:CREDENTIAL_M365_GRAPH_TOKEN
$headers = @{ Authorization = "Bearer $token" }
$baseUrl = 'https://graph.microsoft.com/v1.0'
# Groups that grant privileged access, such as Conditional Access exclusions or admin VPN. List yours.
$protectedGroups = @('CA - Excluded from MFA', 'VPN Admins')
function Invoke-GroupMembershipCopy {
param([string]$SourceUpn, [string]$TargetUpn, [string]$ApplyChanges)
$SourceUpn = $SourceUpn.Trim()
$TargetUpn = $TargetUpn.Trim()
# Anything but an explicit 'true' previews, so a blank input never changes access.
$apply = $ApplyChanges -eq 'true'
Assert-Inputs -SourceUpn $SourceUpn -TargetUpn $TargetUpn
$target = Get-TargetUser -Upn $TargetUpn
$sourceGroups = @(Get-DirectGroups -Upn $SourceUpn)
$heldGroupIds = @(Get-DirectGroups -Upn $TargetUpn | ForEach-Object id)
$outcome = Copy-GroupMemberships -Groups $sourceGroups -TargetId $target.id -HeldGroupIds $heldGroupIds -Apply $apply
Write-CopySummary -SourceUpn $SourceUpn -TargetUpn $TargetUpn -Target $target -Outcome $outcome -Apply $apply
}
function Assert-Inputs {
param([string]$SourceUpn, [string]$TargetUpn)
if (-not $token) { throw "Link the M365 Graph credential to this action." }
foreach ($u in $SourceUpn, $TargetUpn) {
if ($u -notmatch '^[^@\s]+@[^@\s]+$') { throw "'$u' is not a sign-in address." }
}
if ($SourceUpn -eq $TargetUpn) { throw "source_upn and target_upn must be different users." }
}
function Get-TargetUser {
param([string]$Upn)
try {
Invoke-RestMethod -Uri "$baseUrl/users/$([uri]::EscapeDataString($Upn))?`$select=id,displayName" -Headers $headers
} catch {
Write-Error "Failed to find user '$Upn': $_"
exit 1
}
}
function Get-DirectGroups {
param([string]$Upn)
$select = 'id,displayName,groupTypes,mailEnabled,isAssignableToRole'
$uri = "$baseUrl/users/$([uri]::EscapeDataString($Upn))/memberOf?`$select=$select"
try {
while ($uri) {
$page = Invoke-RestMethod -Uri $uri -Headers $headers
# memberOf also returns directory roles and administrative units.
$page.value | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' }
$uri = $page.'@odata.nextLink'
}
} catch {
Write-Error "Failed to list groups for '$Upn': $_"
exit 1
}
}
function Copy-GroupMemberships {
param([object[]]$Groups, [string]$TargetId, [string[]]$HeldGroupIds, [bool]$Apply)
$outcome = @{ added = @(); would_add = @(); already = @(); skipped = @(); failed = @() }
foreach ($g in $Groups) {
if ($HeldGroupIds -contains $g.id) { $outcome.already += $g.displayName; continue }
$skipReason = Get-SkipReason -Group $g
if ($skipReason) { $outcome.skipped += "$($g.displayName) ($skipReason)"; continue }
if (-not $Apply) { $outcome.would_add += $g.displayName; continue }
try {
Add-GroupMember -GroupId $g.id -MemberId $TargetId
$outcome.added += $g.displayName
} catch {
$outcome.failed += "$($g.displayName): $($_.ErrorDetails.Message ?? $_.Exception.Message)"
}
}
$outcome
}
function Get-SkipReason {
param([object]$Group)
if ($protectedGroups -contains $Group.displayName) { return 'protected, add by hand if needed' }
if ($Group.isAssignableToRole) { return 'can hold admin roles, add by hand if needed' }
# Graph can't write members of dynamic groups, or of mail-enabled groups other than Microsoft 365 groups.
if ($Group.groupTypes -contains 'DynamicMembership') { return 'dynamic' }
if ($Group.mailEnabled -and $Group.groupTypes -notcontains 'Unified') { return 'mail-enabled, use Exchange' }
}
function Add-GroupMember {
param([string]$GroupId, [string]$MemberId)
$ref = @{ '@odata.id' = "$baseUrl/directoryObjects/$MemberId" } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$baseUrl/groups/$GroupId/members/`$ref" -Headers $headers `
-ContentType 'application/json' -Body $ref | Out-Null
}
function Write-CopySummary {
param([string]$SourceUpn, [string]$TargetUpn, [object]$Target, [hashtable]$Outcome, [bool]$Apply)
Write-Output "=== $(if ($Apply) { 'GROUPS COPIED' } else { 'PREVIEW: NOTHING CHANGED' }) ==="
Write-Output "Source : $SourceUpn"
Write-Output "Target : $($Target.displayName) ($TargetUpn)"
if ($Apply) {
Write-Output "Added ($($Outcome.added.Count)) : $($Outcome.added -join ', ')"
} else {
Write-Output "Would add ($($Outcome.would_add.Count)) : $($Outcome.would_add -join ', ')"
}
Write-Output "Already a member ($($Outcome.already.Count)) : $($Outcome.already -join ', ')"
Write-Output "Skipped ($($Outcome.skipped.Count)) : $($Outcome.skipped -join ', ')"
if ($Outcome.failed) { Write-Output "Failed ($($Outcome.failed.Count)) : $($Outcome.failed -join '; ')" }
# $script: so the runner's wrapper sees it; a plain assignment stays local to this function.
$script:result = $Outcome + @{ applied = $Apply }
}
Invoke-GroupMembershipCopy -SourceUpn $ctx.inputs.source_upn -TargetUpn $ctx.inputs.target_upn `
-ApplyChanges $ctx.inputs.apply_changes
Test it
Test runs for real
Test skips authorization and approval, uses the real M365 Graph credential, and really adds the target user to groups. Use two test users, so you don't hand real access to the wrong person.
The script is untested, so treat your first test runs as debugging, and don't publish the action until a test run does exactly what you expect.
- Put a test user in two or three test groups, including one on
$protectedGroups. - On the action, select Test. Enter that user as
source_upnand a second test user astarget_upn, and leaveapply_changesoff. - Read the This is not a dry run. warning, tick "I understand this is a real execution that may change real systems." and select Start test run.
- Check Stdout starts with PREVIEW: NOTHING CHANGED, lists the groups it would add, and lists the protected group as skipped. Check the second test user's groups haven't changed.
- Test again with
apply_changeson, and check the lists in Stdout.
Use it
Publish the action. Engineers ask SuperIT, for example "Preview giving Alex the same groups as Jane". The preview run waits for an admin to approve it like any other run; once it finishes, the engineer and the approver read the list. If it's right, the engineer asks again with apply_changes on, and the approver checks the inputs match the preview before approving.
For MSPs, publish to managed teams from the Publish dialog; an engineer or admin in each team authorizes its own M365 Graph credential.
Variations
- Replace
$protectedGroupswith an allowed list, so the action only ever copies groups you've named. - Also copy the source user's directly assigned licences. See Assign or remove a licence.
- Add a
same_access_asinput to your onboarding action and reuse this loop there. See Onboard a Microsoft 365 user.