An IAM access-key census in Rust a cargo script against a local emulator, then a two-crate workspace with one Nix check per crate
A null last-used date is an answer, not a gap. Put the rules where nothing slow has to compile before they can fail.
The problem
Take an AWS account with a dozen IAM (AWS Identity and Access Management) users, some of whom hold long-term access keys. An access key is an access key ID plus a secret, and an application uses the pair to sign API (application programming interface) calls. Nobody can say at a glance which keys are old, which were never used, which are switched off but never deleted, and which users are halfway through a rotation with two keys live.
AWS allows at most two access keys per user and recommends temporary credentials, such as IAM roles, wherever possible [4]. Its documented rotation procedure is: create a second key, move the applications over, confirm the old key is idle with GetAccessKeyLastUsed, deactivate it, and only later delete it [2]. Doing that by hand across many users is slow and easy to forget.
The team wants a census that answers with an exit code, so that a CI (continuous integration) job can run it and fail when there is cleanup to do. Every run shown below went against a local AWS emulator (moto) on 127.0.0.1 with throwaway credentials. No AWS account was involved.
The idea, in plain words
The census lists every user, lists each user's keys by name, asks IAM when each key was last used, and applies a small set of rules with thresholds the team chooses. The lab uses 90 days as the maximum key age and 45 days as the idle limit. These are the lab's policy values, not AWS rules, and a different team would pick different numbers. The prototype is one file that can be run directly as a cargo script [8]. The rules then move into their own crate (a Rust package) with no dependencies, so they compile and test in seconds. Nix then builds one check per crate [10][11], so a regression in the rules fails without compiling the AWS SDK (software development kit). Two sentences carry the rest: a null last-used date is an answer, not a gap, and the rules belong where nothing slow has to compile before they can fail.
Concept A: the three IAM calls and what "last used" means
An access key is a long-term credential for an IAM user [4]. The census needs three calls. ListUsers returns the account's users. ListAccessKeys returns the key metadata (ID, status, creation date) for one user. GetAccessKeyLastUsed returns when one key was most recently used. The third call matters because the first two cannot tell a busy key from a forgotten one.
IAM reports a null LastUsedDate, with Region and ServiceName set to "N/A", when the key "has not been used since IAM began tracking this information" on April 22, 2015 [1]. A null therefore means "never used as far as IAM knows". Here is the census loop from census/src/main.rs:
let mut pages = iam.list_users().into_paginator().page_size(page_size).send();
while let Some(page) = pages.next().await {
let page = page.map_err(|e| format!("ListUsers: {}", DisplayErrorContext(&e)))?;
stats.pages += 1;
users.extend(page.users().iter().map(|u| u.user_name().to_string()));
}
stats.users = users.len();
let mut findings = Vec::new();
for user in &users {
// Name the user: without UserName, IAM lists the keys of whoever signed the call.
let listed = iam
.list_access_keys()
.user_name(user)
.send()
.await
.map_err(|e| format!("ListAccessKeys {user}: {}", DisplayErrorContext(&e)))?;
The first half collects user names across all pages. The second half walks those names, and for each one calls list_access_keys with .user_name(user). Inside the inner loop (not shown here), get_access_key_last_used is called once per key ID, and the answers are copied into plain structs for the rules. The comment in the code records the reason for naming the user: the API documentation says "If the UserName is not specified, the user name is determined implicitly based on the AWS access key ID used to sign the request" [3].
The lab demonstrated that behavior with the AWS command line tool (not the census binary), signing with alice's key:
alice AKIARZPUZDIKJVKNBBN5 Active
[exit 0]
--- the same call with --user-name dave
dave AKIARZPUZDIKLLH44CLW Active
dave AKIARZPUZDIKLDJPW2UX Active
[exit 0]
Without a user name the call returned only alice's single key. With --user-name dave the same credentials returned dave's two keys. The census never relies on that implicit rule, because one signer's keys say nothing about the rest of the account. The lab did not test a signer that has no keys of its own, such as a role.
Symptom: The census runs clean in CI and reports only one key, or none, across the whole account.
Cause: ListAccessKeys was called without UserName, so IAM listed only the keys of whoever signed the request [3], which is one identity's keys at most.
Fix: Always pass UserName from the ListUsers loop, as the census does.
Concept B: the rules as plain data, and the cargo script prototype
A cargo script is a single .rs file that Cargo runs directly with cargo +nightly -Zscript file.rs. Its Cargo.toml lives in a ---cargo frontmatter fence at the top of the file [8]. The feature is nightly only, so a stable toolchain will refuse it. The lab's script starts like this:
#!/usr/bin/env -S cargo +nightly -Zscript
---cargo
[package]
edition = "2024"
[dependencies]
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-iam = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
---
The shebang line lets the file execute as ./iam-key-census.rs. The fence holds ordinary TOML (Tom's Obvious Minimal Language) dependency lines, so the prototype needs no project directory. The rules sit in the same file as an inline module so that the later split is a copy, not a rewrite.
The rules are one function over plain data. Times are Unix seconds (seconds since 1970-01-01 UTC) so tests can use fixed dates:
pub fn classify(keys: &[KeyFacts], now: i64, p: Policy) -> Vec<Finding> {
let mut out = Vec::new();
let mut active_per_user: BTreeMap<&str, usize> = BTreeMap::new();
for k in keys {
if !k.active {
out.push(Finding::Inactive { user: k.user.clone(), key_id: k.key_id.clone() });
continue;
}
*active_per_user.entry(k.user.as_str()).or_default() += 1;
let age_days = (now - k.created) / DAY;
if age_days > p.max_age_days {
out.push(Finding::Rotate { user: k.user.clone(), key_id: k.key_id.clone(), age_days });
}
// A key that was never used has been idle since it was created.
let idle_days = (now - k.last_used.unwrap_or(k.created)) / DAY;
if idle_days > p.idle_days {
out.push(Finding::Idle {
user: k.user.clone(),
key_id: k.key_id.clone(),
idle_days,
never_used: k.last_used.is_none(),
});
}
}
for (user, n) in active_per_user {
if n >= 2 {
out.push(Finding::TwoActive { user: user.to_string() });
}
}
out
}
Four rules are visible. Rotate fires when age is strictly greater than max_age_days, so a key exactly 90 days old is not yet due. Idle measures from last_used, or from created when the key was never used. An inactive key is reported once and then skipped with continue, because aging a key that is already switched off would only repeat the same warning. TwoActive fires when a user has two or more active keys. The IAM guide calls that state normal mid-rotation ("At this point, the user has two active access keys" [2]), so the label reads "rotation in progress or forgotten". The never_used field is what prints "never used" instead of "last used" in the report.
Fresh emulator keys are minutes old, so a run today shows little. The --as-of flag takes an RFC 3339 timestamp and uses it as "now", so the same five keys can be judged as if time had passed:
INACTIVE carol AKIARZPUZDIKM4AFQLJT delete when nothing breaks
TWO-ACTIVE dave rotation in progress or forgotten
users=12 pages=1 keys=5 findings=2
[exit 1]
That is step 1.1: today, only carol's switched-off key and dave's pair. Step 1.2 used as-of 2027-02-02T17:43:51Z, 100 days later:
ROTATE alice AKIARZPUZDIKJVKNBBN5 age=100d
IDLE alice AKIARZPUZDIKJVKNBBN5 last used, idle=100d
ROTATE bob AKIARZPUZDIKOZRAQ5WC age=100d
IDLE bob AKIARZPUZDIKOZRAQ5WC never used, idle=100d
INACTIVE carol AKIARZPUZDIKM4AFQLJT delete when nothing breaks
ROTATE dave AKIARZPUZDIKLLH44CLW age=100d
IDLE dave AKIARZPUZDIKLLH44CLW last used, idle=100d
ROTATE dave AKIARZPUZDIKLDJPW2UX age=100d
IDLE dave AKIARZPUZDIKLDJPW2UX never used, idle=100d
TWO-ACTIVE dave rotation in progress or forgotten
users=12 pages=1 keys=5 findings=10
[exit 1]
Every active key exceeds both limits, giving eight ROTATE and IDLE lines, plus carol's INACTIVE line and dave's TWO-ACTIVE line. Bob's key and dave's second key say "never used", because IAM had no last-used date and the rule fell back to creation time.
| Exit code | Meaning |
|---|---|
| 0 | No findings |
| 1 | Findings to act on |
| 2 | Usage or API error |
Keeping 2 separate from 1 lets CI tell "the account needs cleanup" from "the job itself is broken". Step 1.4 pointed the census at a port where nothing listens, and step 1.5 passed a date it cannot parse:
census failed: ListUsers: dispatch failure: io error: client error (Connect): tcp connect error: Connection refused (os error 61) (DispatchFailure(DispatchFailure { source: ConnectorError { kind: Io, source: hyper_util::client::legacy::Error(Connect, ConnectError("tcp connect error", 127.0.0.1:5999, Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })), connection: Unknown } }))
[exit 2] after 1s
--as-of yesterday: invalid date-time: Smithy does not support timezone offsets in RFC-3339 date times
[exit 2]
Both end in exit 2 within a second, so a scheduler can page someone about the job instead of filing cleanup tickets. The first line is long, but its useful parts are near the front: the operation that failed (ListUsers), the kind of failure (dispatch failure, meaning the request never reached a service), and the refused address. The date error comes from the SDK's own timestamp parser, which wants a full RFC 3339 value such as 2027-02-02T17:43:51Z.
Symptom: A key that was never used and was created long ago never shows up as IDLE.
Cause: Idle time was computed only from LastUsedDate and skipped when it was null, although null means never used [1].
Fix: Fall back to the creation date, as unwrap_or(k.created) does, and keep a test for it (never_used_key_idles_from_creation).
Concept C: talking to an emulator, and paging
A local emulator is a program that answers the same HTTP API as a cloud service, so that code can be tested without an account. An endpoint override tells the SDK to send requests to a different address. The SDKs read AWS_ENDPOINT_URL to "route requests for all services to a custom endpoint URL", and the SDK for Rust supports it [7]. The lab's env.sh sets it:
# Point the AWS CLI and the SDK at the local IAM emulator (moto) with throwaway credentials.
export AWS_ENDPOINT_URL=http://127.0.0.1:5000
export AWS_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=testing AWS_SECRET_ACCESS_KEY=testing
unset AWS_PROFILE
Sourcing this file before running the script or the CLI sends everything to port 5000 on the local machine, and the fake credentials are never checked against a real account. Precedence matters if a config already exists: an explicit setting in code wins, then a service-specific variable such as AWS_ENDPOINT_URL_DYNAMODB, then AWS_ENDPOINT_URL, then the shared config file, and last the default endpoint [7]. The census sets nothing in code, so the environment variable decides. The emulator proves the control flow, not permissions: it accepted testing/testing without complaint, so what a real policy would allow was not tested.
Pagination means a service returns results in portions plus a token for the next portion [5]. For IAM, "IAM might return fewer results, even when there are more results available" and callers "should check IsTruncated after every call"; MaxItems defaults to 100 [3]. The Rust SDK's into_paginator() does this bookkeeping, with .page_size(10) setting results per response and while let Some(result) = paginator.next().await walking the pages [5]. The lab checked what the emulator did with a page size of 5:
'MaxItems': 5
<IsTruncated>false</IsTruncated>
The request carried MaxItems 5, yet the response said it was not truncated, and the census reported users=12 pages=1. The emulator ignored the page size and returned all twelve users in one page. This lab therefore did not exercise multi-page responses. The paginator is used because the documentation requires it, not because the lab proved it.
Symptom: The census reports exactly 100 users, or another round number, in a large account and misses the rest.
Cause: One ListUsers call was read without following IsTruncated and Marker [3].
Fix: Use the SDK paginator [5] for every list call, and test paging against something that truly pages, since this emulator did not.
Concept D: the workspace split and one Nix check per crate
A Cargo workspace is a set of packages managed together. Members share one Cargo.lock in the workspace root and one target output directory, and -p selects a package for a command [9]. A root Cargo.toml with a [workspace] section and no [package] section is a virtual workspace, which must set resolver explicitly because there is no package edition to infer it from [9].
[workspace]
members = ["core", "census"]
resolver = "2"
[package]
name = "keyage-core"
version = "0.1.0"
edition = "2024"
# No dependencies on purpose: the policy compiles and tests in seconds.
[dependencies]
The first file lists the two members. The second is the rules crate, whose empty [dependencies] table is the point of the split: nothing from AWS or tokio is needed to test a rule, so a failing rule can show up without a long compile. The census crate depends on it with keyage-core = { path = "../core" }. Step 2.1, cargo test -p keyage-core, ran 5 tests and all passed. Step 2.2 counted unique crates in each dependency tree: keyage-core: 1 unique crates against iam-key-census: 236 unique crates.
flake-parts is a Nix module framework for flakes. Its perSystem option is "A function from system to flake-like attributes omitting the <system> attribute", and systems lists "All the system types to enumerate" [11]. Entries under perSystem named packages and checks become packages.<system>.<name> and checks.<system>.<name>. Here is the whole flake.nix:
{
description = "iam-key-census: an IAM access-key census, with a Nix check per crate";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "aarch64-darwin" "x86_64-linux" "aarch64-linux" ];
perSystem = { pkgs, lib, self', ... }:
let
# One source tree and one Cargo.lock for the whole workspace.
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [ ./Cargo.toml ./Cargo.lock ./core ./census ];
};
crate = subdir: attrs: pkgs.rustPlatform.buildRustPackage ({
version = "0.1.0";
inherit src;
cargoLock.lockFile = ./Cargo.lock;
buildAndTestSubdir = subdir;
} // attrs);
in
{
packages.default = crate "census" {
pname = "iam-key-census";
nativeBuildInputs = [ pkgs.cmake ];
};
# One check per crate, so a failure names the crate that broke.
checks = {
keyage-core = crate "core" {
pname = "keyage-core-tests";
# A library has no binary to install; the check is the test run.
installPhase = "touch $out";
};
iam-key-census = self'.packages.default;
};
};
};
}
The src value copies only the two Cargo files and the two crate directories into one source tree. The crate helper wraps buildRustPackage, reading dependency hashes from Cargo.lock through cargoLock.lockFile and choosing which crate to build through buildAndTestSubdir [10]. The same helper builds both outputs, so they share one lock file. The core check sets installPhase = "touch $out" because a library has nothing to install, and Nix still needs an output path; the useful work is the test run, which is enabled by default and "runs cargo test on the package to build" [10]. The census check simply reuses self'.packages.default, this flake's own package for the current system [11]. The package also lists cmake as a native build input; the lab did not test whether it is needed.
Step 2.3, nix flake show, listed exactly two checks and one package for aarch64-darwin, with the Linux systems "omitted (use '--all-systems' to show)":
│ │ ├───iam-key-census: CI test
│ │ └───keyage-core: CI test
Building each check cold (step 2.4) shows the cost gap: keyage-core: exit 0 in 19s with 5 passed, and iam-key-census: exit 0 in 147s with 0 passed. The census check ran zero tests because it only tests the census crate, which has none. Step 2.5, nix flake check, printed "(previously built)" for both, then running 0 flake checks..., and the warning "The check omitted these incompatible systems: aarch64-linux, x86_64-linux", and exited 0. Nothing was rebuilt because both results were already in the store. The --all-systems flag checks every system's outputs [12], though the lab did not run it.
Then the rules were broken on purpose (step 2.6): > became >= in the age rule, and only the core check was rebuilt. It failed in 6 seconds:
keyage-core-tests> test tests::exactly_max_age_is_not_yet_due ... FAILED
keyage-core-tests> assertion failed: classify(&[key("K1", true, 90, Some(1))], NOW, P).is_empty()
keyage-core-tests> test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: Cannot build '/nix/store/gk5iwgwb9c0jhbbf4wycdg5wryv6kkh7-keyage-core-tests-0.1.0.drv'.
The output names the test, the assertion, the count of "4 passed; 1 failed", and a derivation (a Nix build recipe) called keyage-core-tests, so the broken crate is identified without reading logs. One trade-off needs saying plainly: the census depends on core, so editing core also invalidates the census package. The gain is that the core check finishes and fails first, not that the census is spared. The lab did not time a census rebuild after a core edit. Step 2.7, nix run . against the emulator, printed the same two findings and [exit 1].
Symptom: A rule change passes nix flake check although a unit test in the rules crate should fail.
Cause: The only check builds the binary crate, and buildAndTestSubdir runs cargo test for that crate only (step 2.4 shows 0 tests), so the library's tests never run [10].
Fix: Give each crate its own check, as checks.keyage-core does, or pass test flags such as --package through cargoTestFlags [10].
Putting it together
The job runs in order. On every change, nix flake check runs both checks, and a bad rule fails the core check within seconds, long before the SDK compiles. On a schedule, the packaged census runs with the team's own thresholds. Exit 1 becomes a ticket list. ROTATE and IDLE keys follow the documented sequence: create a second key, move the applications, confirm with GetAccessKeyLastUsed, deactivate, and delete later [2]. INACTIVE keys are deletion candidates once nothing breaks, because an inactive key can still be reactivated if something does [2]. TWO-ACTIVE means someone must finish or roll back a rotation. Exit 2 means the job itself is broken and nobody should read its output as an account result.
What was verified: aws-sdk-iam 1.131.0 (with aws-config 1.13.0 and tokio 1.54.1) against moto 5.2.4.dev0, with the AWS command line tool 2.38.5 used for seeding; the cargo script on nightly cargo 1.101.0; and the Nix build and nix flake check on aarch64-darwin with nixpkgs rustc 1.98.1, with the sandbox off.
What was not verified: any real AWS account, real permissions, or rate limits; multi-page responses, since the emulator returned one page; the Linux systems, which were omitted from every check; and service accounts that use roles instead of keys, which AWS recommends [4] and which this census does not look at.
Check yourself
--user-name dave returned dave's two.--as-of timestamp?--as-of 100 days later, the same five keys produce ten findings.<IsTruncated>false</IsTruncated> and all twelve users in one page. Multi-page behavior was therefore not exercised.keyage-core-tests and the test exactly_max_age_is_not_yet_due.Sources
- AWS IAM API Reference: AccessKeyLastUsed
- AWS IAM User Guide: Update access keys
- AWS IAM API Reference: ListAccessKeys
- AWS IAM User Guide: Manage access keys for IAM users
- AWS SDK for Rust Developer Guide: Using paginated results
- AWS SDK for Rust Developer Guide: Configuring client endpoints
- AWS SDKs and Tools Reference Guide: Service-specific endpoints
- The Cargo Book: Unstable features, script
- The Cargo Book: Workspaces
- Nixpkgs manual: Compiling Rust applications with Cargo
- flake-parts: Options reference
- Nix manual: nix flake check