Grant access through a security group, add a member, and watch the permission inherit down the scope.
Membership is the access control
In Lab A you assigned a role to a single identity. That is fine for one app. It does not survive a team of twelve with monthly churn: per-person assignments accumulate, offboarding gets missed, and an access review becomes archaeology. The professional pattern is to assign the role once to a security group and then manage people purely through membership. Onboarding is "add to group"; offboarding is "remove from group"; the role assignment never changes. You will prove it by showing a user inherits the role through the group — with nothing assigned to the person at all — and that moving them in and out of the group flips their effective access.
Onboarding is a membership, not a migration.
You need a free Azure account, the Azure CLI (az), then be signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs (winget / brew / apt), and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash), preinstalled and already signed in.
Azure: Owner or User Access Administrator (to assign roles). Entra directory: a role like Global Administrator, or Groups Administrator + User Administrator (to create groups and users) — this is separate from Azure RBAC. On a personal tenant you have both. On a work tenant you almost certainly can't create users/groups; use a personal test tenant, or every az ad ... command returns Insufficient privileges.
Variables
# Windows/Git Bash only: stop it rewriting /subscriptions/... args (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 # if you have more than one subscription, make the intended one active az account set --subscription "$(az account show --query id -o tsv)" SUFFIX=$RANDOM RG="campux-lab-groups-rg" LOCATION="eastus" KV="campux-kv-$SUFFIX" SECRET_NAME="ProductDbConnection" GROUP_NAME="Campux-KeyVault-Readers" GROUP_NICK="campux-kv-readers-$SUFFIX" SUB=$(az account show --query id -o tsv) VAULT_SCOPE="/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.KeyVault/vaults/$KV" # Default verified domain of your tenant (the *.onmicrosoft.com), for test user names. DOMAIN=$(az rest --method GET --url "https://graph.microsoft.com/v1.0/domains" --query "value[?isDefault].id | [0]" -o tsv) # A password that meets Entra complexity rules, for the throwaway test users. USER_PWD="Cx-$(openssl rand -hex 6)-9A!" echo "Suffix=$SUFFIX Domain=$DOMAIN"
$DOMAIN prints something like yourtenant.onmicrosoft.com. If it's empty you lack directory read permission — you can't do this lab on this tenant.A vault and a secret (the thing access protects)
az group create --name "$RG" --location "$LOCATION" az keyvault create --name "$KV" --resource-group "$RG" --location "$LOCATION" --enable-rbac-authorization true # Let yourself write one secret (RBAC vaults don't grant the creator data access automatically). # signed-in-user is empty for personal Gmail/Outlook accounts, so fall back to the token's oid: ME=$(az ad signed-in-user show --query id -o tsv 2>/dev/null) if [ -z "$ME" ]; then PAYLOAD=$(az account get-access-token --query accessToken -o tsv | cut -d. -f2 | tr '_-' '/+') case $(( ${#PAYLOAD} % 4 )) in 2) PAYLOAD="$PAYLOAD==";; 3) PAYLOAD="$PAYLOAD=";; esac ME=$(printf '%s' "$PAYLOAD" | base64 -d 2>/dev/null | grep -o '"oid":"[^"]*"' | cut -d'"' -f4) fi az role assignment create --role "Key Vault Secrets Officer" \ --assignee-object-id "$ME" --assignee-principal-type User --scope "$VAULT_SCOPE" sleep 60 az keyvault secret set --vault-name "$KV" --name "$SECRET_NAME" \ --value "Server=tcp:campux-sql.database.windows.net,1433;Database=products;Authentication=Active Directory Default;"
az keyvault secret show --vault-name "$KV" --name "$SECRET_NAME" --query value -o tsv prints the string. (If Forbidden, wait 60s and re-run the secret set line.)Create the security group
az ad group create --display-name "$GROUP_NAME" --mail-nickname "$GROUP_NICK" GROUP_ID=$(az ad group show --group "$GROUP_NAME" --query id -o tsv) echo "Group object id: $GROUP_ID"
$GROUP_ID is a GUID. The group exists and is empty.Assign the role to the GROUP (once)
This is the whole idea: one assignment, to the group, scoped to just this vault.
# A brand-new group takes a few seconds to replicate across the directory, so a # role assignment fired immediately can fail with PrincipalNotFound. Retry until # it lands — the same "wait for replication" pattern used for new identities. for i in 1 2 3 4 5 6; do if az role assignment create \ --role "Key Vault Secrets User" \ --assignee-object-id "$GROUP_ID" \ --assignee-principal-type Group \ --scope "$VAULT_SCOPE"; then echo ">>> Role assigned to the group on attempt $i"; break fi echo "attempt $i: group still replicating (PrincipalNotFound) — waiting 20s"; sleep 20 done
az role assignment list --scope "$VAULT_SCOPE" --assignee-object-id "$GROUP_ID" --query "[].roleDefinitionName" -o tsv prints Key Vault Secrets User. The group can read secrets; nobody's in it yet, so nobody has access — exactly right. (If you saw one or two PrincipalNotFound retries first, that's the new group replicating, not a mistake — --assignee-principal-type Group plus the retry is exactly how you assign a role to a just-created group.)Create two test engineers
AVA_UPN="ava.okoro@$DOMAIN"
BEN_UPN="ben.carter@$DOMAIN"
az ad user create --display-name "Ava Okoro" --user-principal-name "$AVA_UPN" \
--password "$USER_PWD" --force-change-password-next-sign-in true
az ad user create --display-name "Ben Carter" --user-principal-name "$BEN_UPN" \
--password "$USER_PWD" --force-change-password-next-sign-in true
AVA_ID=$(az ad user show --id "$AVA_UPN" --query id -o tsv)
BEN_ID=$(az ad user show --id "$BEN_UPN" --query id -o tsv)
echo "Ava=$AVA_ID Ben=$BEN_ID"
# A brand-new user can take a few seconds to replicate; pause before "member add".
sleep 15
$AVA_ID and $BEN_ID are GUIDs. Two throwaway users exist, neither with any access yet.Onboard Ava, and prove she inherited access
az ad group member add --group "$GROUP_ID" --member-id "$AVA_ID"
# The deterministic proof: is she in the group AND does the group hold the role (Step 3)?
az ad group member check --group "$GROUP_ID" --member-id "$AVA_ID" --query value -o tsv
Now see it the way an admin does — the contrast that makes it click. First what's assigned directly to Ava (nothing), then the same query including group inheritance:
echo "Directly assigned to Ava (expect empty):" az role assignment list --assignee "$AVA_ID" --scope "$VAULT_SCOPE" \ --query "[].roleDefinitionName" -o tsv echo "Effective for Ava, including groups (expect: Key Vault Secrets User):" az role assignment list --assignee "$AVA_ID" --scope "$VAULT_SCOPE" --include-groups \ --query "[].roleDefinitionName" -o tsv
true. The first list is empty; the second shows Key Vault Secrets User. That gap is the whole lesson: nothing is assigned to Ava, yet she has access — through the group. (If the second is empty too, wait 30–60s for transitive replication; some CLI builds also need --all instead of a resource --scope.)Offboard Ava, onboard Ben — the scale story
A month passes. Ava moves teams; Ben joins. Access changes with membership only — zero role-assignment edits.
az ad group member remove --group "$GROUP_ID" --member-id "$AVA_ID" az ad group member add --group "$GROUP_ID" --member-id "$BEN_ID" echo "Ava in group? $(az ad group member check --group "$GROUP_ID" --member-id "$AVA_ID" --query value -o tsv)" echo "Ben in group? $(az ad group member check --group "$GROUP_ID" --member-id "$BEN_ID" --query value -o tsv)" echo "Ben effective KV role: $(az role assignment list --assignee "$BEN_ID" --scope "$VAULT_SCOPE" --include-groups --query "[].roleDefinitionName" -o tsv)"
false, Ben → true; Ben's effective role is Key Vault Secrets User, Ava's is empty. Access revoked and granted — and you never touched a role assignment. That is access management at scale.Tear it down
az ad group member remove --group "$GROUP_ID" --member-id "$BEN_ID" 2>/dev/null az ad user delete --id "$AVA_UPN" az ad user delete --id "$BEN_UPN" az ad group delete --group "$GROUP_ID" az group delete --name "$RG" --yes az keyvault purge --name "$KV" --location "$LOCATION"
az ad group list --display-name "$GROUP_NAME" --query "[].id" -o tsv is empty and az group exists --name "$RG" is false — directory and subscription back to zero, no stray test users.What you can now honestly claim
You implemented group-based Azure RBAC: a least-privilege role assigned once to a security group, with access for individuals managed entirely by membership. Keep four things: assign roles to groups, not people; membership is the auditable, reversible access control; --include-groups answers "what can this person actually do?" including everything inherited transitively — the query you run in a real access review; and directory permissions are a different system from Azure RBAC. The Bicep piece shows the part that belongs with your infrastructure — the role assignment to an existing group — and why the group itself, a Graph object, isn't in the template.