Engineering reference: the access review reporter architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the credential handling, and the write permissions it deliberately lacks.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- App integration
- Management
- People
- Outside AWS
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
ar-collect | EventBridge weekly | One service at a time, with a delay; stores a dated snapshot | 300s / 512 MB |
ar-match | SQS snapshot queue | Email, alias and name matching; classifies unmatched accounts | 30s / 512 MB |
ar-review | EventBridge weekly + twice-yearly + Function URL | Leaver report, review rounds, and the signed response links | 30s / 512 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
ar-collect-role | secretsmanager:GetSecretValue, dynamodb:PutItem | One secret per service, named individually; the accounts table |
ar-match-role | dynamodb:UpdateItem, secretsmanager:GetSecretValue | Accounts; the staff-list credential only |
ar-review-role | dynamodb:Query, ses:SendEmail | Accounts and reviews; one verified identity |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: accounts
PK service S filestore
SK account_id S the service’s own identifier
email S as the service holds it
display_name S as the service holds it
last_used S where the API exposes it — the useful column
matched_person S or null
match_method S email | alias | name | none
unmatched_kind S leaver | contractor | shared | service | unknown
first_seen S 2026-02-14
last_seen S 2026-08-04 — absence is how removal is confirmed
tier S api | export | manual
`last_seen` not updating is how a removal is verified. A checklist tick is
a claim; a missing account in next week’s snapshot is evidence.
Table: reviews
PK round S 2026-H2
SK service S filestore
owner S the person asked
sent_at S 2026-08-04
state S reviewed | removals_requested | no_reply
answered_at S or null
removals L [{account_id, requested_by, done_at}]
Three states, never a boolean. An `approved` boolean defaulting to false is
how a review record starts counting silence as approval.
Inbound and outbound
- One secret per service, named individually in the IAM policy. A single credential that can read every service’s user list would make this system a more attractive target than any service it reviews.
- Read-only scopes wherever offered, and a recorded note where a service only exposes user listing to a full admin credential.
- Sequential collection with a delay. Several SaaS user APIs rate limit aggressively, and there is no reason to hurry a weekly job.
- No write permission to any reviewed service. Not disabled, not commented out — absent from the credential scope, so it cannot be added by changing code.
The model call
- There is no model in this system. Matching is exact comparison, alias lookup and an exact-name check.
- Fuzzy name matching is deliberately avoided. A close-but-wrong match attaches an account to the wrong person, which is worse than reporting it unmatched.
- Classifying an unmatched account as shared or service uses a pattern list you maintain, not a judgement, so the classification is stable between runs.
- If a model were used anywhere it would be to suggest a classification for a new unmatched account, and a person confirming it is faster than reading a model’s reasoning.
- The cost page assumes none, which is why there is no read band.
Things worth knowing before you build it
- Split leavers from entitlements. They are different questions on different cadences, and conflating them produces a quarterly rubber stamp that catches nothing.
- Default to keep in the review. Default-remove makes busy owners approve everything to avoid breaking somebody’s access.
- Use three states, never an approved boolean. Silence must never be recordable as approval.
- Verify removals from the next snapshot. A ticked checklist is a claim; an absent account is evidence.
- Watch for a service that never appears on a leaver list. That is usually a broken collector rather than perfect offboarding.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts