Skip to content
Matthew Spell

Case study

Okta access review in AWS

A quarterly Okta access review that runs in AWS. The CISO decides every item in Slack, each fix becomes a Jira ticket, and the evidence is kept for audits.

What this shows

  • IAM judgement. 18 checks, including the leaver cases an account status doesn't show, like a service account the leaver owned that keeps running after they go.
  • Evidence an auditor can check: a SHA-256 manifest, a sign-off bound to that hash, and create-only storage under S3 Object Lock.
  • All of it runs in AWS as Terraform, deployed from GitHub Actions through OIDC, so there are no stored AWS keys.

The problem

A quarterly access review tends to be a spreadsheet. Someone marks each row keep or revoke, the revokes may or may not happen, and nobody goes back to check.

Offboarding has a gap of its own. Deactivating someone’s Okta account doesn’t touch the service accounts they own, or GitHub access that wasn’t set up through Okta. Those keep working, and a normal review never looks at them. This one does.

Be the CISO

Be the CISO

You're the CISO at Acme, a fictional company, and the quarterly access review just opened. Decide every item, sign off, and see the tickets it opens. Then change one byte of the evidence and watch the check catch it.

Nothing here is mocked up. The review, the messages and the tickets are the output of the tool's own code at one commit, and this page replays them. A test checks the replay matches the tool byte for byte.

The demo runs here once its script loads. Without JavaScript, the screenshots below show the same review.

The same review as screenshots

These are from a demo run with Acme sent through the same code the Lambda functions run. The reviewer’s name is blacked out.

Slack channel: Okta access review is open, with counts and the tracking ticket, and the report PDF in the thread
1. The review opens. The channel gets counts only and a link to the tracking ticket, with the full PDF in the thread.
Slack DM: a summary with Confirm 13 proposed, then an item card with the facts, what the decision doesn't settle, why it could be an issue, and Keep and Revoke buttons
2. The CISO decides each item in a DM. Each card shows the facts, why it could be an issue, and a proposal. Confirm N proposed accepts every proposal at once.
Slack DM: the end of the decision list, then the manifest hash and the Approve review button
3. The sign-off lists every decision, bound to the report's SHA-256, with Approve review below.
Jira Service Management: sub-tickets for leaver removals, revokes and fixes in the Acme demo
4. One Jira ticket for each leaver, each revoke and each finding to fix. The tracking ticket closes itself once every ticket is settled.

Every run also writes a PDF report. The sample report is from the same fictional company, and it’s incomplete on purpose: the demo plants four data gaps, and the report leads with them.

How it runs in AWS

An EventBridge schedule starts a Step Functions workflow each quarter. Eight Lambda functions share one arm64 container image: they read Okta, post to Slack, handle the button clicks, open the tickets, send reminders, and re-read Okta every day to check the fixes.

How a review runs: Okta is read only; in AWS a quarterly Step Functions workflow collects, sends every item to the CISO in Slack, waits for sign-off, then opens Jira tickets; an hourly job sends reminders and a daily job re-reads Okta to check resolved tickets; every record goes to a create-only S3 evidence bucket
Okta is only ever read. Every record goes to a create-only S3 bucket.

All of it is Terraform, deployed from GitHub Actions through OIDC after an approval. The only step done by hand is storing four secret values, which Terraform deliberately never holds.

Why the evidence can be trusted

  • It can’t change Okta. Okta grants only read scopes, the tool refuses any other scope, and its client can only send GET requests. A test enforces the last one.
  • Every file is hashed. manifest.json lists the SHA-256 of every file in a run, and the CISO’s sign-off is bound to that hash.
  • Nobody can quietly edit a past review. The evidence goes to S3 create-only, under Object Lock.
  • A failed read is never reported as clean. If a source can’t be read in full, the report is marked incomplete and names the source.
  • Fixes are checked. When a ticket is resolved, a daily job reads Okta again and flags the ticket if Okta still shows the problem.
  • A stolen token is useless. Private Key JWT, and DPoP binds each access token to a key that exists only in memory for that run.

SOC 2 and ISO 27001

Each of the 18 checks maps to controls in both frameworks. The full list is in the README.

What it checks SOC 2 ISO 27001:2022
Leavers whose account, API token or sign-ins outlast their last day CC6.2, CC6.3, CC7.2 A.5.18, A.8.16
Accounts with no HR record, contract end dates, inactive and unused accounts CC6.2 A.5.16, A.5.18
Missing MFA CC6.1 A.8.5
Admin users, and service apps that can make changes CC6.3 A.8.2
Service accounts and credentials a leaver owned or held CC6.1, CC6.2, CC6.3 A.5.16, A.5.17, A.5.18, A.8.2
The tool’s own changes: pull requests, required checks, signed evidence CC8.1 A.8.29, A.8.32

Trade-offs

  • Only Super Administrator can read admin role assignments in Okta. The app has it, paired with read-only scopes, and flags itself for review on every run.
  • The HR roster is a CSV export passed in at run time. There’s no live HR integration yet.
  • GitHub is read from a snapshot file. The AWS pipeline doesn’t pass one yet, so its runs are Okta only.
  • MFA status comes from enrolled factors. It doesn’t check whether a sign-on policy requires MFA.
  • Some fixes are taken on the reviewer’s word, because Okta can’t show them. A rotated client secret is one.

Run it yourself

The demo needs no Okta, AWS, Slack or Jira. With uv installed, clone the repository and run:

uv run access-review \
  --snapshot fixtures/demo_snapshot.json \
  --roster fixtures/demo_roster.csv \
  --config fixtures/demo_config.json \
  --github fixtures/demo_github.json \
  --as-of 2026-09-15

That reviews Acme, which has at least one planted case for each check, and writes the report to reports/. To run the whole AWS workflow in memory, Slack and Jira included, run uv run python scripts/e2e_local.py.