Add a user to a Microsoft 365 or security group, or remove them

Build a workflow action that adds a user to a Microsoft 365 group or security group, or removes them, so MSPs and IT teams can handle access requests by asking SuperIT. Microsoft 365 groups include the membership of Microsoft Teams teams and their SharePoint sites.

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 or removing a group member is a write, which only an action with a delegated Microsoft Graph credential can make. Group membership often grants access to data and systems, so an admin approves every run.

To check who is in a group, or which groups a user is in, just ask SuperIT in chat.

When to use it

  • "Add Priya to the Finance team."
  • "Please give Sam access to the Marketing SharePoint."
  • "Remove Tom from the VPN Users group."

What the action does

  1. Checks the inputs and that the user exists.
  2. Finds the group by its exact name, and stops if there's no match or more than one.
  3. Refuses dynamic groups, distribution lists and mail-enabled security groups, which Microsoft Graph can't change. It also refuses groups that can be assigned Microsoft Entra admin roles, and any group on the script's $protectedGroups list.
  4. Adds or removes the user, and reports if they were already in (or not in) the group.

What it doesn't do: it doesn't create groups, change group owners, or manage distribution lists or mail-enabled security groups (use Exchange Online for those). It doesn't know which groups your Conditional Access policies or Azure role assignments use; list those in $protectedGroups.

Before you begin

  • A Microsoft Graph credential for user administration, named M365 Graph, with the scopes GroupMember.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 $protectedGroups at 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 from superit:admins. Don't add an Anyone rule to this action: the script doesn't know who asked, so an end user could add themselves to any group. If end users should request access, build a separate copy where group_name is a select input listing only the groups they may request, and keep an approver on it.
  • 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 "Add or remove Microsoft 365 group member".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Graph.
Inputs:
- user_upn (email, required)
- group_name (string, required) - exact group display name
- operation (select, required, choices: add, remove)
It should stop if the group name matches zero or several groups, refuse dynamic
and mail-enabled non-Microsoft 365 groups, role-assignable groups and a list of
protected groups in the script, and say if nothing needed changing.
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
user_upn email Yes [email protected]
group_name string Yes Finance team
operation select (choices add, remove) Yes add

The main function at the top lists every step in order; each function below it does one of them, and the last line runs it with the action's inputs.

# Goal: add a user to, or remove them from, one Microsoft 365 or security group found by exact
# name, and output the change or why nothing changed.
$token = $env:CREDENTIAL_M365_GRAPH_TOKEN
if (-not $token) { throw "Link the M365 Graph credential to this action." }
$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-GroupMembershipChange {
    param([string]$UserUpn, [string]$GroupName, [string]$Operation)
    $upn = $UserUpn.Trim()
    $name = $GroupName.Trim()
    $op = $Operation.Trim().ToLower()
    Assert-Inputs -Upn $upn -GroupName $name -Operation $op

    $user = Get-TargetUser -Upn $upn
    $group = Get-TargetGroup -Name $name
    Assert-ManageableGroup -Group $group -Name $name
    $isMember = Test-GroupMember -GroupId $group.id -UserId $user.id

    if ($op -eq 'add') {
        if ($isMember) { Write-NoChange -User $user -Reason "already in $name"; return }
        Add-GroupMember -GroupId $group.id -UserId $user.id -Name $name
    } else {
        if (-not $isMember) { Write-NoChange -User $user -Reason "isn't a direct member of $name"; return }
        Remove-GroupMember -GroupId $group.id -UserId $user.id -Name $name
    }

    Write-MembershipSummary -User $user -Upn $upn -GroupName $name -Operation $op
    # $script: because the runner returns the script-level $result, not a function's local copy.
    $script:result = @{ user = $upn; group = $name; operation = $op }
}

function Assert-Inputs {
    param([string]$Upn, [string]$GroupName, [string]$Operation)
    if ($Upn -notmatch '^[^@\s]+@[^@\s]+$') { throw "user_upn must be a sign-in address." }
    if (-not $GroupName) { throw "group_name is required." }
    if ($Operation -notin 'add', 'remove') { throw "operation must be add or remove." }
}

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-TargetGroup {
    param([string]$Name)
    $filter = [uri]::EscapeDataString("displayName eq '$($Name.Replace("'", "''"))'")
    try {
        $groups = @(Get-GraphCollection -Uri "$baseUrl/groups?`$filter=$filter&`$select=id,displayName,groupTypes,mailEnabled,isAssignableToRole")
    } catch {
        Write-Error "Failed to look up group '$Name': $_"
        exit 1
    }
    if ($groups.Count -eq 0) { throw "No group is called '$Name'." }
    if ($groups.Count -gt 1) { throw "$($groups.Count) groups are called '$Name'. Give them unique names first." }
    $groups[0]
}

function Assert-ManageableGroup {
    param($Group, [string]$Name)
    if ($protectedGroups -contains $Group.displayName) { throw "'$Name' is on this action's protected list. Change it in the Microsoft Entra admin center." }
    if ($Group.isAssignableToRole) { throw "'$Name' can be assigned Microsoft Entra admin roles. Change it in the Microsoft Entra admin center." }
    if ($Group.groupTypes -contains 'DynamicMembership') { throw "'$Name' is a dynamic group. Its members are set by rules." }
    if ($Group.mailEnabled -and $Group.groupTypes -notcontains 'Unified') {
        throw "'$Name' is a distribution list or mail-enabled security group. Change it in Exchange Online."
    }
}

function Test-GroupMember {
    param([string]$GroupId, [string]$UserId)
    # The user's own groups are usually far fewer than a large group's members.
    try {
        $userGroups = @(Get-GraphCollection -Uri "$baseUrl/users/$UserId/memberOf/microsoft.graph.group?`$select=id")
    } catch {
        Write-Error "Failed to list groups for user '$UserId': $_"
        exit 1
    }
    $userGroups.id -contains $GroupId
}

function Add-GroupMember {
    param([string]$GroupId, [string]$UserId, [string]$Name)
    $ref = @{ '@odata.id' = "$baseUrl/directoryObjects/$UserId" } | ConvertTo-Json
    try {
        Invoke-RestMethod -Method Post -Uri "$baseUrl/groups/$GroupId/members/`$ref" -Headers $headers `
            -ContentType 'application/json' -Body $ref | Out-Null
    } catch {
        Write-Error "Failed to add user '$UserId' to '$Name': $_"
        exit 1
    }
}

function Remove-GroupMember {
    param([string]$GroupId, [string]$UserId, [string]$Name)
    try {
        Invoke-RestMethod -Method Delete -Uri "$baseUrl/groups/$GroupId/members/$UserId/`$ref" -Headers $headers | Out-Null
    } catch {
        Write-Error "Failed to remove user '$UserId' from '$Name': $_"
        exit 1
    }
}

function Get-GraphCollection {
    param([string]$Uri)
    $items = @()
    while ($Uri) {
        $page = Invoke-RestMethod -Uri $Uri -Headers $headers
        $items += $page.value
        $Uri = $page.'@odata.nextLink'
    }
    $items
}

function Write-NoChange {
    param($User, [string]$Reason)
    Write-Output "=== NO CHANGE ==="
    Write-Output "User   : $($User.displayName)"
    Write-Output "Reason : $Reason"
}

function Write-MembershipSummary {
    param($User, [string]$Upn, [string]$GroupName, [string]$Operation)
    Write-Output "=== GROUP MEMBER $(if ($Operation -eq 'add') { 'ADDED' } else { 'REMOVED' }) ==="
    Write-Output "User  : $($User.displayName) ($Upn)"
    Write-Output "Group : $GroupName"
}

Invoke-GroupMembershipChange -UserUpn $ctx.inputs.user_upn -GroupName $ctx.inputs.group_name `
    -Operation $ctx.inputs.operation

Test it

Test runs for real

Test skips authorization and approval, uses the real M365 Graph credential, and really changes the group. Use a test user and a test group.

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.

  1. On the action, select Test. Enter a test user, a test group and add.
  2. 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.
  3. Check Stdout, then test again with remove.
  4. Test with a group on $protectedGroups, and check the run is refused.

Use it

Publish the action. Engineers ask SuperIT, for example "Add Priya to the Finance team group", and the run waits for an admin to approve it. If nobody is sure of the exact group name, ask SuperIT first, "Which groups have Finance in the name?" or "Which groups is Jane in?". SuperIT reads that through the Microsoft 365 connection.

Membership changes reach SuperIT's m365:* tags at the next sync.

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 $protectedGroups with an allowed list, so the action can only change groups you've named.
  • Add owner as an operation, using /groups/{id}/owners/$ref.
  • Copy every group from a colleague. See Copy group memberships from another user.