A quarterly access review in one hour: accounts, sudo, SSH keys and SSO against the staff list — with script
ISO 27001 A.5.18 and NIS2 Art. 21(2)(i) require you to periodically check who has access and whether that's still right. In practice that becomes an afternoon of spreadsheets, or it doesn't happen. This script turns the review into an hour: it lays the technical inventory (servers, sudo, keys, IdP accounts) next to the HR list, flags every deviation, and produces the signed report an auditor accepts.
Contents
The access review is the piece of evidence missing from almost every audit, and not because nobody thinks it's important. It's missing because the question "who has access to what, and should they?" touches five sources — servers, the IdP, the VPN, the cloud console, HR — and nobody gets those five side by side in one day. This article automates the collecting and the comparing, so the human only has to make the judgement. That judgement is the only thing that can't be scripted; it's also the only thing the auditor really wants to see.
What an access review must produce
To an auditor (ISO 27001 A.5.18, NIS2 Art. 21(2)(i), CyFun Important ID.AM/PR.AC) the result counts, not the method. The result is a document with:
- Date and scope — which systems, which period.
- The list — per person: which accounts, which rights, last use.
- The deviations — accounts without a person, people without the accounts they should have, rights that don't match the role, keys without an owner, accounts unused > 90 days.
- The decision per deviation — keep (with reason), remove (with date), or investigate (with owner).
- Signature of the reviewer, and evidence that the removals were carried out.
Everything except point 4 can be scripted.
Step 1: collect the sources
Servers. Use the inventory from sudo and authorized_keys fleet-wide; it produces per host user, sudo, key and lastlogin lines. For this review one run suffices:
R=/srv/access-review/$(date +%Y-Q$(( ($(date +%-m)-1)/3+1 ))); mkdir -p "$R"
while read -r h; do ssh -o BatchMode=yes -o ConnectTimeout=5 "$h" sudo /usr/local/sbin/access-inventory.sh; done < /srv/inventory/hosts.txt > "$R/servers.tsv"
IdP (SSO). Every IdP has an export. Three examples; the result is always email,name,role,mfa,last_login:
# Keycloak (admin-cli)
kcadm.sh config credentials --server https://sso.example.be --realm master --user admin
kcadm.sh get users -r example --fields username,email,enabled,attributes -q max=1000 | jq -r '.[] | [.email, .username, (.enabled|tostring)] | @csv' > "$R/idp.csv"
# Authentik
curl -s -H "Authorization: Bearer $AK_TOKEN" 'https://sso.example.be/api/v3/core/users/?page_size=1000' \
| jq -r '.results[] | [.email, .name, (.is_active|tostring), (.last_login // "never")] | @csv' > "$R/idp.csv"
# Microsoft Entra ID (Graph, with an app registration holding User.Read.All)
curl -s -H "Authorization: Bearer $GRAPH_TOKEN" 'https://graph.microsoft.com/v1.0/users?$select=mail,displayName,accountEnabled,signInActivity&$top=999' \
| jq -r '.value[] | [.mail, .displayName, (.accountEnabled|tostring), (.signInActivity.lastSignInDateTime // "never")] | @csv' > "$R/idp.csv"
Cloud and the rest. aws iam list-users, az ad user list, the VPN server (wg show peers, or the OpenVPN CA index), the database (\du in PostgreSQL). Each as a csv in $R/. For the first review take the three most important; the rest follows.
HR. A csv with email,name,role,employed_since,left. This is the one file you can't generate; request it from HR with a fixed quarterly deadline. Without an HR list an access review is an inventory, not a review.
Step 2: one identity per person
Server accounts are called jpeeters, the IdP says jan.peeters@example.be, HR says Peeters, Jan. A mapping file is unavoidable — create it once, keep it up to date:
# /srv/access-review/identities.csv — email,server_user,idp_user,cloud_user
jan.peeters@example.be,jpeeters,jan.peeters,jan.peeters
deploy@example.be,deploy,, # service account: owner is in owners.csv
ci@example.be,ci-runner,,ci-runner
Service accounts get an owner in owners.csv (account,owner_email,purpose,expiry). A service account without an owner is by definition a deviation.
Step 3: the script — compare and flag deviations
sudo tee /usr/local/sbin/access-review.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# access-review.sh <review dir> — expects servers.tsv, idp.csv, hr.csv, identities.csv, owners.csv
set -euo pipefail
R=$1; OUT=$R/findings.tsv; : > "$OUT"
f() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$OUT"; } # type, subject, detail, suggested action
# Helper tables
awk -F, 'NR>1 {print $2}' "$R/identities.csv" | grep -v '^$' | sort -u > /tmp/known-server-users
awk -F, 'NR>1 && $5=="" {print tolower($1)}' "$R/hr.csv" | sort -u > /tmp/hr-active
awk -F, 'NR>1 && $5!="" {print tolower($1)"\t"$5}' "$R/hr.csv" | sort -u > /tmp/hr-left
awk -F, 'NR>1 {print $1}' "$R/owners.csv" | sort -u > /tmp/owned-svc
# 1. Server accounts linked to nobody (no identity, no service owner)
awk -F'\t' '$2=="user" {print $3}' "$R/servers.tsv" | sort -u | while read -r u; do
grep -qx "$u" /tmp/known-server-users || grep -qx "$u" /tmp/owned-svc || f orphan-account "$u" "on $(awk -F'\t' -v u="$u" '$2=="user" && $3==u {print $1}' "$R/servers.tsv" | sort -u | paste -sd, -)" "link to person/owner or remove"
done
# 2. People who left with access still active
while IFS=$'\t' read -r mail left; do
su=$(awk -F, -v m="$mail" 'tolower($1)==m {print $2}' "$R/identities.csv")
[ -n "$su" ] && grep -qP "\tuser\t$su\t" "$R/servers.tsv" && f left-but-active "$mail" "server account $su, left $left" "REMOVE today"
grep -qi "^\"\?$mail" "$R/idp.csv" && grep -qi "$mail.*true" "$R/idp.csv" && f left-but-active "$mail" "IdP account active, left $left" "DEACTIVATE today"
done < /tmp/hr-left
# 3. Sudo on hosts where the role doesn't call for it
awk -F'\t' '$2=="sudo" {print $3"\t"$1}' "$R/servers.tsv" | sort -u | while IFS=$'\t' read -r u h; do
mail=$(awk -F, -v u="$u" '$2==u {print $1}' "$R/identities.csv")
role=$(awk -F, -v m="$mail" 'tolower($1)==m {print $3}' "$R/hr.csv")
case "$role" in *sysadmin*|*devops*|*ops*|"") ;; *) f sudo-outside-role "$u" "sudo on $h, role: $role" "confirm or restrict to commands" ;; esac
done
# 4. Keys without comment or of a forbidden type
awk -F'\t' '$2=="key" && ($5 ~ /^(RSA 1024|DSA)/ || $5 ~ /no comment|^[A-Z0-9]+ *$/) {print $3"\t"$1"\t"$5}' "$R/servers.tsv" | sort -u \
| while IFS=$'\t' read -r u h k; do f weak-or-unowned-key "$u@$h" "$k" "replace with ed25519 with comment, or remove"; done
# 5. Unused > 90 days (server: lastlogin from journal; IdP: last login)
cut90=$(date -d '-90 days' +%F)
awk -F'\t' '$2=="user" {print $3}' "$R/servers.tsv" | sort -u | while read -r u; do
last=$(awk -F'\t' -v u="$u" '$2=="lastlogin" && $3==u {print $4}' "$R/servers.tsv" | sort | tail -1)
[ -z "$last" ] && f unused-90d "$u" "no SSH login in journal history" "remove or document why needed" && continue
[[ "${last:0:10}" < "$cut90" ]] && f unused-90d "$u" "last login ${last:0:10}" "remove or document"
done
# 6. IdP accounts without MFA (column depends on your export; here: 5th column = mfa)
awk -F, 'NR>1 && tolower($5)=="false" {print $1}' "$R/idp.csv" 2>/dev/null | while read -r m; do f no-mfa "$m" "IdP account without MFA" "enforce MFA"; done
sort -u "$OUT" -o "$OUT"
echo "$(wc -l < "$OUT") findings → $OUT"
column -t -s $'\t' "$OUT" | head -40
EOF
sudo chmod 0755 /usr/local/sbin/access-review.sh
sudo /usr/local/sbin/access-review.sh "$R"
The output is a tsv with four columns: type, subject, detail, suggested action. That's the list the reviewer walks through — and usually it's between five and thirty lines, not hundreds.
Step 4: the judgement (the hour)
Open findings.tsv in a spreadsheet or editor and fill in a fifth column per line: keep:<reason>, remove:<date>, or investigate:<owner>. Rules:
left-but-activeis neverkeep. Remove, today, and log the removal (step 5).orphan-accountgets an owner or disappears. "Nobody knows" isremove.sudo-outside-rolewithkeepneeds a one-sentence reason ("on-call rotation Q4"). It comes back next quarter.unused-90dwithkeepneeds a planned usage date. Otherwiseremove; an account is recreated in ten seconds.no-mfaisremovefor the access, not for the account: force MFA at the next login.
Save it as findings-reviewed.tsv. That file is the review.
Step 5: execute, prove, sign
# Execute and log removals — per line with remove:
awk -F'\t' '$5 ~ /^remove/' "$R/findings-reviewed.tsv" | while IFS=$'\t' read -r type subj detail action decision; do
case $type in
orphan-account|unused-90d|left-but-active)
u=${subj%%@*}
for h in $(awk -F'\t' -v u="$u" '$2=="user" && $3==u {print $1}' "$R/servers.tsv" | sort -u); do
ssh "$h" "sudo userdel -r '$u' 2>&1" && echo "$(date -Is) removed $u from $h ($type)" >> "$R/actions.log"
done ;;
weak-or-unowned-key) echo "$(date -Is) TODO manual: remove key $detail for $subj" >> "$R/actions.log" ;;
esac
done
# Report: findings + decisions + actions, hashed and signed (see the evidence guide for the key)
( cd "$R" && sha256sum servers.tsv idp.csv hr.csv findings.tsv findings-reviewed.tsv actions.log > MANIFEST.sha256 )
ssh-keygen -Y sign -f /etc/evidence/key -n access-review "$R/MANIFEST.sha256"
echo "$(date -Is) access-review $(basename "$R") by $(whoami): $(wc -l < "$R/findings.tsv") findings, $(grep -c remove "$R/findings-reviewed.tsv") removed" | sudo tee -a /srv/inventory/access-reviews.log
The quarterly report now consists of: the inventory (what was there), the findings (what deviated), the decisions (what you thought of it), the actions (what you did), a manifest with hashes and a signature. That's exactly what ISO 27001 A.5.18 means by "review at planned intervals" and what an auditor wants to see.
Pitfalls
- The HR list doesn't arrive. Without
hr.csvyou can't compute deviations 2 and 3. Agree the deadline in HR's calendar, not IT's; and let the script fail loudly if the file is older than 30 days. - Shared accounts.
deploywith eight keys is one account with eight people behind it. The script sees one owner. Split them (personal accounts + sudo rule), otherwise the review is incomplete by definition. - Review without execution. A list of thirty
removeof which 28 still exist next quarter is worse than no review: it proves you knew. That's whatactions.logis for. - Servers only. The database role with superuser, the S3 bucket policy, the GitHub org owner: access is everywhere. Start with three sources, add one per quarter, and write in the scope what you don't cover (yet).
- Privacy.
hr.csvcontains personal data; so does the review directory. Restricted access, agreed retention (three years is common), and don't paste it into a shared chat.
What you still don't have
- Continuous instead of quarterly. An account created on day 2 after the review stays out of sight for 88 days. The daily diff from the fleet guide catches that for servers; for the IdP you need a webhook or a daily export.
- One identity across all systems.
identities.csvis manual work and goes stale. With thirty employees that's fine; with three hundred it isn't. - Evidence the reviewer read it. A signature over the manifest proves integrity, not attention. Some auditors ask for initials per finding; a
decided_bycolumn with a date is the pragmatic version.
How monsys does it
monsys collects the server side continuously (accounts, sudo, keys, last login per host) and links IdP accounts to dashboard users via the SSO integration. The access review report (ISO 27001 A.5.18) is generated automatically every quarter: all accounts with role, MFA status and last activity, all scoped role assignments, the SSO configuration without secrets, and all access mutations from the audit log in the period — Ed25519-signed, byte-stable, offline-verifiable. The assessment remains human work; you record the decisions in the report, and removals that go through monsys (agent tokens, dashboard users) appear as evidence in the next one.
FAQ
How often should an access review happen?
ISO 27001 says "at planned intervals"; the common interpretation and what auditors expect is every quarter for administrator access and at least annually for regular users. Plus a targeted check within 24 hours of every departure.
What if HR can't provide a list?
Then you start with what you have (the IdP as the best approximation of "who works here") and write explicitly in the scope that the HR link is missing. An auditor values an honest scope more than a review that suggests completeness.
Does every finding need a decision?
Yes. A finding without a decision is an open item the auditor counts as "not reviewed". investigate:<owner> is a valid decision, provided the next review contains an outcome.
Written by the monsys team — sysadmins who do this every day.
Done it by hand? Let monsys keep it running.
Everything in this guide runs in monsys as a continuous check, with history, alerts and audit evidence. 5 servers free, EU-hosted in Belgium, installed in 60 seconds.