Skip to content

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.

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.

  • m365: a connection made from the Microsoft 365 (Microsoft Graph) template, read-only for this workflow. It uses only the ready-made action library/ms365.list-group-members@v1, so the application permissions it needs are GroupMember.Read.All and User.Read.All for 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.
  1. Create the Microsoft 365 connection from its template, name it m365, and grant its app only GroupMember.Read.All and User.Read.All (see Connect Microsoft 365).

  2. Add the company variable admin_groups: a list of objects, one per privileged group, each with id (the Entra ID group id), name (a plain name for the subject line) and owner (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" }
    ]
  3. 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.

  • A different schedule. Change the cron and calendar.skip_on to match your own review cadence and holiday calendar; see Use calendars and holidays.
  • More detail per member. The members step asks Microsoft only for each member’s ID, name and user principal name. Add properties to its select and to the CSV columns in the build_reports script if your reviewers need more, such as jobTitle or department. Each one adds personal data to the run, so add only what reviewers use.
  • Direct members only. Remove transitive: true to 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 members step with a step that reads that directory.

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.

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.

# 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 review
description: 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] }}"]
  • 1.0.0 (2026-10-04): First version in the library.