Skip to content

Part 7 of 7 · Access review reporter series ~7 min read

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

The access review reporter drawn with AWS service namesThree boxes across the top outside the AWS account. Service APIs, providing user lists read-only. The Staff list, read through the Google Sheets API read-only. And SES outbound, carrying leaver findings and review rounds. Inside the account, three groups. EventBridge carrying a weekly collection schedule and a twice-yearly review schedule. Three Lambda functions named collect, match and review. And two DynamoDB tables named accounts and reviews. A note gives the region as us-east-1, one account, and states there is no write permission to any reviewed service, ever.AWS ACCOUNTService APIsuser lists, read-onlyStaff listSheets API, read-onlySES outboundleavers, review roundsEventBridgeweekly collect,twice-yearly reviewLambda x3collect, match, reviewDynamoDB x2accounts, reviewsingroundsoutus-east-1. One account. No write permission to any reviewed service, ever.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
ar-collectEventBridge weeklyOne service at a time, with a delay; stores a dated snapshot300s / 512 MB
ar-matchSQS snapshot queueEmail, alias and name matching; classifies unmatched accounts30s / 512 MB
ar-reviewEventBridge weekly + twice-yearly + Function URLLeaver report, review rounds, and the signed response links30s / 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

RoleAllowedOn
ar-collect-rolesecretsmanager:GetSecretValue, dynamodb:PutItemOne secret per service, named individually; the accounts table
ar-match-roledynamodb:UpdateItem, secretsmanager:GetSecretValueAccounts; the staff-list credential only
ar-review-roledynamodb:Query, ses:SendEmailAccounts 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