Manual permission management in Microsoft 365 is slow, inconsistent, and impossible to audit at scale. This hands-on lesson walks you through building a complete automated permission management system — from approval-gated access requests to M365 Group provisioning and scheduled access reviews — using Power Automate, Graph API, and SharePoint REST API.

Picture this: It's Monday morning, and your inbox has 23 unread emails. Fourteen of them are access requests — contractors who need SharePoint site permissions, new hires who should have been added to the Marketing M365 Group last week, and a manager asking why their direct report still can't view the quarterly reports site. You spend the next two hours manually clicking through the SharePoint Admin Center and Azure AD, and by the time you're done, three more requests have arrived.
This is the reality for anyone managing Microsoft 365 environments without automation. The good news is that Power Automate, combined with the Microsoft Graph API and SharePoint REST API, can handle the entire lifecycle — from receiving an access request to provisioning permissions to notifying stakeholders — without you touching the Admin Center at all. The flows aren't just faster; they're auditable, consistent, and enforceable in ways that manual processes never will be.
By the end of this lesson, you'll have built a production-ready permission management system that handles new user provisioning, role assignment workflows, and structured access request approval pipelines. We'll cover the underlying mechanics deeply enough that you can adapt these patterns to your organization's specific structure.
What you'll learn:
You should be comfortable with the Power Automate canvas — building basic flows, using expressions, and working with JSON. You should understand what Microsoft 365 Groups and SharePoint permission levels are conceptually. Familiarity with HTTP requests and REST APIs is helpful but not required; we'll explain what each call does. You'll need a Power Automate account with at least a standard license (some HTTP actions require premium connectors — we'll flag those), and you'll need SharePoint Site Collection Admin rights or a service account with those rights.
The most common mistake people make when building permission automation is jumping straight into Power Automate without understanding the permission model they're automating. Let's get aligned on the structure.
Microsoft 365 Groups are the foundational identity unit for modern Microsoft 365 collaboration. When you create a Teams channel, a SharePoint Team Site, or a Planner board, there's usually an M365 Group behind it. Every M365 Group has two membership tiers: Owners (who can manage the group and its settings) and Members (who get access to the group's resources). When you add someone to an M365 Group, they automatically get access to the associated SharePoint site, Teams, and other connected services.
SharePoint Permission Levels operate at a different layer. Even without an M365 Group, SharePoint sites have their own permission inheritance model. Users or security groups can be granted permission levels like Full Control, Edit, Contribute, Read, or custom-defined levels. These can be assigned directly to individuals (which is an anti-pattern at scale) or to SharePoint Groups, which are different from M365 Groups. The cleanest architecture is to manage access through M365 Groups (which map to SharePoint Site Members/Owners), and only use direct SharePoint permissions for edge cases like external users or highly granular folder-level access.
Your automation flows need to account for this layered architecture. A new employee provisioning flow should add them to the correct M365 Group. A contractor requesting access to a specific document library might need a direct SharePoint permission grant instead. We'll build flows that handle both scenarios.
Tip: Before building any flow, document your permission matrix. A simple spreadsheet listing each site, its corresponding M365 Group, the SharePoint Groups used, and who the approvers are will save you enormous debugging time later.
Every automation system needs a structured intake mechanism. We'll use a SharePoint list as both the request intake form and the audit log. This gives you a single source of truth and makes it easy to build reporting on top later.
Create a SharePoint list called Access Requests in your intranet or IT operations site. Add the following columns:
REQ-2024-0042This list structure means you can support both self-service requests (where users fill in a Power Apps form or a SharePoint form) and automated provisioning requests (where an HR system or onboarding flow creates the list item directly).
The most impactful flow you can build is one that runs when a new employee record is created in your HR system — or in our case, when a specific SharePoint list item is created in an Onboarding Tracker. This flow resolves the "new hire can't access anything on day one" problem completely.
Create a new Automated Cloud Flow. Select When an item is created from the SharePoint connector as your trigger. Point it at your Onboarding Tracker list. This trigger fires the moment HR (or an onboarding coordinator) adds a new row.
Your first real action should be a Condition that checks the Status column equals "Approved" or "ReadyToProvision" — you don't want the flow to run for draft records that haven't been through HR sign-off yet.
To add someone to an M365 Group via Graph API, you need their Azure AD Object ID, not their email address. Add an HTTP action (this is a premium action — if you're on a standard license, use the Azure AD connector's Get user action instead).
Configure the HTTP action:
Method: GET
URI: https://graph.microsoft.com/v1.0/users/@{triggerOutputs()?['body/NewEmployeeEmail']}
Authentication: Active Directory OAuth
Tenant: your-tenant-id
Audience: https://graph.microsoft.com
Client ID: your-app-registration-client-id
Secret: your-app-registration-secret
Important: You'll need an App Registration in Azure AD with at least
Group.ReadWrite.AllandUser.Read.AllAPI permissions (Application type, not Delegated). Store the client secret in Azure Key Vault and reference it from Power Automate — never hardcode secrets in your flow.
Parse the response using Parse JSON with this schema:
{
"type": "object",
"properties": {
"id": { "type": "string" },
"displayName": { "type": "string" },
"userPrincipalName": { "type": "string" },
"mail": { "type": "string" }
}
}
Now you have the user's Object ID in a dynamic content token called id.
Similarly, you need the Group's Object ID. Add another HTTP GET:
URI: https://graph.microsoft.com/v1.0/groups?$filter=displayName eq '@{triggerOutputs()?['body/Department']} Team'
This uses OData filtering to find the group by display name. Parse the response — note that the Graph API returns a value array, so your parsed JSON schema should reflect that:
{
"type": "object",
"properties": {
"value": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"displayName": { "type": "string" }
}
}
}
}
}
Use first(body('Parse_JSON_Group')?['value'])?['id'] as your expression to grab the first match's ID.
Tip: If your department names don't exactly match your Group display names, maintain a lookup table in a SharePoint list: Department Name → Group ID. Then use a Get item action to look up the correct Group ID instead of doing a fuzzy Graph API search.
Now add the HTTP action to perform the actual membership addition:
Method: POST
URI: https://graph.microsoft.com/v1.0/groups/@{body('Parse_JSON_Group')?['value'][0]['id']}/members/$ref
Headers:
Content-Type: application/json
Body:
{
"@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/@{body('Parse_JSON_User')?['id']}"
}
This is the Graph API call that does the actual work. The $ref endpoint is specifically for adding members to a group. If you want to add them as an Owner instead, replace /members/$ref with /owners/$ref.
Wrap the HTTP add-member action in a Scope block so you can catch failures. After the scope, add a Configure run after condition that runs your error handling if the scope fails. Your error handler should:
outputs('Add_Member_to_Group')For the success path, update the item status to "Provisioned" and send a welcome email to the new employee listing what they now have access to.
Self-service access requests are where things get interesting. The goal is a flow that a user can trigger from a SharePoint form or Power Apps, which then routes to the correct approver, waits for their response, and provisions or rejects access accordingly.
Use When an item is created on your Access Requests list. Your first action should be a Get item action pointing back to the same list with the item ID — this ensures you have fully fresh column values (the trigger payload can sometimes be slightly stale for complex list configurations).
Add a Switch action on the ResourceType field. This is your routing logic. Each case handles a different type of permission grant, and each case first needs to resolve who the approver should be.
In your M365 Group case, the approver should be the Group Owner. Fetch them via Graph API:
Method: GET
URI: https://graph.microsoft.com/v1.0/groups/@{items('Apply_to_each')?['ResourceID']}/owners?$select=mail,displayName
Use first(body('Get_Group_Owners')?['value'])?['mail'] to get the first owner's email. In practice, you might want to send approval to all owners — we'll handle that in the approval action.
For the SharePoint Site case, you can query the site's Site Collection Administrators via the SharePoint REST API using Send an HTTP request to SharePoint:
Method: GET
Site Address: https://yourtenant.sharepoint.com/sites/@{triggerBody()?['ResourceName']}
Uri: _api/web/associatedownergroup/users
Headers:
Accept: application/json;odata=verbose
Add the Start and wait for an approval action from the Approvals connector. Configure it as follows:
Access Request @{triggerBody()?['Title']} - @{triggerBody()?['RequestedFor/DisplayName']} requesting @{triggerBody()?['AccessLevel']} on @{triggerBody()?['ResourceName']}REQUEST DETAILS
──────────────
Requested By: @{triggerBody()?['RequestedBy/DisplayName']}
Requested For: @{triggerBody()?['RequestedFor/DisplayName']} (@{triggerBody()?['RequestedFor/Email']})
Resource: @{triggerBody()?['ResourceName']}
Access Level: @{triggerBody()?['AccessLevel']}
Business Justification:
@{triggerBody()?['BusinessJustification']}
Please review and approve or reject this request.
This request will expire in 72 hours if no action is taken.
Update the Access Requests item's Status to "Pending Approval" and populate ApproverEmail immediately after triggering the approval — before you hit the wait state.
Warning: The Start and wait for an approval action holds a flow run open until someone responds. If your approvers are slow, this counts against your flow run concurrency limits. For high-volume environments, consider splitting this into two flows: one that sends the approval request and a second triggered by the approval response.
After the approval action completes, check outputs('Start_and_wait_for_an_approval')?['body/outcome']. This will be either "Approve" or "Reject".
For the Reject branch:
outputs('Start_and_wait_for_an_approval')?['body/responses'][0]['comments']For the Approve branch, you need to actually provision the access. This is where we branch into the provisioning logic.
Rather than embedding provisioning logic in the approval flow (which makes it hard to reuse), build a separate child flow that does the actual permission grants. Call it from your approval flow using Run a Child Flow. This pattern lets you call the same provisioning engine from your HR onboarding flow, your approval flow, and any future automated triggers.
For cases where you're granting access at the SharePoint level (Contribute to a specific document library, for example), use Send an HTTP request to SharePoint. This action uses your flow's connection to authenticate, so it runs as the connected user — make sure that user has Site Collection Admin rights on all sites you're managing.
First, you need to get the Role Definition ID for the permission level you want to assign. Permission level names don't work in the API — you need the numeric ID.
Method: GET
Site Address: https://yourtenant.sharepoint.com/sites/@{triggerBody()?['SiteSlug']}
Uri: _api/web/roledefinitions?$filter=Name eq '@{triggerBody()?['AccessLevel']}'&$select=Id,Name
Headers:
Accept: application/json;odata=verbose
Parse the response and extract body()?['d']?['results']?[0]?['Id']. This is your Role Definition ID.
Next, get the user's Principal ID (SharePoint's internal user ID, different from Azure AD's Object ID):
Method: GET
Site Address: https://yourtenant.sharepoint.com/sites/@{triggerBody()?['SiteSlug']}
Uri: _api/web/ensureuser(@{encodeUriComponent(concat('"', triggerBody()?['UserEmail'], '"'))})
Headers:
Accept: application/json;odata=verbose
The ensureuser endpoint both ensures the user exists in SharePoint's user information list and returns their Principal ID. Extract body()?['d']?['Id'].
Now grant the permission:
Method: POST
Site Address: https://yourtenant.sharepoint.com/sites/@{triggerBody()?['SiteSlug']}
Uri: _api/web/roleassignments/addroleassignment(principalid=@{body('Get_Principal_ID')?['d']?['Id']},roledefid=@{body('Get_Role_Definition_ID')?['d']?['results'][0]['Id']})
Headers:
Accept: application/json;odata=verbose
X-RequestDigest: @{body('Get_Form_Digest')?['d']?['GetContextWebInformation']?['FormDigestValue']}
Notice the X-RequestDigest header. For POST/PATCH/DELETE operations against the SharePoint REST API, you need a form digest value. Get it first:
Method: POST
Site Address: https://yourtenant.sharepoint.com/sites/@{triggerBody()?['SiteSlug']}
Uri: _api/contextinfo
Headers:
Accept: application/json;odata=verbose
Tip: The Send an HTTP request to SharePoint action automatically handles CSRF protection in most cases, but when you're doing role assignment operations, explicitly passing the form digest is more reliable and prevents intermittent failures.
If you're granting access at the document library level rather than the whole site, you first need to break permission inheritance on that library (if it hasn't been broken already), then assign the role:
Method: POST
Uri: _api/web/lists/getbytitle('@{triggerBody()?['LibraryName']}')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=true)
The copyRoleAssignments=true parameter copies existing permissions from the parent site before breaking inheritance, so you don't accidentally lock out current members.
Then the role assignment URI changes to:
Uri: _api/web/lists/getbytitle('@{triggerBody()?['LibraryName']}')/roleassignments/addroleassignment(principalid=@{variables('PrincipalId')},roledefid=@{variables('RoleDefId')})
Every permission change should be logged. Your Access Requests list handles requested changes, but you also need to log what actually happened — including changes made outside the flow. Build a dedicated Permission Audit Log SharePoint list with these columns:
utcNow())After every provisioning action — whether successful or failed — create an item in this list. Use this expression to capture the full API response:
string(outputs('Grant_SharePoint_Permission'))
This is more verbose than you might want in a list, but when something goes wrong at 2am and an executive can't access a site, having the complete API response in a searchable list item is invaluable.
Provisioning access is only half the problem. Access that's never reviewed accumulates like technical debt. Build a scheduled flow that runs monthly and flags stale access for review.
Set your trigger to Recurrence — once monthly on the first day of the month at 6am.
Query your Access Requests list for items where:
Use Get items with a filter query:
Status eq 'Provisioned' and ProvisionedDate le '@{formatDateTime(addDays(utcNow(), -90), 'yyyy-MM-ddTHH:mm:ssZ')}' and AccessLevel ne 'Member'
For each returned item, send an approval request to the original approver asking whether the access should be extended, made permanent, or revoked. Based on the response, either update the ProvisionedDate (extending the access window) or call your child flow to remove the permission.
Removing an M365 Group member via Graph API:
Method: DELETE
URI: https://graph.microsoft.com/v1.0/groups/@{items('Apply_to_each')?['ResourceID']}/members/@{variables('UserObjectID')}/$ref
Removing a SharePoint role assignment:
Method: POST
Uri: _api/web/roleassignments/getbyprincipalid(@{variables('PrincipalId')})/deletobject
(Note: deletobject is not a typo — this is SharePoint's REST API endpoint name.)
Build the following complete workflow that you can test in your own tenant:
Scenario: The Marketing team regularly brings on freelance contractors who need Contribute access to the "Campaign Assets" document library on the Marketing site for a defined period (30 days by default).
Step 1: Create the Access Requests list with the schema described earlier. Add one test item manually with ResourceType = "Document Library", ResourceName = "marketing", AccessLevel = "Contribute", and a realistic business justification.
Step 2: Build the trigger flow that fires on list item creation. In the flow:
Step 3: In your child flow, implement the full SharePoint permission grant sequence:
_api/web/lists/getbytitle('Campaign Assets')/HasUniqueRoleAssignments)Step 4: Set a 30-day expiry. In your parent flow, after provisioning succeeds, use the Delay Until action set to addDays(utcNow(), 30). After the delay, automatically revoke access and send a notification to both the contractor and the approver.
Step 5: Test with a real user in your tenant (use yourself or a test account). Verify the permission appears in the SharePoint document library's permission settings. Check the Audit Log list item. Then wait (or manually trigger the revocation branch) and verify the permission is removed.
"The flow runs successfully but the user still can't access the site." This is almost always a SharePoint caching issue. SharePoint caches permission evaluations for a period. Ask the user to clear their browser cache and try an InPrivate window. If that doesn't work, verify the role assignment actually exists by running the GET version of the roleassignments API call and checking the response.
"I'm getting 403 Forbidden on Graph API calls." Check two things: First, that your App Registration has the right permissions and that an admin has granted consent for those permissions. Adding a permission in the App Registration doesn't automatically grant it — you need to hit "Grant admin consent" in Azure AD. Second, check whether you need Application permissions vs. Delegated permissions. For flows running without a user context (background flows), you need Application permissions.
"My group name lookup returns multiple results."
Group display names are not unique in Azure AD. Two different M365 Groups can have the same display name. Use the $filter=mailNickname eq '...' parameter instead — the mail nickname (the part before @yourtenant.onmicrosoft.com in the group's email) is unique. Better yet, store the Group Object ID directly in your lookup table and skip the name resolution entirely.
"The approval is timing out before the approver responds." Power Automate approvals can wait up to 30 days by default. If you're hitting limits earlier, check your Power Platform environment's session timeout settings. For long-running approvals, use the two-flow pattern (send approval, then use a separate trigger on the approval completion) rather than the single wait-in-place pattern.
"Break role inheritance is failing on some libraries."
If the document library is part of a site that has a very large number of unique permissions already, SharePoint may reject additional uniqueness operations. Check the site's unique permissions count via _api/web/lists/getbytitle('Library Name')/UniquePermissions. If this is hitting limits, you need to reorganize the permission architecture — nesting permissions this deeply usually indicates a design problem.
"My flow is creating duplicate audit log entries." This happens when you have retry logic enabled on your audit log create action. If the action times out but actually succeeded, the retry creates a second entry. Disable automatic retries on your audit logging actions, or add a check before creating the log entry by filtering for existing entries with the same FlowRunID.
"The ensureuser call fails for external users."
External users have a different format in SharePoint. Their login name is i:0#.f|membership|user@externaldomain.com. Build a Condition that checks whether the email contains your tenant domain. If it doesn't, format the login name accordingly before passing it to ensureuser.
You've now built a production-capable permission management system that handles the full lifecycle: intake, approval routing, provisioning, audit logging, and access review. The key architectural decisions that make this work at scale are: using a SharePoint list as a structured intake and audit mechanism, separating provisioning logic into child flows, using the Graph API for M365 Group operations and SharePoint REST API for granular site permissions, and building explicit error handling rather than letting flows fail silently.
The patterns here are composable. The approval routing logic from Flow 2 can be reused for any governance workflow. The Graph API membership management from Flow 1 applies equally to Teams channels, Planner plans, and Exchange distribution lists. The scheduled review pattern from Flow 4 applies to any time-bound access grant you might manage.
Where to go next:
POST /teams/{team-id}/channels and POST /teams/{team-id}/channels/{channel-id}/members endpoints follow the same pattern as group membership management.The investment in building these flows properly pays dividends immediately and compounds over time. Every access request that goes through the automated pipeline instead of your inbox is time recovered for higher-value work — and every permission change in the audit log is a compliance conversation you can handle in minutes instead of days.