If you are just getting started with Identity and Access Management on Azure, this post is for you. I will walk you through how I designed and automated a complete access governance system for a fictional fintech company; covering infrastructure provisioning, intern onboarding, and zero-trust offboarding. Using Bash scripts, Azure RBAC, and GitHub Actions CI/CD pipelines.
This is the kind of project that sits at the intersection of cloud engineering and security. It is not glamorous, but it is one of those solutions that keep organizations from ending up in the news for all the wrong reasons.
This is One of my Capstone projects in my TechCrush Cloud Engineering bootcamp series. If you want to see where this journey started, you can read my previous posts where I tackled deploying a web app across two Azure regions and automating resource group creation for multi-environment workflows.
The Problem
VerdantPay is a fintech company growing fast. They bring on interns every cycle, they have permanent engineering teams working across web and database tiers, and people leave. The company needs three things to happen reliably:
- When infrastructure is provisioned, the network, resource groups, security groups, and RBAC role assignments should all be created in one operation with a full audit trail.
- When an intern joins, they should be added to the correct security group in one command.
- When someone leaves, every group membership, every role assignment, and the account itself should be revoked and verified. Not tomorrow. Not when someone remembers. Immediately.
The third one is the one that matters most. A departed engineer who still has Contributor access to your production resource group is not a hypothetical risk. It is one that shows up in compliance audits and incident reports.
What You Will Need
Before running any of these scripts, make sure you have:
- Azure CLI installed on your local machine. Follow the official installation guide.
- An active Azure account with at least Pay-As-You-Go. A free account works for most of this, but you will need Entra ID (Azure AD) permissions to create groups and manage users.
-
A GitHub repository with
AZURE_CREDENTIALSconfigured as a repository secret for the CI/CD pipelines. - A terminal that runs Bash; Linux, macOS, or WSL on Windows.
Understanding the Design
The Principle: Least Privilege, Enforced by Structure
The core design decision behind this entire system is that role assignments are made to Azure AD groups, not to individual users.
When you assign a role directly to a user, revoking access means finding every individual assignment across every scope. When you assign roles to groups, revoking access means removing the user from the group. One operation. One place to audit.
The Role Matrix
Before writing a single line of code, I built a role matrix. Every group, its scope, its permission level, whether the access is time-bound, and the justification for why that level was chosen and not a higher one.
| Group | Scope | Azure Role | Time-bound | Why This Level |
|---|---|---|---|---|
| InternWebDevs | web-subnet | Reader | Yes, 6 weeks | Interns need to inspect web-tier resources. Contributor would let them modify infrastructure they should not touch. |
| InternDBReadOnly | db-subnet | Reader | Yes, 6 weeks | Interns need to inspect DB config. They must not modify the database or its network rules. |
| WebAdmins | web-subnet | Contributor | No, permanent | Web admins manage the web tier. Owner is not needed because they do not manage access. |
| DBAdmins | db-subnet | Contributor | No, permanent | DB admins manage the database tier. Reader covers infrastructure. Data actions are granted separately. |
The rule: no group or role assignment is created without a corresponding row in this matrix.
The Architecture
The system has three layers: automation scripts, Azure AD groups, and Azure infrastructure, connected by GitHub Actions pipelines.
The provisioning script creates the resource group, VNet, subnets, AD groups, and role assignments. The onboarding script adds a user to a group. The offboarding script revokes everything and verifies the revocation. GitHub Actions orchestrates the provisioning and offboarding pipelines with full artifact logging.
The Scripts
1. Provisioning: provision.sh
This script builds the entire infrastructure and access control layer in one run.
#!/bin/bash
set -euo pipefail
LOG_FILE="provision-log.txt"
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
log() {
echo "[$TIMESTAMP] $1" | tee -a "$LOG_FILE"
}
log "Starting provisioning..."
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
# 1. Create Resource Group
RG_NAME="verdantpay-rg"
LOCATION="eastus"
log "Creating Resource Group: $RG_NAME in $LOCATION"
az group create --name $RG_NAME --location $LOCATION -o json >> "$LOG_FILE"
# 2. Create VNet and Subnets
VNET_NAME="verdantpay-vnet"
az network vnet create \
--resource-group $RG_NAME \
--name $VNET_NAME \
--address-prefix "10.0.0.0/16" -o json >> "$LOG_FILE"
az network vnet subnet create \
--resource-group $RG_NAME \
--vnet-name $VNET_NAME \
--name "web-subnet" \
--address-prefixes "10.0.1.0/24" -o json >> "$LOG_FILE"
az network vnet subnet create \
--resource-group $RG_NAME \
--vnet-name $VNET_NAME \
--name "db-subnet" \
--address-prefixes "10.0.2.0/24" -o json >> "$LOG_FILE"
What matters here:
set -euo pipefail at the top is non-negotiable. If any command fails, the script stops. If any variable is unset, the script stops. In infrastructure automation, a script that silently continues after a failure is worse than a script that crashes loudly.
The script also checks whether subnets and groups already exist before creating them. This makes the script idempotent you can run it ten times and get the same result. This matters in CI/CD where pipelines re-run on retries.
Creating AD Groups and Role Assignments
create_ad_group() {
local group_name=$1
GROUP_ID=$(az ad group show --group "$group_name" --query id -o tsv 2>/dev/null || echo "")
if [ -z "$GROUP_ID" ]; then
az ad group create --display-name "$group_name" --mail-nickname "$group_name" -o json >> "$LOG_FILE"
else
log "Group $group_name already exists."
fi
}
create_ad_group "verdantpay-InternWebDevs"
create_ad_group "verdantpay-InternDBReadOnly"
create_ad_group "verdantpay-WebAdmins"
create_ad_group "verdantpay-DBAdmins"
After creating groups, the script waits 30 seconds for Entra ID replication before attempting role assignments. This upgrade was as a result of a notable failed case that I encountered. Group creation is eventually consistent, and assigning a role to a group that has not replicated yet will fail silently or throw an error depending on your timing.
The role assignment function includes a retry loop:
assign_role() {
local group_name=$1
local role=$2
local scope=$3
local retries=5
local wait=10
for i in $(seq 1 $retries); do
GROUP_ID=$(az ad group show --group "$group_name" --query id -o tsv 2>/dev/null || echo "")
if [ -n "$GROUP_ID" ]; then
break
fi
log "Group '$group_name' not found yet, retrying in ${wait}s... (attempt $i/$retries)"
sleep $wait
done
EXISTING=$(az role assignment list --assignee "$GROUP_ID" --role "$role" --scope "$scope" --query '[].id' -o tsv 2>/dev/null || echo "")
if [ -z "$EXISTING" ]; then
az role assignment create --assignee "$GROUP_ID" --role "$role" --scope "$scope" -o json >> "$LOG_FILE"
else
log "Role assignment already exists for $group_name"
fi
}
This is the kind of defensive code you write after learning the hard way that Azure AD group replication is not instant. The retry loop gives the system time to catch up without failing the entire pipeline.
2. Onboarding: onboard.sh
This script is intentionally simple. One command. One log entry.
#!/bin/bash
set -euo pipefail
USER_EMAIL=$1
GROUP_NAME=$2
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
EXECUTOR=$(az ad signed-in-user show --query userPrincipalName -o tsv 2>/dev/null || echo "service-principal")
USER_OBJECT_ID=$(az ad user show --id "$USER_EMAIL" --query id --output tsv)
az ad group member add --group "$GROUP_NAME" --member-id "$USER_OBJECT_ID"
echo "[$TIMESTAMP] ADDED: User '$USER_EMAIL' to Group '$GROUP_NAME' by '$EXECUTOR'" | tee -a "onboarding-log.txt"
Why it logs the executor: When an intern gets added to verdantpay-InternWebDevs, you need to know who added them. The script captures the signed-in user's principal name. If it is running via a service principal in CI/CD, it logs that instead. This is a basic audit requirement that most onboarding scripts skip.
3. Offboarding: offboard.sh
This is the most important script in the entire system. It follows a strict sequence:
- Look up the user's Object ID
- Capture the before state — every group membership and role assignment
- Remove the user from every AD group
- Delete every direct role assignment
- Disable the Azure AD account
- Verify that all access has been revoked
- Write
VERIFICATION: PASSorVERIFICATION: FAIL
#!/bin/bash
set -euo pipefail
USER_EMAIL=$1
LOG_FILE="offboarding-log.txt"
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
# Step 1: Get Object ID
OBJECT_ID=$(az ad user show --id "$USER_EMAIL" --query id --output tsv)
# Step 2: Capture before-state
echo '--- BEFORE STATE ---' >> "$LOG_FILE"
echo "Timestamp: $TIMESTAMP" >> "$LOG_FILE"
echo "User: $USER_EMAIL" >> "$LOG_FILE"
az ad user get-member-groups --id "$OBJECT_ID" --security-enabled-only true >> "$LOG_FILE"
az role assignment list --assignee "$OBJECT_ID" --all >> "$LOG_FILE"
# Step 3: Remove from all groups
USER_GROUPS=$(az ad user get-member-groups --id "$OBJECT_ID" --security-enabled-only true --query '[].displayName' -o tsv)
for GROUP in $USER_GROUPS; do
az ad group member remove --group "$GROUP" --member-id "$OBJECT_ID" || true
done
# Step 4: Delete direct role assignments
ROLE_IDS=$(az role assignment list --assignee "$OBJECT_ID" --all --query '[].id' -o tsv)
for ROLE_ID in $ROLE_IDS; do
az role assignment delete --ids "$ROLE_ID" || true
done
# Step 5: Disable account
az ad user update --id "$OBJECT_ID" --account-enabled false
# Step 6: Verify with retries
VERIFY_RETRIES=5
VERIFY_WAIT=15
for i in $(seq 1 $VERIFY_RETRIES); do
REMAINING_ROLES=$(az role assignment list --assignee "$OBJECT_ID" --all --query '[]' -o tsv)
REMAINING_GROUPS=$(az ad user get-member-groups --id "$OBJECT_ID" -o tsv)
if [ -z "$REMAINING_ROLES" ] && [ -z "$REMAINING_GROUPS" ]; then
echo 'VERIFICATION: PASS -- all access removed for '"$USER_EMAIL" >> "$LOG_FILE"
exit 0
fi
echo "Residual access detected, retrying in ${VERIFY_WAIT}s... (attempt $i/$VERIFY_RETRIES)"
sleep $VERIFY_WAIT
done
echo 'VERIFICATION: FAIL -- residual access detected after retries' >> "$LOG_FILE"
exit 1
The script queries Azure AD and Azure RBAC after the revocation to confirm that all groups and roles are actually gone. If they are not because of replication lag or a partial failure the script retries up to 5 times over 75 seconds. If it still fails, it exits with code 1, which causes the CI/CD pipeline to fail and alert the team.
The || true on the removal commands is deliberate. If a user has already been removed from a group (maybe by a previous partial run), the command would fail and set -e would kill the script before it gets to the other groups. The || true lets it continue to the next group while the verification step at the end catches anything that was actually missed.
The CI/CD Pipelines
Provisioning Pipeline
name: VerdantPay IAM Provisioning
on:
push:
branches: [ main ]
paths:
- 'scripts/provision.sh'
- 'scripts/onboard.sh'
jobs:
provision:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Run provisioning script
run: bash scripts/provision.sh
- name: Upload access log
uses: actions/upload-artifact@v4
with:
name: access-log
path: provision-log.txt
Path filtering is the design decision here. The pipeline only triggers when provision.sh or onboard.sh are modified. Updating the README does not trigger a cloud deployment. This is was done to saves unnecessary runs and keep the audit logs clean.
Offboarding Pipeline
name: VerdantPay Offboarding
on:
workflow_dispatch:
inputs:
user_email:
description: 'Email address of the user to offboard'
required: true
jobs:
offboard:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Run offboarding script
run: bash scripts/offboard.sh ${{ github.event.inputs.user_email }}
- name: Upload offboarding log
uses: actions/upload-artifact@v4
with:
name: offboarding-log
path: offboarding-log.txt
Why workflow_dispatch instead of an automated trigger? Offboarding is a destructive, irreversible action. Triggering it automatically on a push would be dangerous. workflow_dispatch requires a security administrator to manually type in the user's email and click Run.
The offboarding log is uploaded as a GitHub Actions artifact. Security teams can download it after the run to prove that access was revoked at a specific timestamp.
What the Result Looks Like
After running the full provisioning, I tested the system with real user accounts. The onboarding log captured every addition:
[2026-08-03T07:01:49Z] ADDED: User 'temiloluwa@...' to Group 'verdantpay-WebAdmins' by 'emmanuelajibokunedu...'
And the offboarding log shows the complete before-and-after state:
--- BEFORE STATE ---
Timestamp: 2026-08-03T07:48:08Z
User: temiloluwa@...
Groups before:
[
{ "displayName": "verdantpay-WebAdmins" }
]
VERIFICATION: PASS -- all access removed for temiloluwa@...
The VERIFICATION: PASS line means the script queried Azure after revocation, found zero remaining group memberships and zero remaining role assignments, and confirmed the offboarding was complete.
What I Would Improve in a v2
1. Time-bound access with Azure AD PIM
The role matrix specifies that intern access should be time-bound to 6 weeks, but the current scripts do not enforce expiry automatically. In a v2, I would integrate Azure AD Privileged Identity Management (PIM) to set eligible assignments with an automatic expiry date. That way, even if the offboarding script is never run, the access disappears on its own.
2. Slack or Teams notifications on offboarding
When the offboarding pipeline completes, the result sits in a GitHub Actions log that someone has to go look at. A webhook that posts the PASS/FAIL result to a Slack or Teams channel would close that loop and give security teams instant visibility.
3. A dedicated teardown script
I actually built this already, a teardown.sh that removes everything in reverse order: role assignments first, then AD groups, then the resource group.
Key Takeaways
- Assign roles to groups, never to individual users.
- Build a role matrix before writing code.
- Verify your revocations.
- Use
set -euo pipefailin every script. - Make your CI/CD pipelines produce evidence.
- Write a teardown script for everything you provision.
What Is Next
Follow me for more contents about Cloud engineering and DevOps
Follow along on my Dev.to profile if you want to see how it goes.
You can find the full scripts, pipeline configs, role matrix, and architecture diagram here: github.com/EmmanuelAjibokun/VerdantPay-IAM
Top comments (0)