Quarterly privileged access review
Who this is for: security and identity teams who owe their auditor evidence that privileged access is reviewed every quarter, not only that it was granted once.
Before you start: a Microsoft 365 connection named m365 (see Connect Microsoft 365), and the company variable admin_groups.
What it does
Section titled “What it does”On the first business day of each quarter (skipping weekends and Finnish public holidays), the workflow lists the members of each Entra ID group in admin_groups, builds one CSV per group, and emails it to that group’s owner, asking them to confirm or remove each member. It never removes anyone itself: a person reads the list and decides.
The list includes people who get access through a group nested inside the privileged group, because they have the same access as direct members.
If a group in admin_groups doesn’t exist, has more members than one review lists (1,000), or Microsoft can’t be reached, the run fails and no owner gets an incomplete list.
What it can access
Section titled “What it can access”m365: a connection made from the Microsoft 365 (Microsoft Graph) template, read-only for this workflow. It uses only the ready-made actionlibrary/ms365.list-group-members@v1, so the application permissions it needs areGroupMember.Read.AllandUser.Read.Allfor the members’ names. Don’t grant any permission that changes data for this workflow.- It emails only the owners named in
admin_groups, one CSV per group, containing each member’s Entra ID, name and user principal name and nothing else. - No secret value is ever in the workflow. The connection holds its own credential.
What you need to set up
Section titled “What you need to set up”-
Create the Microsoft 365 connection from its template, name it
m365, and grant its app onlyGroupMember.Read.AllandUser.Read.All(see Connect Microsoft 365). -
Add the company variable
admin_groups: a list of objects, one per privileged group, each withid(the Entra ID group id),name(a plain name for the subject line) andowner(who should review it), for example:[{ "id": "11111111-1111-1111-1111-111111111111", "name": "Domain Admins", "owner": "iam-owner@bank.example" },{ "id": "22222222-2222-2222-2222-222222222222", "name": "Database Admins", "owner": "dba-owner@bank.example" }] -
Take it into use from the Liitora library (see Take a library workflow into use), then read the test cases on your copy, run them and approve it.
How to adapt it
Section titled “How to adapt it”- A different schedule. Change the
cronandcalendar.skip_onto match your own review cadence and holiday calendar; see Use calendars and holidays. - More detail per member. The
membersstep asks Microsoft only for each member’s ID, name and user principal name. Add properties to itsselectand to the CSV columns in thebuild_reportsscript if your reviewers need more, such asjobTitleordepartment. Each one adds personal data to the run, so add only what reviewers use. - Direct members only. Remove
transitive: trueto list only the people added to the group itself. People in nested groups then don’t appear in the review at all, so do this only if you review the nested groups separately. - A tracked response instead of an email reply. Point the email at a form or a service desk queue instead of asking owners to reply, if you want the confirmation itself recorded somewhere.
- GitHub or another directory’s groups. The pattern (fetch members, build a CSV per group, email the owner) works the same way for any directory with a “list group members” API. Replace the
membersstep with a step that reads that directory.
What Liitora shows on the approval page
Section titled “What Liitora shows on the approval page”On the schedule
0 8 1 1,4,7,10 *(Helsinki time), moved to the next business day on Finnish public holidays or weekends or when someone starts it by hand: list the members of each privileged group in Entra ID for each item, then build a CSV of the members of each group, then email each group owner to confirm or remove members for each item.
Trust details
Section titled “Trust details”| What | Details |
|---|---|
| Publisher | Liitora |
| Version | 1.0.0 |
| Licence | Apache-2.0 |
| Content hash | sha256:e2ac4df8e88bb921a6481678c23ba7fdb1971c1a821c414356b666e892ebe845 |
| Tests | 5 |
| Connections | m365 |
| Company variables | admin_groups |
| Secrets | None |
| Personal data | Each group member’s Entra ID, name and user principal name, emailed to that group’s owner. |
| Saved state | No |
| Runs on your own runner | No |
| Actions | library/ms365.list-group-members@v1, platform/email.send@v1 |
Liitora signs each version of this workflow. When your company takes it into use, Liitora checks the content hash and the signature again before it copies the workflow into your company. See How the library is kept safe.
The full YAML
Section titled “The full YAML”# A Liitora library workflow for a bank CISO (see the guide, Library > Quarterly privileged# access review). Connection used: m365 (from the Microsoft 365 template,# read-only here). Company variable used: admin_groups, a list of# { id, name, owner } objects, one per privileged group to review.name: Quarterly privileged access reviewdescription: On the first business day of each quarter, list the members of each privileged Entra ID group, also through nested groups, build a CSV per group and email it to that group's owner asking them to confirm or remove each member.
on: schedule: - cron: "0 8 1 1,4,7,10 *" timezone: Europe/Helsinki calendar: skip_on: [fi_public_holidays, weekends] shift: next_business_day manual: true
steps: - id: members name: List the members of each privileged group in Entra ID uses: library/ms365.list-group-members@v1 connections: graph: m365 strategy: for_each: ${{ vars.admin_groups }} max_parallel: 3 with: group: ${{ each.item.id }} transitive: true select: [displayName, userPrincipalName]
- id: build_reports name: Build a CSV of the members of each group needs: [members] with: groups: ${{ vars.admin_groups }} results: ${{ steps.members.outputs }} run: | export default async function run(ctx, input) { const groups = input.groups || []; const results = input.results || []; const reports = []; for (let i = 0; i < groups.length; i++) { const group = groups[i]; const result = results[i]; if (!result || result.found !== true) { throw new Error(`Entra ID has no group with the id given for ${group.name}. Check the admin_groups company variable.`); } if (result.truncated) { throw new Error(`${group.name} has more members than one review lists. Split the group or review it by hand.`); } const members = result.members.map((m) => ({ id: m.id, name: m.displayName, upn: m.userPrincipalName, })); const csv = members.length > 0 ? ctx.formats.csv.build(members) : "id,name,upn\r\n"; const fileName = `access-review-${group.id}.csv`; const artifact = await ctx.artifacts.put(fileName, csv, { contentType: "text/csv" }); reports.push({ owner: group.owner, name: group.name, artifact_name: artifact.name, member_count: members.length, }); } return { reports }; }
- id: notify_owners name: Email each group owner to confirm or remove members needs: [build_reports] uses: platform/email.send@v1 strategy: for_each: ${{ steps.build_reports.outputs.reports }} max_parallel: 3 fail_fast: false with: to: ["${{ each.item.owner }}"] subject: "Quarterly access review: ${{ each.item.name }}" text: "Please confirm that each of the ${{ each.item.member_count }} members of ${{ each.item.name }} still needs privileged access, and remove anyone who does not. The current member list is attached." attachments: ["${{ artifacts[each.item.artifact_name] }}"]Changelog
Section titled “Changelog”- 1.0.0 (2026-10-04): First version in the library.