Missing access review evidence: reconstruct records and fix the process
For developers and technical owners dealing with an audit finding on missing access reviews. This runbook shows how to reconstruct defensible evidence from identity, HR, ticketing, and repository logs, then replace the ad-hoc process with a repeatable review workflow and retention controls.
TL;DR — An auditor flagged missing access reviews because you either did the reviews but did not retain evidence, or your joiner/mover/leaver process never produced reviewable records in the first place. Start by pulling immutable-ish logs from your IdP, source control, ticketing system, and HR roster for the review period; the most common fix is to create a dated review artifact that ties group membership to reviewer approval and then automate recurring exports and retention. Reading time: ~6 min
The scenario
It is Tuesday at 3:40 PM and the audit PBC list just came back with a red comment: "No evidence of quarterly access reviews for production, source control, and cloud admin access for Q2 and Q3." You know managers looked at access at least once in Slack and maybe in a spreadsheet, but nobody can point to a signed artifact, a ticket, or even the exact membership snapshot from the review date. The auditor wants evidence by tomorrow morning, and now you need two things fast: something defensible for the missed periods, and a process that will not fail the next sample.
Symptoms
- Auditor note in the report or PBC tracker similar to:
Control failure: User access reviews were not evidenced for the selected period.
Population provided does not demonstrate reviewer, review date, scope, or disposition.
- You can list current access, but not historical access as-of the review date.
- Identity provider logs show admin changes, but no recurring review event or approval trail.
- Tickets exist for some removals, but there is no complete population-to-decision mapping.
- HR roster and active account list do not reconcile; examples:
employee_id 10428 terminated 2026-07-14 still present in group prod-admins
contractor account active with last_login 2026-09-03 and no current sponsor
- Repository/cloud/platform access is granted through nested groups or direct assignments, so a simple group export misses effective access.
- Retention gaps: logs for the sampled quarter are already expired from the provider's default retention window.
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Reviews happened informally but no evidence was retained | Very common | `grep -RniE 'access review |
| No point-in-time membership snapshot exists for the audited period | Very common | `jq -r '.timestamp' idp-audit-export.jsonl |
| Access is spread across multiple systems and the review population was incomplete | Common | python reconcile_access.py --hr hr.csv --idp groups.csv --git repo_access.csv --cloud iam.csv |
| Direct assignments / nested groups hide effective access | Common | python expand_effective_access.py --groups groups.json --members members.json --target prod-admins |
| Joiner/mover/leaver workflow is manual, so reviews are compensating for bad lifecycle control | Common | `grep -RniE 'termination |
| Audit/event retention is too short, so the evidence aged out | Less common but painful | `find ./exports -type f -printf '%TY-%Tm-%Td %p |
| ' | sort | tail -20` |
Step-by-step diagnosis
- Export what you still can from the systems of record.
- IdP/provider dashboard path: your identity provider's dashboard (e.g. Security or Audit Logs → Export) for the sampled quarter.
- Also export current membership from groups that matter: production admins, cloud admins, repo admins, VPN, database access.
- If you have APIs, pull raw JSON/CSV now before retention rolls again.
mkdir -p evidence/{idp,hr,tickets,git,cloud}
# Put exports here with original filenames and timestamps preserved.
ls -R evidence
- This is your problem if the oldest available audit event is newer than the sampled period. Jump to Fixes → Audit/event retention is too short.
- Check whether reviews existed informally.
grep -RniE 'access review|quarterly review|reviewed access|remove access|approved to retain' evidence 2>/dev/null | head -50
- If you find emails, tickets, chat exports, or spreadsheet filenames around the review date, but no consolidated artifact, jump to Fixes → Reviews happened informally but no evidence was retained.
- If nothing turns up, continue.
- Determine whether you can reconstruct point-in-time access.
jq -r 'select(.event_type=="group.membership.add" or .event_type=="group.membership.remove") | [.timestamp,.actor,.target_group,.target_user,.event_type] | @tsv' evidence/idp/*.jsonl | sort | tail -20
- If you have add/remove events covering the full period, you can rebuild membership as-of a date. Jump to step 4.
- If you only have current-state exports and sparse logs, jump to Fixes → No point-in-time membership snapshot exists.
- Reconcile population completeness across systems.
python reconcile_access.py --hr evidence/hr/roster.csv --idp evidence/idp/groups.csv --git evidence/git/repo_access.csv --cloud evidence/cloud/iam.csv --out evidence/reconcile.csv
column -s, -t < evidence/reconcile.csv | sed -n '1,20p'
- This is your problem if the output shows users with privileged access not present in the review population, or terminated users still active. Jump to Fixes → Access is spread across multiple systems and the review population was incomplete.
- Expand effective access for nested groups and direct grants.
python expand_effective_access.py --groups evidence/idp/groups.json --members evidence/idp/members.json --direct evidence/cloud/direct_assignments.csv --target prod-admins > evidence/effective_access.csv
sed -n '1,20p' evidence/effective_access.csv
- This is your problem if users appear through nested groups or direct grants that were absent from the original review list. Jump to Fixes → Direct assignments / nested groups hide effective access.
- Check whether lifecycle failures are the real root cause.
python stale_accounts.py --hr evidence/hr/roster.csv --access evidence/reconcile.csv --days 1
- If you see terminated or transferred users retaining access beyond policy, jump to Fixes → Joiner/mover/leaver workflow is manual.
View as text
[HR Roster] ---- 1. export ----> [Reconcile Script] [IdP Logs] ---- 2. membership ----> [Reconcile Script] [Git/Cloud] ---- 3. admins ----> [Reconcile Script] [Reconcile Script] ---- 4. gaps ----> [Review Artifact] [Reviewer] ---- 5. approve/remove ----> [Ticket/PR]
Fixes
Reviews happened informally but no evidence was retained
Create a retroactive review packet for the sampled period. Do not fabricate dates; label it as reconstructed evidence and include raw exports, methodology, reviewer, and remediation taken.
mkdir -p review-packet/Q3-2026/{raw,derived}
cp evidence/idp/* review-packet/Q3-2026/raw/
cp evidence/hr/* review-packet/Q3-2026/raw/
cp evidence/git/* review-packet/Q3-2026/raw/
cp evidence/cloud/* review-packet/Q3-2026/raw/
Create a reviewer decision file:
system,access_path,user,manager,employment_status,review_decision,decision_date,reviewer,evidence_ref
idp,prod-admins,alice@example.com,Bob,active,retain,2026-10-14,eng-director,TICKET-1842
cloud,direct:OrganizationAdmin,bob@example.com,Carol,active,remove,2026-10-14,cto,TICKET-1843
Store it in version control or an immutable document repository with a ticket reference.
Verify it worked:
test -f review-packet/Q3-2026/derived/review_decisions.csv && echo OK
No point-in-time membership snapshot exists for the audited period
Reconstruct membership from add/remove events if possible. If not, state the limitation explicitly and use nearest-available evidence plus current-state remediation.
python rebuild_membership.py --events evidence/idp/audit.jsonl --group prod-admins --as-of 2026-09-30T23:59:59Z > review-packet/Q3-2026/derived/prod-admins-asof.csv
sed -n '1,10p' review-packet/Q3-2026/derived/prod-admins-asof.csv
If logs are incomplete, create a gap memo in the packet:
Limitation: provider audit export available from 2026-08-15 onward only. Q2 point-in-time membership cannot be fully reconstructed. Current-state review completed on 2026-10-14; all exceptions remediated under TICKET-1843..1849.
Verify it worked:
wc -l review-packet/Q3-2026/derived/prod-admins-asof.csv
Access is spread across multiple systems and the review population was incomplete
Build one review population from all privileged systems, keyed by stable identity such as employee ID or primary email.
python reconcile_access.py --hr evidence/hr/roster.csv --idp evidence/idp/groups.csv --git evidence/git/repo_access.csv --cloud evidence/cloud/iam.csv --out review-packet/Q3-2026/derived/unified_population.csv
Then review only from unified_population.csv, not from a single system export.
Example output shape:
user,email,employee_id,hr_status,idp_privileged,git_admin,cloud_admin,db_access
Alice,alice@example.com,10411,active,yes,no,yes,no
Evan,evan@example.com,10428,terminated,yes,yes,no,no
Verify it worked:
csvcut -c email,hr_status,idp_privileged,git_admin,cloud_admin review-packet/Q3-2026/derived/unified_population.csv | head
Direct assignments / nested groups hide effective access
Flatten nested groups and eliminate direct grants where possible.
python expand_effective_access.py --groups evidence/idp/groups.json --members evidence/idp/members.json --direct evidence/cloud/direct_assignments.csv --target prod-admins > review-packet/Q3-2026/derived/effective_prod_admins.csv
Then remediate by moving direct grants into reviewed groups.
⚠️ Removing direct grants can cause immediate loss of production access. Do the add-to-group step first, validate, then remove the direct assignment.
# Pseudocode / provider-specific commands vary.
# 1) add user to reviewed group
# 2) confirm effective access still present
# 3) remove direct assignment
Verify it worked:
grep -n ',direct:' review-packet/Q3-2026/derived/effective_prod_admins.csv || echo 'no direct grants remain in reviewed scope'
Joiner/mover/leaver workflow is manual, so reviews are compensating for bad lifecycle control
Wire access revocation to HR status changes and manager changes via tickets or automation. At minimum, create an offboarding queue and a daily diff.
python stale_accounts.py --hr evidence/hr/roster.csv --access review-packet/Q3-2026/derived/unified_population.csv --days 1 > stale_accounts.csv
sed -n '1,20p' stale_accounts.csv
Create remediation tickets from the diff and require closure evidence.
user,email,issue,required_action,ticket
Evan,evan@example.com,terminated user still in prod-admins,remove from group,TICKET-1843
Verify it worked:
test ! -s stale_accounts.csv && echo 'no stale privileged accounts'
Audit/event retention is too short, so the evidence aged out
Increase retention where your provider supports it, and export logs to your own storage on a schedule. Use object storage with write-once or versioned retention if available.
mkdir -p /var/lib/access-review-exports
cat >/etc/cron.daily/export-access-review-logs <<'EOF'
#!/bin/sh
set -eu
stamp=$(date -u +%F)
out=/var/lib/access-review-exports/$stamp
mkdir -p "$out"
# Replace with provider API calls
cp /srv/current-exports/idp-audit.jsonl "$out/"
cp /srv/current-exports/group-memberships.csv "$out/"
sha256sum "$out"/* > "$out/SHA256SUMS"
EOF
chmod +x /etc/cron.daily/export-access-review-logs
Verify it worked:
run-parts --test /etc/cron.daily && ls -R /var/lib/access-review-exports | tail -20
Prevention
- Generate a dated review artifact every review cycle, not just screenshots.
quarter=$(date -u +Q%q-%Y 2>/dev/null || date -u +%Y-Q$(($(date -u +%-m - 1)/3 + 1)))
mkdir -p reviews/$quarter
cp unified_population.csv reviews/$quarter/
cp review_decisions.csv reviews/$quarter/
sha256sum reviews/$quarter/* > reviews/$quarter/SHA256SUMS
- Add a CI check that fails if privileged access exports are missing from the review packet.
#!/usr/bin/env bash
set -eu
for f in unified_population.csv review_decisions.csv SHA256SUMS; do
test -f "reviews/$REVIEW_PERIOD/$f" || { echo "missing $f"; exit 1; }
done
- Schedule a daily reconciliation against HR and page on stale privileged accounts.
python stale_accounts.py --hr /data/hr/roster.csv --access /data/access/unified_population.csv --days 1 | tee /tmp/stale.csv
if [ -s /tmp/stale.csv ]; then
mail -s 'Stale privileged accounts detected' security@example.com < /tmp/stale.csv
exit 2
fi
Exit code 2 is useful if your scheduler/monitor treats non-zero as alertable.
- Ban direct privileged assignments except break-glass accounts, and detect drift.
python detect_direct_privileged_grants.py --cloud /data/cloud/iam.csv --allowlist breakglass@example.com
Expected clean output:
OK: 0 unauthorized direct privileged grants
- Retain raw audit logs and membership snapshots longer than the audit lookback period.
{
"retention_days": 550,
"exports": [
"idp_audit_jsonl",
"group_memberships_csv",
"cloud_admins_csv",
"repo_admins_csv"
]
}
Pick a retention period that covers at least one full audit cycle plus buffer; 18 months is a practical floor for many teams.
- Put reviewer identity and disposition into tickets, not chat.
Access review decision
- Review period: 2026-Q3
- Reviewer: eng-director@example.com
- Scope: prod-admins, cloud-admins, repo-admins
- Decision: retain/remove per attached CSV
- Evidence: SHA256SUMS attached, raw exports linked
That gives you reviewer, date, scope, and disposition in one place — the exact fields auditors usually ask for.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI