diff --git a/README.md b/README.md
index 5852df10..eb25c86a 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,12 @@ Set the `--global-aws-sso-enabled` and `--global-aws-orgs-enabled` flags to pull
- SSO Groups
- SSO Users
+IAM user profiles include the most recent access key activity through `iam:ListAccessKeys` and `iam:GetAccessKeyLastUsed`, whether or not secrets are synced. Set `--sync-secrets` to also pull each IAM access key as a secret carrying its status (Active or Inactive), when it was last used, and which service and region it was last used from. Inactive keys were already synced; they now report as disabled rather than carrying no status.
+
+An IAM user's Last Login reports the latest password-based AWS sign-in exposed by `PasswordLastUsed`, including AWS Management Console sign-ins; access key use remains separate in `access_key_last_used` and, when `--sync-secrets` is set, on each key resource. Set `--sync-iam-user-console-access` to also record whether each IAM user has a console login profile (`iam:GetLoginProfile`, one call per user).
+
+Identity Center user Last Login uses a separate CloudTrail event feed. Enable Organizations support, Identity Center support, and `--sync-sso-user-last-login`, and grant `cloudtrail:LookupEvents` to report those sign-ins.
+
`baton-aws` also supports account provisioning and deprovisioning for AWS IAM Identity Center (SSO) users via the Identity Store API. See the "Syncing and Provisioning all supported objects" IAM policy below for the required permissions.
By default, `baton-aws` uses the AWS credentials from your AWS config. You can explicitly define the region, access key, and secret key by setting the following flags: `--global-secret-access-key`, `--global-access-key-id`, `--global-region`.
@@ -89,17 +95,21 @@ Available Commands:
capabilities Get connector capabilities
completion Generate the autocompletion script for the specified shell
config Get the connector config schema
+ health-check Check the health of a running connector
help Help about any command
Flags:
+ --auth-method string ($BATON_AUTH_METHOD)
--client-id string The client ID used to authenticate with ConductorOne ($BATON_CLIENT_ID)
--client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET)
- --create-account-resource-type string Which AWS user type C1 should create when provisioning accounts: "iam_user" (default) or "sso_user" ($BATON_CREATE_ACCOUNT_RESOURCE_TYPE) (default "iam_user")
+ --create-account-resource-type string Which AWS user type C1 should create when provisioning accounts. 'iam_user' (default) creates IAM users; 'sso_user' creates AWS Identity Center (SSO) users. Only one path can be active at a time per connector instance. ($BATON_CREATE_ACCOUNT_RESOURCE_TYPE) (default "iam_user")
--external-id string The external id for the aws account ($BATON_EXTERNAL_ID)
--external-resource-c1z string The path to the c1z file to sync external baton resources with ($BATON_EXTERNAL_RESOURCE_C1Z)
--external-resource-entitlement-id-filter string The entitlement that external users, groups must have access to sync external baton resources ($BATON_EXTERNAL_RESOURCE_ENTITLEMENT_ID_FILTER)
+ --external-resource-traits strings Resource type traits (e.g. "user", "group", "app") to sync and match from the external resource c1z. When unset the matcher falls back to user and group; passing this flag replaces the full set rather than adding to it. ($BATON_EXTERNAL_RESOURCE_TRAITS)
-f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z")
--global-access-key-id string The global-access-key-id for the aws account ($BATON_GLOBAL_ACCESS_KEY_ID)
+ --global-aws-cross-account-iam-enabled When both Organizations and Identity Center are enabled, also sync IAM users, roles, and groups from every child account. Requires sts:AssumeRole on OrganizationAccountAccessRole in each child account. Has no effect when Identity Center is disabled (cross-account IAM sync always runs in that mode). ($BATON_GLOBAL_AWS_CROSS_ACCOUNT_IAM_ENABLED)
--global-aws-orgs-enabled Enable support for AWS Organizations ($BATON_GLOBAL_AWS_ORGS_ENABLED)
--global-aws-sso-enabled Enable support for AWS IAM Identity Center ($BATON_GLOBAL_AWS_SSO_ENABLED)
--global-aws-sso-region string The region for the sso identities ($BATON_GLOBAL_AWS_SSO_REGION) (default "us-east-1")
@@ -107,22 +117,34 @@ Flags:
--global-region string The region for the aws account ($BATON_GLOBAL_REGION)
--global-role-arn string The role arn for the aws account ($BATON_GLOBAL_ROLE_ARN)
--global-secret-access-key string The global-secret-access-key for the aws account ($BATON_GLOBAL_SECRET_ACCESS_KEY)
+ --health-check Enable the HTTP health check endpoint ($BATON_HEALTH_CHECK)
+ --health-check-port int Port for the HTTP health check endpoint ($BATON_HEALTH_CHECK_PORT) (default 8081)
-h, --help help for baton-aws
+ --http-timeout-seconds int HTTP client timeout in seconds (max 1800) ($BATON_HTTP_TIMEOUT_SECONDS) (default 300)
--iam-assume-role-name string Role name for the IAM role to assume when using the AWS connector ($BATON_IAM_ASSUME_ROLE_NAME) (default "OrganizationAccountAccessRole")
+ --keep-previous-sync-c1z Keep the previously synced c1z on disk to enable ETag replay across service-mode syncs (requires a connector that supports ETag replay; costs one c1z of local disk) ($BATON_KEEP_PREVIOUS_SYNC_C1Z)
--log-format string The output format for logs: json, console ($BATON_LOG_FORMAT) (default "json")
--log-level string The log level: debug, info, warn, error ($BATON_LOG_LEVEL) (default "info")
+ --log-level-debug-expires-at string The timestamp indicating when debug-level logging should expire ($BATON_LOG_LEVEL_DEBUG_EXPIRES_AT)
+ --log-path strings The file path to write logs to ($BATON_LOG_PATH)
--otel-collector-endpoint string The endpoint of the OpenTelemetry collector to send observability data to (used for both tracing and logging if specific endpoints are not provided) ($BATON_OTEL_COLLECTOR_ENDPOINT)
+ --parallel-sync Deprecated: use --workers instead. ($BATON_PARALLEL_SYNC)
-p, --provisioning This must be set in order for provisioning actions to be enabled ($BATON_PROVISIONING)
--role-arn string The role arn for the aws account ($BATON_ROLE_ARN)
+ --skip-entitlements-and-grants This must be set to skip syncing of entitlements and grants ($BATON_SKIP_ENTITLEMENTS_AND_GRANTS)
--skip-full-sync This must be set to skip a full sync ($BATON_SKIP_FULL_SYNC)
+ --storage-engine string The storage engine to use when opening the sync c1z file: sqlite or pebble. Defaults to pebble when unset. ($BATON_STORAGE_ENGINE)
--sync-iam-user-console-access Enable fetching IAM user console login profiles via iam:GetLoginProfile (one API call per user). Disabled by default. ($BATON_SYNC_IAM_USER_CONSOLE_ACCESS)
--sync-only-attached-policies Only sync IAM managed policies that are attached to at least one user, role, or group ($BATON_SYNC_ONLY_ATTACHED_POLICIES)
+ --sync-resource-types strings The resource type IDs to sync ($BATON_SYNC_RESOURCE_TYPES)
--sync-resources strings The resource IDs to sync ($BATON_SYNC_RESOURCES)
--sync-secrets Whether to sync secrets or not ($BATON_SYNC_SECRETS)
--sync-sso-user-last-login Enable fetching last login time for SSO users from CloudTrail (requires cloudtrail:LookupEvents permission) ($BATON_SYNC_SSO_USER_LAST_LOGIN)
+ --task-concurrency int The number of Baton tasks to run concurrently in service mode. Tasks may include sync, grant, revoke, and more. Minimum value is 1, maximum value is 100. ($BATON_TASK_CONCURRENCY) (default 3)
--ticketing This must be set to enable ticketing support ($BATON_TICKETING)
--use-assume Enable support for assume role ($BATON_USE_ASSUME)
-v, --version version for baton-aws
+ --workers int The number of sync workers to use. -1 for auto-detect, 0 for sequential, >0 for parallel ($BATON_WORKERS)
Use "baton-aws [command] --help" for more information about a command.
```
@@ -151,6 +173,8 @@ _These policies have comments prefixed with // that need to be removed before us
"iam:ListAttachedRolePolicies",
"iam:ListAccessKeys",
"iam:GetAccessKeyLastUsed",
+ // Optional: only used with --sync-iam-user-console-access.
+ "iam:GetLoginProfile",
"iam:ListSigningCertificates",
"iam:ListSSHPublicKeys",
"iam:ListServiceSpecificCredentials",
@@ -206,6 +230,15 @@ _These policies have comments prefixed with // that need to be removed before us
// Sync identity center users, groups, and permission sets, as well as the organization accounts
"Sid": "SSOUserGroupAccountAndPermissionSetSyncing"
},
+ {
+ "Action": [
+ "cloudtrail:LookupEvents"
+ ],
+ "Effect": "Allow",
+ "Resource": "*",
+ // Optional: only needed with --sync-sso-user-last-login.
+ "Sid": "SSOUserLastLogin"
+ },
{
"Action": [
"organizations:ListRoots",
@@ -248,6 +281,8 @@ _These policies have comments prefixed with // that need to be removed before us
"iam:ListAttachedRolePolicies",
"iam:ListAccessKeys",
"iam:GetAccessKeyLastUsed",
+ // Optional: only used with --sync-iam-user-console-access.
+ "iam:GetLoginProfile",
"iam:ListSigningCertificates",
"iam:ListSSHPublicKeys",
"iam:ListServiceSpecificCredentials",
@@ -303,6 +338,15 @@ _These policies have comments prefixed with // that need to be removed before us
// Sync identity center users, groups, and permission sets, as well as the organization accounts
"Sid": "SSOUserGroupAccountAndPermissionSetSyncing"
},
+ {
+ "Action": [
+ "cloudtrail:LookupEvents"
+ ],
+ "Effect": "Allow",
+ "Resource": "*",
+ // Optional: only needed with --sync-sso-user-last-login.
+ "Sid": "SSOUserLastLogin"
+ },
{
"Action": [
"iam:AddUserToGroup",
@@ -478,6 +522,8 @@ Each sub-account will need to have the following policy attached to the role tha
"iam:ListAttachedRolePolicies",
"iam:ListAccessKeys",
"iam:GetAccessKeyLastUsed",
+ // Optional: only used with --sync-iam-user-console-access.
+ "iam:GetLoginProfile",
"iam:ListSigningCertificates",
"iam:ListSSHPublicKeys",
"iam:ListServiceSpecificCredentials",
diff --git a/docs/connector.mdx b/docs/connector.mdx
index 2e6fcf87..5673930c 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -94,6 +94,33 @@ Each policy resource carries its policy document (the JSON that defines which ac
Syncing policy documents requires the policy read permissions listed in the IAM policies below (`iam:GetPolicy`, `iam:GetPolicyVersion`, and the `iam:Get*Policy` / `iam:List*Policies` actions). If these permissions are missing, the connector logs a warning and syncs the policy resources without their document contents rather than failing the sync — so a policy that appears with no document is usually a missing-permission symptom rather than an empty policy.
+## IAM user last login and access key activity
+
+For an IAM user, **Last Login** reports the latest password-based AWS sign-in exposed by `PasswordLastUsed`. This includes AWS Management Console sign-ins and can also include password sign-ins to other AWS websites, such as AWS Marketplace. Programmatic activity through an access key does not update it. Keeping the two apart is the point of the field: a user whose only recent activity is an automated key still reads as dormant for password-based access review.
+
+Both signals remain visible on the user:
+
+- `password_last_used` — the password-based AWS sign-in timestamp behind **Last Login**.
+- `access_key_last_used` — the most recent use across all of the user's access keys.
+
+Populating `access_key_last_used` requires `iam:ListAccessKeys` and `iam:GetAccessKeyLastUsed`; it does not require **Sync secrets**.
+
+When AWS returns no `PasswordLastUsed` value, C1 leaves **Last Login** unset. This can mean the user never had a password, or that AWS has no tracked password use. It does not indicate whether the user currently has a console password; use `console_access_enabled` for that distinction.
+
+Per-key detail lives on the access key itself, on the [Inventory page](/product/admin/inventory):
+
+- **Last used**, together with `last_used_service` and `last_used_region` — what the key was calling and from where. For a key with no tracked use, AWS returns a null date and `"N/A"` placeholders; the connector omits those values.
+- **Status** — Active or Inactive. Inactive keys were already synced; they now carry a disabled status so reviewers can tell them apart from active keys. Inactive keys can be reactivated; reviewers decide whether to delete them.
+
+Two optional settings gate parts of this, and both are off by default:
+
+- **Sync secrets** — without it no access keys are synced, so none of the per-key detail above appears.
+- **Sync IAM User Console Access** (`BATON_SYNC_IAM_USER_CONSOLE_ACCESS`) — without it `console_access_enabled`, `password_reset_required`, and `login_profile_created_at` are not populated. It is off by default because it costs one `iam:GetLoginProfile` call per IAM user and requires `iam:GetLoginProfile` on the connector role. This setting detects an IAM console password; it does not detect access through Identity Center or an assumed role.
+
+IAM user **Last Login** itself does not depend on either setting.
+
+Identity Center user Last Login uses a separate CloudTrail event feed. Enable Organizations support, Identity Center support, and **Sync SSO User Last Login** (`BATON_SYNC_SSO_USER_LAST_LOGIN`), and grant `cloudtrail:LookupEvents` to report Identity Center sign-ins. This setting is also off by default and does not affect IAM user Last Login or access key activity.
+
## Gather AWS credentials
Configuring the connector requires you to pass in credentials generated in AWS. Gather these credentials before you move on.
@@ -233,7 +260,8 @@ Next, you will create an inline policy to define the specific data this role can
"iam:GetGroup",
"iam:GetRole",
"iam:ListAccessKeys",
- "iam:GetAccessKeyLastUsed"
+ "iam:GetAccessKeyLastUsed",
+ "iam:GetLoginProfile"
],
"Resource": "*"
},
@@ -287,13 +315,13 @@ Next, you will create an inline policy to define the specific data this role can
* iam:GetGroup: Required to see which users belong to which groups. ListGroups only provides the group names, while GetGroup identifies the specific members.
* iam:GetRole: Allows C1 to retrieve detailed information about specific roles, including their trust relationships.
- **Optional: Security & Audit Insights** Include these permissions if you want C1 to sync access key metadata.
+ **Access key activity** These permissions populate `access_key_last_used` on each IAM user. Enable **Sync secrets** to also sync each key as its own secret resource with status and per-key activity.
- * iam:ListAccessKeys and iam:GetAccessKeyLastUsed: These allow C1 to gather data on the status of your IAM access keys, such as when they were last used. This is useful for identifying unused or stale credentials.
+ * iam:ListAccessKeys and iam:GetAccessKeyLastUsed: These allow C1 to gather access key metadata, including when each key was last used. This is useful for identifying unused or stale credentials.
Note that C1 only retrieves metadata for auditing; it does not store or access the secret access keys themselves.
- **Optional: IAM User Console Access** Include this permission if you enable **Sync IAM User Console Access** (`BATON_SYNC_IAM_USER_CONSOLE_ACCESS`). This flag is off by default because it makes one `iam:GetLoginProfile` call per IAM user.
+ **Optional: IAM User Console Access** The JSON above includes this permission. It is used only when **Sync IAM User Console Access** (`BATON_SYNC_IAM_USER_CONSOLE_ACCESS`) is enabled. The flag is off by default because it makes one `iam:GetLoginProfile` call per IAM user.
* iam:GetLoginProfile: Allows C1 to see whether each IAM user has a console login profile, and whether a password reset is required.
@@ -432,6 +460,7 @@ The permissions policy below is broken into several sections to align with these
"Action": [
"iam:GetGroup",
"iam:GetAccessKeyLastUsed",
+ "iam:GetLoginProfile",
"iam:GetRole",
"iam:ListAccountAliases",
"iam:ListGroups",
@@ -469,6 +498,14 @@ The permissions policy below is broken into several sections to align with these
"Resource": "*",
"Sid": "ConductorOneReadAccess"
},
+ {
+ "Action": [
+ "cloudtrail:LookupEvents"
+ ],
+ "Effect": "Allow",
+ "Resource": "*",
+ "Sid": "SSOUserLastLogin"
+ },
{
"Action": [
"iam:AddUserToGroup",
@@ -519,8 +556,7 @@ The permissions policy below is broken into several sections to align with these
"Effect": "Allow",
"Action": [
"iam:ListRoles",
- "iam:ListPolicies",
- "iam:ListAccessKeys"
+ "iam:ListPolicies"
],
"Resource": "*"
},
@@ -572,6 +608,7 @@ The permissions policy below is broken into several sections to align with these
- `organizations:ListParents`: **Recommended**, only needed to re-parent accounts onto their Organizational Unit for the Sparse ACLs by-inheritance hierarchy. If omitted, accounts sync flat without OU grouping instead of failing.
- `organizations:DescribeAccount`: **Recommended**, enables proactive validation of account status before creating assignments. If omitted, the connector proceeds anyway and surfaces a less clear error for suspended accounts.
- `sso:List..., sso:Describe..., sso:Get...`: These permissions allow C1 to list your permission sets, see how they are assigned to accounts and users, and read the inline and managed IAM policies attached to each permission set.
+ - `cloudtrail:LookupEvents`: **Optional**, only needed when Organizations support, Identity Center support, and **Sync SSO User Last Login** are all enabled. The JSON above includes it so the connector can read Identity Center sign-in events in that configuration.
- `organizations:ListRoots, organizations:ListOrganizationalUnitsForParent`: **Optional**, only needed for the Sparse ACLs Organization Root / Organizational Unit hierarchy (see [Sparse ACLs](#sparse-acls-organizations-and-permission-sets-as-scoped-bindings) above). If omitted, the connector logs a warning and skips the OU hierarchy instead of failing.
- `iam:GetUser, and various iam:List... permissions`: These permissions are necessary for C1 to first retrieve all associated credentials and metadata for an IAM user before a complete deletion can be performed.
**Section 2: Provisioning access (“C1ProvisionAccess”)**
@@ -587,11 +624,12 @@ The permissions policy below is broken into several sections to align with these
- `iam:AttachRolePolicy, iam:DeleteRole, etc.`: These are permissions to manage IAM roles. The key detail is the Resource constraint: `arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*`. This ensures that C1 can only modify roles created and managed by the AWS IAM Identity Center service itself, preventing it from modifying other roles in your account. Note that read-only access (`iam:GetRole`) is granted separately in Section 1 for all roles.
**Section 4: Other permissions**
These are supporting permissions that enable specific features or functionality. Some of the permissions listed here can be omitted if your particular use case doesn’t require the data they pull in to C1.
- - `IAMListPermissions`: This section allows C1 to sync data on access keys. C1 does not store or access the access keys. If you do not want to sync access key data, you can omit this section.
+ - `IAMListPermissions`: This section adds supplementary IAM role and managed-policy listing in the management account. Access key activity is provided by `iam:ListAccessKeys` and `iam:GetAccessKeyLastUsed` in the `ConductorOneReadAccess` statement above.
- `sts:AssumeRole`: **This permission is required when AWS Organizations support is enabled and the connector is doing cross-account IAM sync.** That covers two cases: Organizations on with Identity Center off, and Organizations on with Identity Center on plus the "Also sync cross-account IAM" flag turned on. It lets C1 assume the `OrganizationAccountAccessRole` in your child accounts so it can discover and sync resources across your AWS Organization. It can be omitted if AWS Organizations support is disabled, or if Identity Center is on without the cross-account IAM flag.
- `iam:GetSAMLProvider`: This is a necessary permission to read the configuration of the SAML provider that AWS IAM Identity Center uses for single sign-on.
- The permissions listed in the `"Sid": "IAMListPermissions"` and `"Sid": "AccessToSSOProvisiondRoles"` sections are required only if you want to use C1 to create assignments in the AWS Organization’s management account. In certain cases, you may also need to add `iam:UpdateSAMLProvider` to these sections.
- - The `iam:GetAccessKeyLastUsed` permission is only needed if you want C1 to sync access key secret data.
+ - `iam:ListAccessKeys` and `iam:GetAccessKeyLastUsed` let C1 report the most recent access key activity on each IAM user. Enable **Sync secrets** to also sync each key as its own secret resource with per-key activity.
+ - `iam:GetLoginProfile` is only needed when **Sync IAM User Console Access** is enabled. It lets C1 report whether an IAM user has a console password.
Click **Review Policy**.
@@ -642,6 +680,12 @@ The permissions policy below is broken into several sections to align with these
**Optional.** Enable **Sync secrets** to display them on the [Inventory page](/product/admin/inventory).
+ **Optional.** Enable **Sync IAM User Console Access** to report whether each IAM user has a console password. The connector role must include `iam:GetLoginProfile`.
+
+
+ **Optional.** If both Organizations support and Identity Center support are enabled, enable **Sync SSO User Last Login** to report Identity Center sign-ins. The connector role must include `cloudtrail:LookupEvents`.
+
+
Click **Save**.
@@ -726,6 +770,17 @@ stringData:
BATON_GLOBAL_AWS_SSO_ENABLED: true
BATON_GLOBAL_AWS_SSO_REGION:
+ # Optional: Sync access keys as secret resources on the Inventory page
+ BATON_SYNC_SECRETS: true
+
+ # Optional: Report whether IAM users have a console password
+ # Requires iam:GetLoginProfile
+ BATON_SYNC_IAM_USER_CONSOLE_ACCESS: true
+
+ # Optional: Report Identity Center user Last Login from CloudTrail
+ # Requires Organizations and Identity Center support plus cloudtrail:LookupEvents
+ BATON_SYNC_SSO_USER_LAST_LOGIN: true
+
# Optional: Choose which AWS user type C1 should create when provisioning accounts.
# "iam_user" (default) creates IAM users. "sso_user" creates AWS Identity Center
# (SSO) users via the Identity Store API. Only one path is active at a time per
@@ -841,6 +896,7 @@ resource "aws_iam_role" "ConductorOneIntegration" {
"Action" : [
"iam:GetGroup",
"iam:GetAccessKeyLastUsed",
+ "iam:GetLoginProfile",
"iam:GetRole",
"iam:ListAccountAliases",
"iam:ListGroups",
@@ -878,6 +934,14 @@ resource "aws_iam_role" "ConductorOneIntegration" {
"Resource" : "*",
"Sid" : "ConductorOneReadAccess"
},
+ {
+ "Action" : [
+ "cloudtrail:LookupEvents"
+ ],
+ "Effect" : "Allow",
+ "Resource" : "*",
+ "Sid" : "SSOUserLastLogin"
+ },
{
"Action" : [
"iam:AddUserToGroup",
@@ -974,6 +1038,8 @@ resource "aws_iam_role" "ConductorOneIntegration" {
}
}
```
+
+After applying the Terraform configuration, enable **Sync secrets** and **Sync IAM User Console Access** in the connector settings if you want their optional data. Enable **Sync SSO User Last Login** only when both Organizations support and Identity Center support are enabled.
diff --git a/pkg/connector/iam_user.go b/pkg/connector/iam_user.go
index 9b8e3b81..29ffabf0 100644
--- a/pkg/connector/iam_user.go
+++ b/pkg/connector/iam_user.go
@@ -84,7 +84,13 @@ func (o *iamUserResourceType) List(ctx context.Context, parentId *v2.ResourceId,
Id: awsSdk.ToString(user.Arn),
}
profile := iamUserProfile(ctx, user)
- lastLogin := getLastLogin(ctx, iamClient, user)
+ activity := getLoginActivity(ctx, iamClient, user)
+ if activity.passwordLastUsed != nil {
+ profile["password_last_used"] = activity.passwordLastUsed.Format(time.RFC3339)
+ }
+ if activity.accessKeyLastUsed != nil {
+ profile["access_key_last_used"] = activity.accessKeyLastUsed.Format(time.RFC3339)
+ }
options := make([]resourceSdk.UserTraitOption, 0)
if o.aws != nil && o.aws.syncIAMUserConsoleAccess {
@@ -104,8 +110,12 @@ func (o *iamUserResourceType) List(ctx context.Context, parentId *v2.ResourceId,
for _, email := range getUserEmails(user) {
options = append(options, resourceSdk.WithEmail(email, true))
}
- if lastLogin != nil {
- options = append(options, resourceSdk.WithLastLogin(*lastLogin))
+ // Last Login is the console sign-in alone. Folding access key use into it
+ // would keep a user driven by an automated key from ever looking dormant,
+ // which is the distinction this field exists to make. Key activity is
+ // reported on the access key itself, as its last-used time.
+ if activity.passwordLastUsed != nil {
+ options = append(options, resourceSdk.WithLastLogin(*activity.passwordLastUsed))
}
userResource, err := resourceSdk.NewUserResource(awsSdk.ToString(user.UserName),
@@ -276,48 +286,50 @@ func getConsoleAccess(ctx context.Context, client *iam.Client, user iamTypes.Use
}, nil
}
-func getLastLogin(ctx context.Context, client *iam.Client, user iamTypes.User) *time.Time {
- logger := ctxzap.Extract(ctx).With(
- zap.String("user_id", *user.UserId),
- )
+// loginActivity holds the two authentication signals AWS reports for an IAM
+// user. They are tracked separately because an access key call is not
+// equivalent to a human signing in to the console, and reviewers need to tell
+// them apart.
+type loginActivity struct {
+ passwordLastUsed *time.Time
+ accessKeyLastUsed *time.Time
+}
+
+// getLoginActivity reports the user's console sign-in time alongside the most
+// recent use of any of their access keys. A key that has never been used
+// contributes nothing.
+func getLoginActivity(ctx context.Context, client *iam.Client, user iamTypes.User) loginActivity {
+ activity := loginActivity{passwordLastUsed: user.PasswordLastUsed}
res, err := client.ListAccessKeys(ctx, &iam.ListAccessKeysInput{UserName: user.UserName})
if err != nil {
- logger.Debug("Error listing access keys", zap.Error(err))
- return user.PasswordLastUsed
+ ctxzap.Extract(ctx).Debug("Error listing access keys",
+ zap.String("user_id", awsSdk.ToString(user.UserId)),
+ zap.Error(err),
+ )
+ return activity
}
- accessKeyLastUsedDates := make([]time.Time, 0, len(res.AccessKeyMetadata))
for _, key := range res.AccessKeyMetadata {
- accessKeyLastUsed := getAccessKeyLastUsedDate(ctx, client, awsSdk.ToString(key.AccessKeyId))
- if accessKeyLastUsed == nil {
- logger.Debug("Error getting access key last used", zap.String("access_key_id", awsSdk.ToString(key.AccessKeyId)))
+ accessKeyID := awsSdk.ToString(key.AccessKeyId)
+ usage, err := getAccessKeyLastUsed(ctx, client, accessKeyID)
+ if err != nil {
+ ctxzap.Extract(ctx).Debug("Error getting access key last used",
+ zap.String("user_id", awsSdk.ToString(user.UserId)),
+ zap.String("access_key_id", accessKeyID),
+ zap.Error(err),
+ )
continue
}
- accessKeyLastUsedDates = append(accessKeyLastUsedDates, *accessKeyLastUsed)
- }
-
- // check if access key was the last one to be used
- var out time.Time
- if len(accessKeyLastUsedDates) > 0 {
- out = accessKeyLastUsedDates[0]
- }
- for _, lastUsed := range accessKeyLastUsedDates {
- if lastUsed.Before(out) {
- out = lastUsed
+ if usage.date == nil {
+ continue
+ }
+ if activity.accessKeyLastUsed == nil || usage.date.After(*activity.accessKeyLastUsed) {
+ activity.accessKeyLastUsed = usage.date
}
}
- // check if password was the last one to be used
- if user.PasswordLastUsed != nil && user.PasswordLastUsed.Before(out) {
- out = *user.PasswordLastUsed
- }
-
- if out.IsZero() {
- return nil
- }
-
- return &out
+ return activity
}
func getUserEmails(user iamTypes.User) []string {
diff --git a/pkg/connector/iam_user_last_login_test.go b/pkg/connector/iam_user_last_login_test.go
new file mode 100644
index 00000000..4f0e2df8
--- /dev/null
+++ b/pkg/connector/iam_user_last_login_test.go
@@ -0,0 +1,225 @@
+package connector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ awsSdk "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+ iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
+ smithymiddleware "github.com/aws/smithy-go/middleware"
+ "github.com/stretchr/testify/require"
+)
+
+// getLoginActivity takes the concrete *iam.Client, so responses are stubbed with
+// an AWS SDK v2 Finalize middleware keyed on the operation name, the same seam
+// iamClientReturning uses in iam_user_delete_test.go.
+//
+// Each entry in keyLastUsed is one access key the user owns; a nil entry is a key
+// that exists but IAM has never reported usage for. That distinction is the whole
+// point: "owns no keys" and "owns a key that was never used" are different states
+// and used to produce different bugs.
+func iamClientWithKeys(listErr error, keyLastUsed ...*time.Time) *iam.Client {
+ lookups := make([]keyLookupResult, len(keyLastUsed))
+ for i, lastUsed := range keyLastUsed {
+ lookups[i] = keyLookupResult{lastUsed: lastUsed}
+ }
+ return iamClientWithKeyLookups(listErr, lookups)
+}
+
+type keyLookupResult struct {
+ lastUsed *time.Time
+ err error
+}
+
+func iamClientWithKeyLookups(listErr error, lookups []keyLookupResult) *iam.Client {
+ keys := make([]iamTypes.AccessKeyMetadata, 0, len(lookups))
+ lastUsedByKey := make(map[string]*time.Time, len(lookups))
+ errByKey := make(map[string]error, len(lookups))
+ for i, lookup := range lookups {
+ id := fmt.Sprintf("AKIAEXAMPLE%d", i)
+ keys = append(keys, iamTypes.AccessKeyMetadata{AccessKeyId: awsSdk.String(id)})
+ lastUsedByKey[id] = lookup.lastUsed
+ errByKey[id] = lookup.err
+ }
+
+ return iam.New(iam.Options{
+ Region: "us-east-1",
+ APIOptions: []func(*smithymiddleware.Stack) error{
+ func(stack *smithymiddleware.Stack) error {
+ return stack.Initialize.Add(
+ smithymiddleware.InitializeMiddlewareFunc("stubIAM",
+ func(ctx context.Context, in smithymiddleware.InitializeInput, _ smithymiddleware.InitializeHandler) (smithymiddleware.InitializeOutput, smithymiddleware.Metadata, error) {
+ switch input := in.Parameters.(type) {
+ case *iam.ListAccessKeysInput:
+ if listErr != nil {
+ return smithymiddleware.InitializeOutput{}, smithymiddleware.Metadata{}, listErr
+ }
+ return smithymiddleware.InitializeOutput{
+ Result: &iam.ListAccessKeysOutput{AccessKeyMetadata: keys},
+ }, smithymiddleware.Metadata{}, nil
+ case *iam.GetAccessKeyLastUsedInput:
+ id := awsSdk.ToString(input.AccessKeyId)
+ if err := errByKey[id]; err != nil {
+ return smithymiddleware.InitializeOutput{}, smithymiddleware.Metadata{}, err
+ }
+ return smithymiddleware.InitializeOutput{
+ Result: &iam.GetAccessKeyLastUsedOutput{
+ AccessKeyLastUsed: &iamTypes.AccessKeyLastUsed{
+ LastUsedDate: lastUsedByKey[id],
+ },
+ },
+ }, smithymiddleware.Metadata{}, nil
+ default:
+ return smithymiddleware.InitializeOutput{}, smithymiddleware.Metadata{}, fmt.Errorf("unexpected input type %T", in.Parameters)
+ }
+ }),
+ smithymiddleware.Before,
+ )
+ },
+ },
+ })
+}
+
+func tp(s string) *time.Time {
+ parsed, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ panic(err)
+ }
+ return &parsed
+}
+
+// The console sign-in and the newest access key use are reported as two
+// independent signals: neither may absorb, mask or overwrite the other.
+func TestGetLoginActivity_ReportsBothSignalsIndependently(t *testing.T) {
+ consoleLogin := tp("2025-11-17T17:48:00Z")
+ keyUse := tp("2026-08-26T00:15:00Z")
+ olderKeyUse := tp("2026-01-02T09:00:00Z")
+
+ for _, tc := range []struct {
+ name string
+ consoleSignIn *time.Time
+ keys []*time.Time
+ wantKeyLastUsed *time.Time
+ }{
+ {
+ // The old code reported the oldest activity, so a key used after the
+ // console sign-in used to overwrite Last Login with the sign-in.
+ name: "a key used after the console sign-in leaves the sign-in intact",
+ consoleSignIn: consoleLogin,
+ keys: []*time.Time{keyUse},
+ wantKeyLastUsed: keyUse,
+ },
+ {
+ name: "a console sign-in with no access keys is still reported",
+ consoleSignIn: consoleLogin,
+ keys: nil,
+ },
+ {
+ // The original defect: a key that exists but was never used left the
+ // running comparison at its zero value, and comparing the sign-in
+ // against that zero discarded it entirely.
+ name: "a key that was never used does not discard the console sign-in",
+ consoleSignIn: consoleLogin,
+ keys: []*time.Time{nil},
+ },
+ {
+ name: "keys that were all never used report no key activity",
+ consoleSignIn: nil,
+ keys: []*time.Time{nil, nil},
+ },
+ {
+ // Newest first, so a loop keeping the last value it saw rather than the
+ // greatest one fails here.
+ name: "the newest of several keys wins when listed first",
+ keys: []*time.Time{keyUse, olderKeyUse},
+ wantKeyLastUsed: keyUse,
+ },
+ {
+ name: "the newest of several keys wins when listed last",
+ keys: []*time.Time{olderKeyUse, keyUse},
+ wantKeyLastUsed: keyUse,
+ },
+ {
+ name: "an unused key alongside a used one does not hide the used one",
+ keys: []*time.Time{nil, olderKeyUse},
+ wantKeyLastUsed: olderKeyUse,
+ },
+ {
+ name: "a console sign-in after the key use leaves the key use intact",
+ consoleSignIn: keyUse,
+ keys: []*time.Time{consoleLogin},
+ wantKeyLastUsed: consoleLogin,
+ },
+ {
+ name: "a user who has never authenticated reports neither signal",
+ consoleSignIn: nil,
+ keys: nil,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ user := iamTypes.User{
+ UserName: awsSdk.String("ci-iam-1"),
+ UserId: awsSdk.String("AIDAEXAMPLE"),
+ PasswordLastUsed: tc.consoleSignIn,
+ }
+
+ activity := getLoginActivity(context.Background(), iamClientWithKeys(nil, tc.keys...), user)
+
+ require.Equal(t, tc.consoleSignIn, activity.passwordLastUsed,
+ "the console sign-in must survive whatever the keys report")
+ require.Equal(t, tc.wantKeyLastUsed, activity.accessKeyLastUsed,
+ "the newest key use must survive whatever the console reports")
+ })
+ }
+}
+
+// Losing the access keys must not also lose the console sign-in we already hold:
+// reporting no activity for a user who has signed in would read as a dormant
+// account.
+func TestGetLoginActivity_KeepsConsoleLoginWhenKeysCannotBeListed(t *testing.T) {
+ consoleLogin := tp("2026-07-28T18:38:16Z")
+ user := iamTypes.User{
+ UserName: awsSdk.String("ci-iam-1"),
+ UserId: awsSdk.String("AIDAEXAMPLE"),
+ PasswordLastUsed: consoleLogin,
+ }
+
+ activity := getLoginActivity(context.Background(),
+ iamClientWithKeys(errors.New("AccessDenied"), tp("2026-08-26T00:15:00Z")), user)
+
+ require.Equal(t, consoleLogin, activity.passwordLastUsed)
+ require.Nil(t, activity.accessKeyLastUsed)
+}
+
+func TestGetLoginActivity_OmitsKeyActivityWhenLastUsedLookupFails(t *testing.T) {
+ consoleLogin := tp("2026-07-28T18:38:16Z")
+ used := tp("2026-08-26T00:15:00Z")
+ user := iamTypes.User{
+ UserName: awsSdk.String("ci-iam-1"),
+ UserId: awsSdk.String("AIDAEXAMPLE"),
+ PasswordLastUsed: consoleLogin,
+ }
+
+ t.Run("a failed lookup is not treated as never used and does not hide a used key", func(t *testing.T) {
+ activity := getLoginActivity(context.Background(), iamClientWithKeyLookups(nil, []keyLookupResult{
+ {err: errors.New("AccessDenied")},
+ {lastUsed: used},
+ }), user)
+
+ require.Equal(t, consoleLogin, activity.passwordLastUsed)
+ require.Equal(t, used, activity.accessKeyLastUsed)
+ })
+
+ t.Run("every lookup failing leaves key activity unset", func(t *testing.T) {
+ activity := getLoginActivity(context.Background(), iamClientWithKeyLookups(nil, []keyLookupResult{
+ {err: errors.New("AccessDenied")},
+ }), user)
+
+ require.Equal(t, consoleLogin, activity.passwordLastUsed)
+ require.Nil(t, activity.accessKeyLastUsed)
+ })
+}
diff --git a/pkg/connector/secrets.go b/pkg/connector/secrets.go
index fd60abb3..802d09e7 100644
--- a/pkg/connector/secrets.go
+++ b/pkg/connector/secrets.go
@@ -7,6 +7,7 @@ import (
awsSdk "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/iam"
+ iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/pagination"
resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource"
@@ -79,24 +80,58 @@ func (o *secretResourceType) List(ctx context.Context, parentId *v2.ResourceId,
annos := &v2.V1Identifier{
Id: awsSdk.ToString(user.Arn),
}
+ // iam_user resources are keyed by ARN, so the owner has to be referenced
+ // by ARN too: a UserId here resolves to nothing and leaves the key with
+ // no owner to review it against.
+ ownerID := &v2.ResourceId{
+ ResourceType: resourceTypeIAMUser.Id,
+ Resource: awsSdk.ToString(user.Arn),
+ BatonResource: false,
+ }
options := []resourceSdk.SecretTraitOption{
- resourceSdk.WithSecretCreatedByID(&v2.ResourceId{
- ResourceType: resourceTypeIAMUser.Id,
- Resource: *user.UserId,
- BatonResource: false,
- }),
- resourceSdk.WithSecretIdentityID(&v2.ResourceId{
- ResourceType: resourceTypeIAMUser.Id,
- Resource: *user.UserId,
- BatonResource: false,
- }),
+ resourceSdk.WithSecretCreatedByID(ownerID),
+ resourceSdk.WithSecretIdentityID(ownerID),
resourceSdk.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET),
resourceSdk.WithSecretDetail("aws.access_key"),
}
- keyLastUsedDate := getAccessKeyLastUsedDate(ctx, iamClient, *key.AccessKeyId)
- if keyLastUsedDate != nil {
- options = append(options, resourceSdk.WithSecretLastUsedAt(*keyLastUsedDate))
+ // Which service the key last called separates a person doing work from
+ // automation, so reviewers can judge whether the key is still needed.
+ profile := map[string]any{}
+ usage, err := getAccessKeyLastUsed(ctx, iamClient, *key.AccessKeyId)
+ if err != nil {
+ logger.Debug("Error getting access key last used",
+ zap.String("access_key_id", awsSdk.ToString(key.AccessKeyId)),
+ zap.Error(err),
+ )
+ } else {
+ if usage.date != nil {
+ options = append(options, resourceSdk.WithSecretLastUsedAt(*usage.date))
+ }
+ if usage.service != "" {
+ profile["last_used_service"] = usage.service
+ }
+ if usage.region != "" {
+ profile["last_used_region"] = usage.region
+ }
+ }
+
+ // Inactive keys already synced; they now carry a disabled status so
+ // reviewers can tell them apart from active keys.
+ keyStatus := v2.Status_RESOURCE_STATUS_DISABLED
+ if key.Status == iamTypes.StatusTypeActive {
+ keyStatus = v2.Status_RESOURCE_STATUS_ENABLED
+ }
+
+ resourceOptions := []resourceSdk.ResourceOption{
+ resourceSdk.WithResourceCreatedAt(*key.CreateDate),
+ resourceSdk.WithResourceStatus(keyStatus, string(key.Status)),
+ resourceSdk.WithAnnotation(annos),
+ }
+ // A key IAM has never reported usage for carries no profile at all,
+ // rather than an empty one.
+ if len(profile) > 0 {
+ resourceOptions = append(resourceOptions, resourceSdk.WithResourceProfile(profile))
}
secretResource, err := resourceSdk.NewSecretResource(
@@ -104,8 +139,7 @@ func (o *secretResourceType) List(ctx context.Context, parentId *v2.ResourceId,
resourceTypeSecret,
*key.AccessKeyId,
options,
- resourceSdk.WithResourceCreatedAt(*key.CreateDate),
- resourceSdk.WithAnnotation(annos),
+ resourceOptions...,
)
if err != nil {
return nil, nil, err
@@ -136,20 +170,40 @@ func (o *secretResourceType) Grants(ctx context.Context, resource *v2.Resource,
return nil, nil, nil
}
-func getAccessKeyLastUsedDate(ctx context.Context, iamClient *iam.Client, accessKeyId string) *time.Time {
+// notApplicable is what IAM reports for the service and region of a key that
+// has never been used.
+const notApplicable = "N/A"
+
+// accessKeyUsage is what IAM knows about the last call made with a key. A key
+// that has never been used carries a nil date and no service, and is left that
+// way rather than filled in with a placeholder.
+type accessKeyUsage struct {
+ date *time.Time
+ service string
+ region string
+}
+
+func getAccessKeyLastUsed(ctx context.Context, iamClient *iam.Client, accessKeyId string) (accessKeyUsage, error) {
logger := ctxzap.Extract(ctx)
- accessKeyLastUsed, err := iamClient.GetAccessKeyLastUsed(ctx, &iam.GetAccessKeyLastUsedInput{
+ resp, err := iamClient.GetAccessKeyLastUsed(ctx, &iam.GetAccessKeyLastUsedInput{
AccessKeyId: awsSdk.String(accessKeyId),
})
if err != nil {
- logger.Warn("Error getting access key last used", zap.Error(err))
- return nil
+ return accessKeyUsage{}, err
}
- if accessKeyLastUsed.AccessKeyLastUsed == nil ||
- accessKeyLastUsed.AccessKeyLastUsed.LastUsedDate == nil ||
- accessKeyLastUsed.AccessKeyLastUsed.LastUsedDate.IsZero() {
+ if resp.AccessKeyLastUsed == nil ||
+ resp.AccessKeyLastUsed.LastUsedDate == nil ||
+ resp.AccessKeyLastUsed.LastUsedDate.IsZero() {
logger.Debug("Access key last used date is nil or zero", zap.String("access_key_id", accessKeyId))
- return nil
+ return accessKeyUsage{}, nil
+ }
+
+ usage := accessKeyUsage{date: resp.AccessKeyLastUsed.LastUsedDate}
+ if service := awsSdk.ToString(resp.AccessKeyLastUsed.ServiceName); service != notApplicable {
+ usage.service = service
+ }
+ if region := awsSdk.ToString(resp.AccessKeyLastUsed.Region); region != notApplicable {
+ usage.region = region
}
- return accessKeyLastUsed.AccessKeyLastUsed.LastUsedDate
+ return usage, nil
}
diff --git a/pkg/connector/secrets_test.go b/pkg/connector/secrets_test.go
new file mode 100644
index 00000000..c73c7d8b
--- /dev/null
+++ b/pkg/connector/secrets_test.go
@@ -0,0 +1,202 @@
+package connector
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ awsSdk "github.com/aws/aws-sdk-go-v2/aws"
+ awsMiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+ iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
+ smithymiddleware "github.com/aws/smithy-go/middleware"
+ v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
+ "github.com/conductorone/baton-sdk/pkg/annotations"
+ resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// iamClientWithUserKeys stubs the three IAM calls the secret builder makes for
+// a single user, using the same middleware seam as the other IAM tests.
+// lastUsed is returned verbatim for every GetAccessKeyLastUsed call.
+func iamClientWithUserKeys(userName string, keys []iamTypes.AccessKeyMetadata, lastUsed *iamTypes.AccessKeyLastUsed, lastUsedErr error) *iam.Client {
+ return iam.New(iam.Options{
+ Region: "us-east-1",
+ APIOptions: []func(*smithymiddleware.Stack) error{
+ func(stack *smithymiddleware.Stack) error {
+ return stack.Finalize.Add(
+ smithymiddleware.FinalizeMiddlewareFunc("stubIAMSecrets",
+ func(ctx context.Context, _ smithymiddleware.FinalizeInput, _ smithymiddleware.FinalizeHandler) (smithymiddleware.FinalizeOutput, smithymiddleware.Metadata, error) {
+ switch awsMiddleware.GetOperationName(ctx) {
+ case "ListUsers":
+ return smithymiddleware.FinalizeOutput{
+ Result: &iam.ListUsersOutput{Users: []iamTypes.User{{
+ UserName: awsSdk.String(userName),
+ UserId: awsSdk.String("AIDAEXAMPLE"),
+ Arn: awsSdk.String("arn:aws:iam::123456789012:user/" + userName),
+ }}},
+ }, smithymiddleware.Metadata{}, nil
+ case "ListAccessKeys":
+ return smithymiddleware.FinalizeOutput{
+ Result: &iam.ListAccessKeysOutput{AccessKeyMetadata: keys},
+ }, smithymiddleware.Metadata{}, nil
+ case "GetAccessKeyLastUsed":
+ if lastUsedErr != nil {
+ return smithymiddleware.FinalizeOutput{}, smithymiddleware.Metadata{}, lastUsedErr
+ }
+ return smithymiddleware.FinalizeOutput{
+ Result: &iam.GetAccessKeyLastUsedOutput{AccessKeyLastUsed: lastUsed},
+ }, smithymiddleware.Metadata{}, nil
+ default:
+ return smithymiddleware.FinalizeOutput{}, smithymiddleware.Metadata{}, nil
+ }
+ }),
+ smithymiddleware.Before,
+ )
+ },
+ },
+ })
+}
+
+func requireSecretTrait(t *testing.T, resource *v2.Resource) *v2.SecretTrait {
+ t.Helper()
+
+ trait := &v2.SecretTrait{}
+ annos := annotations.Annotations(resource.GetAnnotations())
+ found, err := annos.Pick(trait)
+ require.NoError(t, err)
+ require.True(t, found)
+
+ return trait
+}
+
+// Inactive keys already synced; they now carry a disabled status so reviewers
+// can tell them apart from active keys.
+func TestSecretList_ReportsAccessKeyStatus(t *testing.T) {
+ created := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
+
+ for _, tc := range []struct {
+ name string
+ status iamTypes.StatusType
+ want v2.Status_ResourceStatus
+ }{
+ {
+ name: "active key is enabled",
+ status: iamTypes.StatusTypeActive,
+ want: v2.Status_RESOURCE_STATUS_ENABLED,
+ },
+ {
+ name: "inactive key is synced as disabled",
+ status: iamTypes.StatusTypeInactive,
+ want: v2.Status_RESOURCE_STATUS_DISABLED,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ client := iamClientWithUserKeys("ci-iam-1", []iamTypes.AccessKeyMetadata{{
+ AccessKeyId: awsSdk.String("AKIAEXAMPLE"),
+ UserName: awsSdk.String("ci-iam-1"),
+ CreateDate: awsSdk.Time(created),
+ Status: tc.status,
+ }}, nil, nil)
+
+ resources, _, err := secretBuilder(client, nil).List(context.Background(), nil, resourceSdk.SyncOpAttrs{})
+ require.NoError(t, err)
+ require.Len(t, resources, 1)
+
+ assert.Equal(t, tc.want, resources[0].GetStatus().GetStatus())
+ assert.Equal(t, string(tc.status), resources[0].GetStatus().GetDetails())
+
+ trait := requireSecretTrait(t, resources[0])
+ expectedOwner := &v2.ResourceId{
+ ResourceType: resourceTypeIAMUser.Id,
+ Resource: "arn:aws:iam::123456789012:user/ci-iam-1",
+ }
+ assert.Equal(t, expectedOwner, trait.GetCreatedById())
+ assert.Equal(t, expectedOwner, trait.GetIdentityId())
+ })
+ }
+}
+
+// The service a key last called is what separates a person from automation, so
+// it has to survive onto the resource. IAM reports "N/A" for a key that was
+// never used, and that placeholder must not reach the profile.
+func TestSecretList_ReportsLastUsedService(t *testing.T) {
+ used := time.Date(2026, time.August, 26, 0, 15, 0, 0, time.UTC)
+
+ for _, tc := range []struct {
+ name string
+ lastUsed *iamTypes.AccessKeyLastUsed
+ wantFields map[string]any
+ }{
+ {
+ name: "service and region are surfaced",
+ lastUsed: &iamTypes.AccessKeyLastUsed{
+ LastUsedDate: awsSdk.Time(used),
+ ServiceName: awsSdk.String("iam"),
+ Region: awsSdk.String("us-east-1"),
+ },
+ wantFields: map[string]any{"last_used_service": "iam", "last_used_region": "us-east-1"},
+ },
+ {
+ name: "never used key reports no service",
+ lastUsed: &iamTypes.AccessKeyLastUsed{
+ ServiceName: awsSdk.String("N/A"),
+ Region: awsSdk.String("N/A"),
+ },
+ wantFields: map[string]any{},
+ },
+ {
+ name: "N/A placeholders are omitted even when a last-used date is present",
+ lastUsed: &iamTypes.AccessKeyLastUsed{
+ LastUsedDate: awsSdk.Time(used),
+ ServiceName: awsSdk.String("N/A"),
+ Region: awsSdk.String("N/A"),
+ },
+ wantFields: map[string]any{},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ client := iamClientWithUserKeys("ci-iam-1", []iamTypes.AccessKeyMetadata{{
+ AccessKeyId: awsSdk.String("AKIAEXAMPLE"),
+ UserName: awsSdk.String("ci-iam-1"),
+ CreateDate: awsSdk.Time(time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)),
+ Status: iamTypes.StatusTypeActive,
+ }}, tc.lastUsed, nil)
+
+ resources, _, err := secretBuilder(client, nil).List(context.Background(), nil, resourceSdk.SyncOpAttrs{})
+ require.NoError(t, err)
+ require.Len(t, resources, 1)
+
+ trait := requireSecretTrait(t, resources[0])
+ if tc.lastUsed == nil || tc.lastUsed.LastUsedDate == nil {
+ assert.Nil(t, resources[0].GetProfile())
+ assert.Nil(t, trait.GetLastUsedAt())
+ } else {
+ assert.Equal(t, used, trait.GetLastUsedAt().AsTime())
+ if len(tc.wantFields) == 0 {
+ assert.Nil(t, resources[0].GetProfile())
+ } else {
+ assert.Equal(t, tc.wantFields, resources[0].GetProfile().AsMap())
+ }
+ }
+ })
+ }
+}
+
+func TestSecretList_LookupErrorStillSyncsKey(t *testing.T) {
+ client := iamClientWithUserKeys("ci-iam-1", []iamTypes.AccessKeyMetadata{{
+ AccessKeyId: awsSdk.String("AKIAEXAMPLE"),
+ UserName: awsSdk.String("ci-iam-1"),
+ CreateDate: awsSdk.Time(time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)),
+ Status: iamTypes.StatusTypeActive,
+ }}, nil, errors.New("AccessDenied"))
+
+ resources, _, err := secretBuilder(client, nil).List(context.Background(), nil, resourceSdk.SyncOpAttrs{})
+ require.NoError(t, err)
+ require.Len(t, resources, 1)
+ assert.Nil(t, resources[0].GetProfile())
+ assert.Nil(t, requireSecretTrait(t, resources[0]).GetLastUsedAt())
+ assert.Equal(t, v2.Status_RESOURCE_STATUS_ENABLED, resources[0].GetStatus().GetStatus())
+}