diff --git a/README.md b/README.md
index 88be3874..7ebb8ace 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,8 @@ When access-key activity is available, an IAM user's Last Login is the most rece
Omitting `UserTrait.LastLogin` keeps the connector from publishing an incomplete timestamp as authoritative, but it does not clear a Last Login that C1 already stored. C1's current ingestion skips users whose incoming `UserTrait.LastLogin` is nil and only advances a stored timestamp when the incoming value is newer, so a user synced first with readable access-key activity and then with the lookup denied keeps the previously ingested value in C1. Use `access_key_activity_status` to tell whether the sync that produced a profile could read current access-key activity; clearing or propagating an unavailable Last Login requires platform-side support.
+Set `--sync-resource-tags` to publish AWS resource tags on accounts, IAM users, and IAM roles as an `aws_tags` profile field (a nested map of tag key to tag value). None of the `List*` APIs return tags — `organizations.Account` has no `Tags` field at all, and `iam:ListUsers` / `iam:ListRoles` always return an empty `Tags` slice — so each resource costs at least one extra call (`organizations:ListTagsForResource`, `iam:ListUserTags`, `iam:ListRoleTags`). The documented 50-tag quota counts only user-created tags; AWS-reserved `aws:`-prefixed system tags are additional, so the connector reads tags across pages rather than assuming one response covers them. The flag is off by default because `organizations:ListTagsForResource` is throttled at 10 requests/second (burst 15) per account, so a 1,000-account organization spends roughly 100 seconds on tag reads alone. A missing tag permission fails the sync with a `PermissionDenied` naming the action to grant, rather than quietly syncing untagged resources — enabling the flag is an explicit request for tags, and C1 policy rules that read them would otherwise evaluate against tags that silently are not there.
+
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.
@@ -159,6 +161,7 @@ Flags:
--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-tags Sync AWS resource tags onto accounts, IAM users, and IAM roles as the aws_tags profile field. Tags are not returned by the List APIs, so this costs at least one extra API call per resource. ($BATON_SYNC_RESOURCE_TAGS)
--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)
@@ -198,6 +201,9 @@ _These policies have comments prefixed with // that need to be removed before us
"iam:GetAccessKeyLastUsed",
// Optional: only used with --sync-iam-user-console-access.
"iam:GetLoginProfile",
+ // Optional: only used with --sync-resource-tags.
+ "iam:ListUserTags",
+ "iam:ListRoleTags",
"iam:ListSigningCertificates",
"iam:ListSSHPublicKeys",
"iam:ListServiceSpecificCredentials",
@@ -239,6 +245,8 @@ _These policies have comments prefixed with // that need to be removed before us
"organizations:ListParents",
"organizations:ListRoots",
"organizations:ListOrganizationalUnitsForParent",
+ // Optional: only used with --sync-resource-tags.
+ "organizations:ListTagsForResource",
"sso:ListInstances",
"sso:ListPermissionSets",
"sso:DescribePermissionSet",
@@ -306,6 +314,9 @@ _These policies have comments prefixed with // that need to be removed before us
"iam:GetAccessKeyLastUsed",
// Optional: only used with --sync-iam-user-console-access.
"iam:GetLoginProfile",
+ // Optional: only used with --sync-resource-tags.
+ "iam:ListUserTags",
+ "iam:ListRoleTags",
"iam:ListSigningCertificates",
"iam:ListSSHPublicKeys",
"iam:ListServiceSpecificCredentials",
@@ -347,6 +358,8 @@ _These policies have comments prefixed with // that need to be removed before us
"organizations:ListParents",
"organizations:ListRoots",
"organizations:ListOrganizationalUnitsForParent",
+ // Optional: only used with --sync-resource-tags.
+ "organizations:ListTagsForResource",
"sso:ListInstances",
"sso:ListPermissionSets",
"sso:DescribePermissionSet",
@@ -547,6 +560,9 @@ Each sub-account will need to have the following policy attached to the role tha
"iam:GetAccessKeyLastUsed",
// Optional: only used with --sync-iam-user-console-access.
"iam:GetLoginProfile",
+ // Optional: only used with --sync-resource-tags.
+ "iam:ListUserTags",
+ "iam:ListRoleTags",
"iam:ListSigningCertificates",
"iam:ListSSHPublicKeys",
"iam:ListServiceSpecificCredentials",
diff --git a/baton_capabilities.json b/baton_capabilities.json
index 8d39b7eb..581a9fe1 100644
--- a/baton_capabilities.json
+++ b/baton_capabilities.json
@@ -67,6 +67,9 @@
{
"permission": "organizations:ListParents"
},
+ {
+ "permission": "organizations:ListTagsForResource"
+ },
{
"permission": "sso:ListPermissionSets"
},
@@ -152,6 +155,9 @@
{
"permission": "organizations:ListParents"
},
+ {
+ "permission": "organizations:ListTagsForResource"
+ },
{
"permission": "sso:ListPermissionSets"
},
@@ -241,6 +247,9 @@
"permissions": [
{
"permission": "iam:ListAccountAliases"
+ },
+ {
+ "permission": "organizations:ListTagsForResource"
}
]
}
@@ -253,6 +262,9 @@
"permissions": [
{
"permission": "iam:ListAccountAliases"
+ },
+ {
+ "permission": "organizations:ListTagsForResource"
}
]
}
@@ -448,6 +460,9 @@
{
"permission": "iam:ListGroupsForUser"
},
+ {
+ "permission": "iam:ListUserTags"
+ },
{
"permission": "iam:CreateUser"
},
@@ -525,6 +540,9 @@
{
"permission": "iam:ListGroupsForUser"
},
+ {
+ "permission": "iam:ListUserTags"
+ },
{
"permission": "iam:CreateUser"
},
@@ -900,6 +918,9 @@
},
{
"permission": "iam:ListAttachedRolePolicies"
+ },
+ {
+ "permission": "iam:ListRoleTags"
}
]
}
@@ -918,6 +939,9 @@
},
{
"permission": "iam:ListAttachedRolePolicies"
+ },
+ {
+ "permission": "iam:ListRoleTags"
}
]
}
diff --git a/config_schema.json b/config_schema.json
index 02928afb..635bf56b 100644
--- a/config_schema.json
+++ b/config_schema.json
@@ -236,6 +236,12 @@
"description": "Only sync IAM managed policies that are attached to at least one user, role, or group",
"boolField": {}
},
+ {
+ "name": "sync-resource-tags",
+ "displayName": "Sync Resource Tags",
+ "description": "Sync AWS resource tags onto accounts, IAM users, and IAM roles as the aws_tags profile field. Tags are not returned by the List APIs, so this costs at least one extra API call per resource.",
+ "boolField": {}
+ },
{
"name": "create-account-resource-type",
"displayName": "Account Provisioning Target",
diff --git a/docs/connector.mdx b/docs/connector.mdx
index c6a5d346..f76ff1f8 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -123,6 +123,8 @@ 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`) — reports `console_access_status` as `enabled` when AWS returns a login profile, `disabled` when no login profile exists, or `unavailable` when AWS denies the lookup. The connector includes `console_access_enabled`, `password_reset_required`, and `login_profile_created_at` only when the state is known. 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.
+- **Sync Resource Tags** (`BATON_SYNC_RESOURCE_TAGS`) — without it the `aws_tags` profile field on accounts, IAM users, and IAM roles is empty. It is off by default because tags are not returned by any `List*` call, so it costs at least one extra call per resource (`organizations:ListTagsForResource`, `iam:ListUserTags`, `iam:ListRoleTags`). The 50-tag quota covers only user-created tags — AWS-reserved `aws:` system tags are additional — so tags are read across pages. `organizations:ListTagsForResource` is throttled at 10 requests/second per account, which is the practical cost in a large organization. A missing tag permission fails the sync with a `PermissionDenied` naming the action to grant, rather than quietly syncing untagged resources: enabling this setting is an explicit request for tags, so losing them silently would leave policy rules evaluating against tags that are not there.
+
IAM user **Last Login** does not depend on either optional 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.
@@ -268,7 +270,9 @@ Next, you will create an inline policy to define the specific data this role can
"iam:GetRole",
"iam:ListAccessKeys",
"iam:GetAccessKeyLastUsed",
- "iam:GetLoginProfile"
+ "iam:GetLoginProfile",
+ "iam:ListUserTags",
+ "iam:ListRoleTags"
],
"Resource": "*"
},
@@ -277,7 +281,8 @@ Next, you will create an inline policy to define the specific data this role can
"Effect": "Allow",
"Action": [
"organizations:ListAccounts",
- "organizations:DescribeOrganization"
+ "organizations:DescribeOrganization",
+ "organizations:ListTagsForResource"
],
"Resource": "*"
},
@@ -332,6 +337,11 @@ Next, you will create an inline policy to define the specific data this role can
* iam:GetLoginProfile: Allows C1 to see whether each IAM user has a console login profile, and whether a password reset is required.
+ **Optional: Resource Tags** The JSON above includes these permissions. They are used only when **Sync Resource Tags** (`BATON_SYNC_RESOURCE_TAGS`) is enabled. The flag is off by default because it makes at least one extra call per account, IAM user, and IAM role. If you enable it, these permissions are required — the sync fails without them rather than silently omitting tags.
+
+ * iam:ListUserTags and iam:ListRoleTags: Allow C1 to read the tags on each IAM user and role. The `ListUsers` and `ListRoles` responses do not include tags, so these per-resource calls are the only source.
+ * organizations:ListTagsForResource: Allows C1 to read the tags on each AWS account. The `ListAccounts` response has no tags field at all.
+
**Optional: AWS Organizations Support** Include these permissions if you enable the "Enable support for AWS Organizations" checkbox in the C1 UI.
* organizations:ListAccounts: Allows the connector to discover all accounts within your AWS Organization.
@@ -492,6 +502,9 @@ The permissions policy below is broken into several sections to align with these
"sso:ListPermissionSetsProvisionedToAccount",
"organizations:ListRoots",
"organizations:ListOrganizationalUnitsForParent",
+ "organizations:ListTagsForResource",
+ "iam:ListUserTags",
+ "iam:ListRoleTags",
"iam:GetUser",
"iam:ListAccessKeys",
"iam:ListSigningCertificates",
@@ -638,6 +651,7 @@ The permissions policy below is broken into several sections to align with these
- 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.
- `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. When cross-account IAM sync is active, grant both actions to the assumed role in every member account.
- `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.
+ - `organizations:ListTagsForResource`, `iam:ListUserTags` and `iam:ListRoleTags` are only needed when **Sync Resource Tags** is enabled. They let C1 read the tags on each account, IAM user and IAM role. None of the `List*` calls return tags, so these are the only source. If the setting is on and a permission is missing, the sync fails rather than silently omitting tags.
Click **Review Policy**.
@@ -694,6 +708,9 @@ The permissions policy below is broken into several sections to align with these
**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.** Enable **Sync Resource Tags** to publish AWS tags on accounts, IAM users and IAM roles as an `aws_tags` profile field, usable in policy rules. The connector role must include `organizations:ListTagsForResource`, `iam:ListUserTags` and `iam:ListRoleTags`. This adds at least one API call per account, user and role.
+
+
**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`.
@@ -797,6 +814,10 @@ stringData:
# Requires Organizations and Identity Center support plus cloudtrail:LookupEvents
BATON_SYNC_SSO_USER_LAST_LOGIN: true
+ # Optional: Publish AWS resource tags as the aws_tags profile field
+ # Requires organizations:ListTagsForResource, iam:ListUserTags, iam:ListRoleTags
+ BATON_SYNC_RESOURCE_TAGS: 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
@@ -991,6 +1012,9 @@ resource "aws_iam_role" "ConductorOneIntegration" {
"organizations:ListOrganizationalUnitsForParent",
"organizations:ListParents",
"organizations:ListRoots",
+ "organizations:ListTagsForResource",
+ "iam:ListUserTags",
+ "iam:ListRoleTags",
"sso:DescribePermissionSet",
"sso:GetInlinePolicyForPermissionSet",
"sso:ListAccountAssignments",
diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go
index 1db76d1d..101d037c 100644
--- a/pkg/config/conf.gen.go
+++ b/pkg/config/conf.gen.go
@@ -21,6 +21,7 @@ type Aws struct {
SyncSsoUserLastLogin bool `mapstructure:"sync-sso-user-last-login"`
SyncIamUserConsoleAccess bool `mapstructure:"sync-iam-user-console-access"`
SyncOnlyAttachedPolicies bool `mapstructure:"sync-only-attached-policies"`
+ SyncResourceTags bool `mapstructure:"sync-resource-tags"`
CreateAccountResourceType string `mapstructure:"create-account-resource-type"`
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 53edf7ae..29b05a83 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -143,6 +143,15 @@ var (
field.WithDescription("Only sync IAM managed policies that are attached to at least one user, role, or group"),
field.WithDefaultValue(false),
)
+ SyncResourceTags = field.BoolField(
+ "sync-resource-tags",
+ field.WithDisplayName("Sync Resource Tags"),
+ field.WithDescription(
+ "Sync AWS resource tags onto accounts, IAM users, and IAM roles as the aws_tags profile field. "+
+ "Tags are not returned by the List APIs, so this costs at least one extra API call per resource.",
+ ),
+ field.WithDefaultValue(false),
+ )
GlobalAwsAccountProvisioningTargetField = field.SelectField(
"create-account-resource-type",
[]string{"iam_user", "sso_user"},
@@ -176,6 +185,7 @@ var Config = field.NewConfiguration(
SyncSSOUserLastLogin,
SyncIAMUserConsoleAccess,
SyncOnlyAttachedPolicies,
+ SyncResourceTags,
GlobalAwsAccountProvisioningTargetField,
},
field.WithConstraints(
diff --git a/pkg/connector/account.go b/pkg/connector/account.go
index dfc5b32c..3be9cb1b 100644
--- a/pkg/connector/account.go
+++ b/pkg/connector/account.go
@@ -145,6 +145,10 @@ type accountResourceType struct {
// this so accounts never point at a Root/OU resource that this run never syncs, which
// would otherwise leave a dangling "MISSING RESOURCE" parent.
hierarchySync HierarchySyncFlags
+
+ // syncResourceTags gates the per-account organizations:ListTagsForResource call.
+ // See tags.go for why this is opt-in.
+ syncResourceTags bool
}
func (o *accountResourceType) ResourceType(_ context.Context) *v2.ResourceType {
@@ -194,6 +198,15 @@ func (o *accountResourceType) List(ctx context.Context, _ *v2.ResourceId, opts r
l.Debug("baton-aws: account found", zap.String("name", name), zap.String("account_id", accountId), zap.String("account_status", string(status)))
profile := accountProfile(ctx, account)
+
+ if o.syncResourceTags {
+ tags, err := fetchAccountTags(ctx, o.orgClient, accountId)
+ if err != nil {
+ return nil, nil, err
+ }
+ profile[tagsProfileField] = tags
+ }
+
resourceOpts := []resourceSdk.ResourceOption{
resourceSdk.WithAnnotation(annos),
// Sparse ACLs: advertise the scope-binding type as a child so the SDK
@@ -973,6 +986,7 @@ func accountBuilder(
region string,
identityClient client.IdentityStoreClient,
hierarchySync HierarchySyncFlags,
+ syncResourceTags bool,
) *accountResourceType {
return &accountResourceType{
resourceType: resourceTypeAccount,
@@ -983,6 +997,7 @@ func accountBuilder(
identityInstance: identityInstance,
region: region,
hierarchySync: hierarchySync,
+ syncResourceTags: syncResourceTags,
}
}
diff --git a/pkg/connector/account_iam.go b/pkg/connector/account_iam.go
index e7cfde62..0ee11039 100644
--- a/pkg/connector/account_iam.go
+++ b/pkg/connector/account_iam.go
@@ -75,6 +75,15 @@ func (o *accountIAMResourceType) List(ctx context.Context, _ *v2.ResourceId, opt
Id: awsSdk.ToString(account.Id),
}
profile := accountProfile(ctx, account)
+
+ if o.aws != nil && o.aws.syncResourceTags {
+ tags, err := fetchAccountTags(ctx, o.orgClient, awsSdk.ToString(account.Id))
+ if err != nil {
+ return nil, nil, err
+ }
+ profile[tagsProfileField] = tags
+ }
+
userResource, err := resourceSdk.NewAppResource(
awsSdk.ToString(account.Name),
resourceTypeAccountIam,
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 0d6893fa..7d97f46e 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -59,6 +59,7 @@ type Config struct {
SyncSSOUserLastLogin bool
SyncIAMUserConsoleAccess bool
SyncOnlyAttachedPolicies bool
+ SyncResourceTags bool
AccountProvisioningTarget string
}
@@ -111,6 +112,7 @@ type AWS struct {
syncIAMUserConsoleAccess bool
syncOnlyAttachedPolicies bool
syncIAMPolicyGrants bool
+ syncResourceTags bool
accountProvisioningTarget string
}
@@ -370,6 +372,7 @@ func New(ctx context.Context, awsc *cfg.Aws, connectorOpts *cli.ConnectorOpts) (
SyncSSOUserLastLogin: awsc.SyncSsoUserLastLogin,
SyncIAMUserConsoleAccess: awsc.SyncIamUserConsoleAccess,
SyncOnlyAttachedPolicies: awsc.SyncOnlyAttachedPolicies,
+ SyncResourceTags: awsc.SyncResourceTags,
AccountProvisioningTarget: awsc.CreateAccountResourceType,
}
if config.AccountProvisioningTarget == "" {
@@ -411,6 +414,7 @@ func New(ctx context.Context, awsc *cfg.Aws, connectorOpts *cli.ConnectorOpts) (
syncIAMUserConsoleAccess: config.SyncIAMUserConsoleAccess,
syncOnlyAttachedPolicies: config.SyncOnlyAttachedPolicies,
syncIAMPolicyGrants: syncIAMPolicyGrants,
+ syncResourceTags: config.SyncResourceTags,
accountProvisioningTarget: config.AccountProvisioningTarget,
@@ -555,7 +559,7 @@ func (c *AWS) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSy
l := ctxzap.Extract(ctx)
rs := []connectorbuilder.ResourceSyncerV2{
iamUserBuilder(c.iamClient, c.awsClientFactory, c, c.syncIAMPolicyGrants),
- iamRoleBuilder(c.iamClient, c.awsClientFactory, c.syncIAMPolicyGrants),
+ iamRoleBuilder(c.iamClient, c.awsClientFactory, c.syncIAMPolicyGrants, c.syncResourceTags),
iamGroupBuilder(c.iamClient, c.awsClientFactory, c.syncIAMPolicyGrants),
iamPolicyBuilder(c.iamClient, c.awsClientFactory, c.syncOnlyAttachedPolicies),
// ssoAdminClient/identityInstance are nil when SSO is disabled; the inline
@@ -579,7 +583,7 @@ func (c *AWS) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSy
if c.orgsEnabled && c.ssoEnabled {
l.Debug("orgsEnabled. creating accountBuilder")
acct := accountBuilder(c.orgClient, c.roleARN, c.ssoAdminClient, c.identityInstance, c.ssoRegion, c.identityStoreClient,
- HierarchySyncFlags{Organization: c.willSyncOrganization, OrganizationalUnit: c.willSyncOrganizationalUnit})
+ HierarchySyncFlags{Organization: c.willSyncOrganization, OrganizationalUnit: c.willSyncOrganizationalUnit}, c.syncResourceTags)
rs = append(rs,
acct,
// Sparse ACLs (Cloud Infrastructure Access): permission set as role, and the
@@ -619,15 +623,15 @@ func (d *defaultCapabilitiesBuilder) Validate(_ context.Context) (annotations.An
func (d *defaultCapabilitiesBuilder) ResourceSyncers(_ context.Context) []connectorbuilder.ResourceSyncerV2 {
return []connectorbuilder.ResourceSyncerV2{
iamUserBuilder(nil, nil, nil, true),
- iamRoleBuilder(nil, nil, true),
+ iamRoleBuilder(nil, nil, true, true),
iamGroupBuilder(nil, nil, true),
iamPolicyBuilder(nil, nil, false),
inlinePolicyBuilder(nil, nil, nil, nil),
ssoUserBuilder("", nil, nil, nil, nil),
ssoGroupBuilder("", nil, nil, nil),
- accountBuilder(nil, "", nil, nil, "", nil, HierarchySyncFlags{Organization: true, OrganizationalUnit: true}),
+ accountBuilder(nil, "", nil, nil, "", nil, HierarchySyncFlags{Organization: true, OrganizationalUnit: true}, true),
permissionSetBuilder(nil, nil, true),
- permissionSetAssignmentBuilder(accountBuilder(nil, "", nil, nil, "", nil, HierarchySyncFlags{Organization: true, OrganizationalUnit: true})),
+ permissionSetAssignmentBuilder(accountBuilder(nil, "", nil, nil, "", nil, HierarchySyncFlags{Organization: true, OrganizationalUnit: true}, true)),
organizationBuilder(nil),
organizationalUnitBuilder(nil),
accountIAMBuilder(nil, nil, nil, true),
diff --git a/pkg/connector/iam_policy.go b/pkg/connector/iam_policy.go
index ae29e781..f8df94cf 100644
--- a/pkg/connector/iam_policy.go
+++ b/pkg/connector/iam_policy.go
@@ -28,6 +28,10 @@ import (
const (
iamPolicyAttachedEntitlement = "attached"
+ // policyNameProfileField is the profile key carrying a policy's name, shared by
+ // managed policies and the inline policies in inline_policy.go.
+ policyNameProfileField = "aws_policy_name"
+
// AWS-managed policy documents are global; cache them so multi-account List
// does not re-fetch the same public document once per account.
iamPolicyDocumentCacheKeyPrefix = "aws-connector-iam-policy-document:"
@@ -152,8 +156,8 @@ func (o *iamPolicyResourceType) List(ctx context.Context, parentId *v2.ResourceI
awsManaged := isAWSManagedPolicyARN(policyARN)
profile := map[string]any{
- "aws_policy_name": awsSdk.ToString(policy.PolicyName),
- "aws_policy_arn": policyARN,
+ policyNameProfileField: awsSdk.ToString(policy.PolicyName),
+ "aws_policy_arn": policyARN,
}
policyDocument, err := o.getPolicyDocument(ctx, opts.Session, iamClient, policyARN)
diff --git a/pkg/connector/iam_user.go b/pkg/connector/iam_user.go
index 4c05319f..cfbb10c7 100644
--- a/pkg/connector/iam_user.go
+++ b/pkg/connector/iam_user.go
@@ -97,6 +97,17 @@ func (o *iamUserResourceType) List(ctx context.Context, parentId *v2.ResourceId,
}
options := make([]resourceSdk.UserTraitOption, 0)
+ // ListUsers always returns an empty Tags slice, so the aws_tags set by
+ // iamUserProfile is a placeholder. Only a per-user iam:ListUserTags call
+ // yields real tags; see tags.go.
+ if o.aws != nil && o.aws.syncResourceTags {
+ tags, err := fetchIAMUserTags(ctx, iamClient, awsSdk.ToString(user.UserName))
+ if err != nil {
+ return nil, nil, err
+ }
+ profile[tagsProfileField] = tags
+ }
+
if o.aws != nil && o.aws.syncIAMUserConsoleAccess {
consoleAccess, err := getConsoleAccess(ctx, iamClient, user)
if err != nil {
@@ -239,7 +250,7 @@ func iamUserProfile(ctx context.Context, user iamTypes.User) map[string]interfac
profile["aws_arn"] = awsSdk.ToString(user.Arn)
profile["aws_path"] = awsSdk.ToString(user.Path)
profile["aws_user_type"] = iamType
- profile["aws_tags"] = userTagsToMap(user)
+ profile[tagsProfileField] = userTagsToMap(user)
profile["aws_user_id"] = awsSdk.ToString(user.UserId)
return profile
diff --git a/pkg/connector/inline_policy.go b/pkg/connector/inline_policy.go
index 0ecf58da..4c6375a5 100644
--- a/pkg/connector/inline_policy.go
+++ b/pkg/connector/inline_policy.go
@@ -94,8 +94,8 @@ func (o *inlinePolicyResourceType) List(ctx context.Context, parentId *v2.Resour
resourceID := inlinePolicyResourceID(parentId.Resource, policyName)
profile := map[string]any{
- "aws_policy_name": policyName,
- "aws_parent_arn": parentId.Resource,
+ policyNameProfileField: policyName,
+ "aws_parent_arn": parentId.Resource,
}
policyDocument, err := o.getInlinePolicyDocument(ctx, iamClient, parentId, policyName)
if err != nil {
@@ -175,9 +175,9 @@ func (o *inlinePolicyResourceType) listPermissionSetInlinePolicy(ctx context.Con
}
profile := map[string]any{
- "aws_policy_name": permissionSetInlinePolicyName,
- "aws_parent_arn": parentId.Resource,
- "policy_document": document,
+ policyNameProfileField: permissionSetInlinePolicyName,
+ "aws_parent_arn": parentId.Resource,
+ "policy_document": document,
}
policyResource, err := resourceSdk.NewRoleResource(
permissionSetInlinePolicyName,
diff --git a/pkg/connector/organization_test.go b/pkg/connector/organization_test.go
index af3332be..08f23662 100644
--- a/pkg/connector/organization_test.go
+++ b/pkg/connector/organization_test.go
@@ -369,5 +369,5 @@ func newOrgAccountWithSyncFilter(orgs *fakeOrgs, willSyncOrganization, willSyncO
IdentityStoreId: awsSdk.String(behaviorIdentityStoreID),
}
return accountBuilder(orgs, "", &fakeSSOAdmin{}, identityInstance, behaviorRegion, nil,
- HierarchySyncFlags{Organization: willSyncOrganization, OrganizationalUnit: willSyncOrganizationalUnit})
+ HierarchySyncFlags{Organization: willSyncOrganization, OrganizationalUnit: willSyncOrganizationalUnit}, false)
}
diff --git a/pkg/connector/partition.go b/pkg/connector/partition.go
index 5e4d2fcd..4f4ca833 100644
--- a/pkg/connector/partition.go
+++ b/pkg/connector/partition.go
@@ -10,12 +10,16 @@ import (
"google.golang.org/grpc/status"
)
+// awsPartition is the commercial AWS partition id, and the fallback for any region that
+// matches no other partition's region prefix.
+const awsPartition = "aws"
+
// Supported partitions, and the region prefixes that identify each. GovCloud and the ISO
// partitions are absent because nothing here has been exercised against them. Matching is
// case-sensitive, like the SDK's own aws-cn region regex.
var partitionRegionPrefixes = map[string][]string{
- "aws": {},
- "aws-cn": {"cn-"},
+ awsPartition: {},
+ "aws-cn": {"cn-"},
}
func partitionForRegion(region string) string {
@@ -26,7 +30,7 @@ func partitionForRegion(region string) string {
}
}
}
- return "aws"
+ return awsPartition
}
// unsupportedPartitionError returns nil for a partition the connector supports. Shared by
diff --git a/pkg/connector/permission_set_assignment_behavior_test.go b/pkg/connector/permission_set_assignment_behavior_test.go
index 9884f42b..4098b1ea 100644
--- a/pkg/connector/permission_set_assignment_behavior_test.go
+++ b/pkg/connector/permission_set_assignment_behavior_test.go
@@ -162,8 +162,10 @@ type fakeOrgs struct {
listOUsFn func(*awsOrgs.ListOrganizationalUnitsForParentInput) (*awsOrgs.ListOrganizationalUnitsForParentOutput, error)
listParentsFn func(*awsOrgs.ListParentsInput) (*awsOrgs.ListParentsOutput, error)
describeAccountFn func(*awsOrgs.DescribeAccountInput) (*awsOrgs.DescribeAccountOutput, error)
+ listTagsFn func(*awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error)
listParentsCalls int
+ listTagsCalls int
}
func (f *fakeOrgs) DescribeAccount(_ context.Context, in *awsOrgs.DescribeAccountInput, _ ...func(*awsOrgs.Options)) (*awsOrgs.DescribeAccountOutput, error) {
@@ -198,6 +200,18 @@ func (f *fakeOrgs) ListOrganizationalUnitsForParent(
return &awsOrgs.ListOrganizationalUnitsForParentOutput{}, nil
}
+func (f *fakeOrgs) ListTagsForResource(
+ _ context.Context,
+ in *awsOrgs.ListTagsForResourceInput,
+ _ ...func(*awsOrgs.Options),
+) (*awsOrgs.ListTagsForResourceOutput, error) {
+ f.listTagsCalls++
+ if f.listTagsFn != nil {
+ return f.listTagsFn(in)
+ }
+ return &awsOrgs.ListTagsForResourceOutput{}, nil
+}
+
func (f *fakeOrgs) ListParents(_ context.Context, in *awsOrgs.ListParentsInput, _ ...func(*awsOrgs.Options)) (*awsOrgs.ListParentsOutput, error) {
f.listParentsCalls++
if f.listParentsFn != nil {
@@ -219,7 +233,7 @@ func newBehaviorAccount(sso *fakeSSOAdmin) *accountResourceType {
IdentityStoreId: awsSdk.String(behaviorIdentityStoreID),
}
return accountBuilder(&fakeOrgs{}, "", sso, identityInstance, behaviorRegion, &test.MockedIdentityStoreClient{},
- HierarchySyncFlags{Organization: true, OrganizationalUnit: true})
+ HierarchySyncFlags{Organization: true, OrganizationalUnit: true}, false)
}
func behaviorBinding(t *testing.T) (*v2.Resource, *v2.Entitlement) {
diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go
index 75e4b521..ba88cb86 100644
--- a/pkg/connector/resource_types.go
+++ b/pkg/connector/resource_types.go
@@ -5,6 +5,12 @@ import (
"github.com/conductorone/baton-sdk/pkg/annotations"
)
+// resourceTypeIDOrganizationalUnit is the organizational_unit resource type id. It is a
+// const rather than a reference to resourceTypeOrganizationalUnit.Id because the OU type
+// declares itself as its own child (nested OUs), which as a var reference would be an
+// initialization cycle.
+const resourceTypeIDOrganizationalUnit = "organizational_unit"
+
func capabilityPermissions(perms ...string) *v2.CapabilityPermissions {
cp := &v2.CapabilityPermissions{}
for _, p := range perms {
@@ -28,6 +34,8 @@ var (
"iam:ListRoles",
"iam:GetRole",
"iam:ListAttachedRolePolicies",
+ // Only called when sync-resource-tags is enabled; ListRoles returns no tags.
+ "iam:ListRoleTags",
)),
}
@@ -78,6 +86,8 @@ var (
// Sparse ACLs hierarchy (Phase 2): resolve each account's parent (Root/OU) so
// c1's by-inheritance review can walk the org tree. Fail-soft if absent.
"organizations:ListParents",
+ // Only called when sync-resource-tags is enabled; ListAccounts returns no tags.
+ "organizations:ListTagsForResource",
"sso:ListPermissionSets",
"sso:DescribePermissionSet",
"sso:ListPermissionSetsProvisionedToAccount",
@@ -114,6 +124,8 @@ var (
&v2.V1Identifier{Id: "account_iam"},
capabilityPermissions(
"iam:ListAccountAliases",
+ // Only called when sync-resource-tags is enabled; ListAccounts returns no tags.
+ "organizations:ListTagsForResource",
),
),
}
@@ -158,6 +170,8 @@ var (
"iam:ListUserPolicies",
"iam:ListAttachedUserPolicies",
"iam:ListGroupsForUser",
+ // Only called when sync-resource-tags is enabled; ListUsers returns no tags.
+ "iam:ListUserTags",
// Provision
"iam:CreateUser",
"iam:DeleteLoginProfile",
@@ -253,7 +267,7 @@ var (
&v2.SkipEntitlementsAndGrants{},
&v2.OptInRequired{},
// The root is the crawl seed for the OU tree.
- &v2.ChildResourceType{ResourceTypeId: "organizational_unit"},
+ &v2.ChildResourceType{ResourceTypeId: resourceTypeIDOrganizationalUnit},
capabilityPermissions(
"organizations:ListRoots",
"organizations:ListOrganizationalUnitsForParent",
@@ -268,12 +282,12 @@ var (
// matching annotation attached to each emitted resource instance (see
// organizationalUnitResource in organization.go). SkipEntitlementsAndGrants + OptInRequired.
resourceTypeOrganizationalUnit = &v2.ResourceType{
- Id: "organizational_unit",
+ Id: resourceTypeIDOrganizationalUnit,
DisplayName: "Organizational Unit",
Annotations: annotations.New(
&v2.SkipEntitlementsAndGrants{},
&v2.OptInRequired{},
- &v2.ChildResourceType{ResourceTypeId: "organizational_unit"},
+ &v2.ChildResourceType{ResourceTypeId: resourceTypeIDOrganizationalUnit},
capabilityPermissions(
"organizations:ListOrganizationalUnitsForParent",
),
diff --git a/pkg/connector/role.go b/pkg/connector/role.go
index 96da24c7..c89407dd 100644
--- a/pkg/connector/role.go
+++ b/pkg/connector/role.go
@@ -31,6 +31,9 @@ type roleResourceType struct {
iamClient *iam.Client
awsClientFactory *AWSClientFactory
syncIAMPolicyGrants bool
+
+ // syncResourceTags gates the per-role iam:ListRoleTags call. See tags.go.
+ syncResourceTags bool
}
func (o *roleResourceType) ResourceType(_ context.Context) *v2.ResourceType {
@@ -74,6 +77,18 @@ func (o *roleResourceType) List(ctx context.Context, parentId *v2.ResourceId, op
Id: awsSdk.ToString(role.Arn),
}
profile := roleProfile(ctx, role)
+
+ // ListRoles always returns an empty Tags slice, so the aws_tags set by
+ // roleProfile is a placeholder. Only a per-role iam:ListRoleTags call
+ // yields real tags; see tags.go.
+ if o.syncResourceTags {
+ tags, err := fetchIAMRoleTags(ctx, iamClient, awsSdk.ToString(role.RoleName))
+ if err != nil {
+ return nil, nil, err
+ }
+ profile[tagsProfileField] = tags
+ }
+
nhiType, nhiDetail := classifyRoleNHI(ctx, role)
roleResource, err := resourceSdk.NewRoleResource(
awsSdk.ToString(role.RoleName),
@@ -259,12 +274,13 @@ func (o *roleResourceType) Grants(
return grants, nil, nil
}
-func iamRoleBuilder(iamClient *iam.Client, awsClientFactory *AWSClientFactory, syncIAMPolicyGrants bool) *roleResourceType {
+func iamRoleBuilder(iamClient *iam.Client, awsClientFactory *AWSClientFactory, syncIAMPolicyGrants bool, syncResourceTags bool) *roleResourceType {
return &roleResourceType{
resourceType: resourceTypeRole,
iamClient: iamClient,
awsClientFactory: awsClientFactory,
syncIAMPolicyGrants: syncIAMPolicyGrants,
+ syncResourceTags: syncResourceTags,
}
}
@@ -280,7 +296,7 @@ func roleProfile(ctx context.Context, role iamTypes.Role) map[string]interface{}
profile := make(map[string]interface{})
profile["aws_arn"] = awsSdk.ToString(role.Arn)
profile["aws_path"] = awsSdk.ToString(role.Path)
- profile["aws_tags"] = roleTagsToMap(role)
+ profile[tagsProfileField] = roleTagsToMap(role)
profile["aws_role_name"] = awsSdk.ToString(role.RoleName)
profile["aws_role_description"] = awsSdk.ToString(role.Description)
// MaxSessionDuration is an IAM-owned role setting returned by ListRoles.
diff --git a/pkg/connector/ssoadmin_api.go b/pkg/connector/ssoadmin_api.go
index 2a46fa1e..12869cae 100644
--- a/pkg/connector/ssoadmin_api.go
+++ b/pkg/connector/ssoadmin_api.go
@@ -72,4 +72,9 @@ type orgsAPI interface {
optFns ...func(*awsOrgs.Options),
) (*awsOrgs.ListOrganizationalUnitsForParentOutput, error)
ListParents(ctx context.Context, params *awsOrgs.ListParentsInput, optFns ...func(*awsOrgs.Options)) (*awsOrgs.ListParentsOutput, error)
+ ListTagsForResource(
+ ctx context.Context,
+ params *awsOrgs.ListTagsForResourceInput,
+ optFns ...func(*awsOrgs.Options),
+ ) (*awsOrgs.ListTagsForResourceOutput, error)
}
diff --git a/pkg/connector/sts_actions.go b/pkg/connector/sts_actions.go
index d5b07b7f..99ef89ae 100644
--- a/pkg/connector/sts_actions.go
+++ b/pkg/connector/sts_actions.go
@@ -31,6 +31,10 @@ const actionAssumeRoleWithWebIdentity = "assume_role_with_web_identity"
const maxSTSSessionPolicyLength = 2048
+// stsExpirationField is the expiration key, shared by the action's declared return type,
+// the encrypted credential envelope, and the action response, so the three cannot drift.
+const stsExpirationField = "expiration"
+
var roleSessionNamePattern = regexp.MustCompile(`^[\w+=,.@-]{2,64}$`)
var assumeRoleWithWebIdentitySchema = &v2.BatonActionSchema{
@@ -73,7 +77,7 @@ var assumeRoleWithWebIdentitySchema = &v2.BatonActionSchema{
Description: "Base64-encoded age ciphertext; never plaintext STS material.", Field: &configv1.Field_StringField{},
},
{Name: "encryption_key_id", DisplayName: "Encryption key ID", Field: &configv1.Field_StringField{}},
- {Name: "expiration", DisplayName: "Expiration", Field: &configv1.Field_StringField{}},
+ {Name: stsExpirationField, DisplayName: "Expiration", Field: &configv1.Field_StringField{}},
{Name: "assumed_role_arn", DisplayName: "Assumed role ARN", Field: &configv1.Field_StringField{}},
},
ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_DYNAMIC},
@@ -149,7 +153,7 @@ func (c *AWS) issueSTSWebIdentitySession(ctx context.Context, args *structpb.Str
"access_key_id": awsSdk.ToString(output.Credentials.AccessKeyId),
"secret_access_key": awsSdk.ToString(output.Credentials.SecretAccessKey),
"session_token": awsSdk.ToString(output.Credentials.SessionToken),
- "expiration": expiration,
+ stsExpirationField: expiration,
})
if err != nil {
return nil, nil, fmt.Errorf("baton-aws: marshal STS credential envelope: %w", err)
@@ -169,7 +173,7 @@ func (c *AWS) issueSTSWebIdentitySession(ctx context.Context, args *structpb.Str
response, err := structpb.NewStruct(map[string]any{
"encrypted_credentials": base64.StdEncoding.EncodeToString(ciphertext.Bytes()),
"encryption_key_id": hex.EncodeToString(keyID[:]),
- "expiration": expiration,
+ stsExpirationField: expiration,
"assumed_role_arn": awsSdk.ToString(output.AssumedRoleUser.Arn),
})
if err != nil {
diff --git a/pkg/connector/tags.go b/pkg/connector/tags.go
new file mode 100644
index 00000000..0940f9eb
--- /dev/null
+++ b/pkg/connector/tags.go
@@ -0,0 +1,121 @@
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ 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"
+ awsOrgs "github.com/aws/aws-sdk-go-v2/service/organizations"
+ awsOrgsTypes "github.com/aws/aws-sdk-go-v2/service/organizations/types"
+)
+
+// tagsProfileField is the profile key carrying a resource's AWS tags, a nested map of tag
+// key -> tag value.
+const tagsProfileField = "aws_tags"
+
+// iamTagsMaxItems is the API default, set explicitly so the request does not depend on it.
+const iamTagsMaxItems int32 = 100
+
+// Tags are not returned by any List* call, so each resource costs an extra request, which
+// is why sync-resource-tags is opt-in. The fetchers paginate because the documented 50-tag
+// quota excludes aws: system tags while the IAM responses cap at 50 items, so a resource
+// can exceed one page. Every error is fatal, including a missing permission: the flag is an
+// explicit request for tags, and silently untagged resources would leave c1 policy rules
+// evaluating against tags that are not there.
+
+// iamTagsAPI is the subset of the IAM client used for tag reads. It satisfies the SDK's
+// ListUserTagsAPIClient and ListRoleTagsAPIClient paginator interfaces.
+type iamTagsAPI interface {
+ ListUserTags(ctx context.Context, params *iam.ListUserTagsInput, optFns ...func(*iam.Options)) (*iam.ListUserTagsOutput, error)
+ ListRoleTags(ctx context.Context, params *iam.ListRoleTagsInput, optFns ...func(*iam.Options)) (*iam.ListRoleTagsOutput, error)
+}
+
+func putIAMTags(rv map[string]interface{}, tags []iamTypes.Tag) {
+ for _, tag := range tags {
+ rv[awsSdk.ToString(tag.Key)] = awsSdk.ToString(tag.Value)
+ }
+}
+
+func putOrgTags(rv map[string]interface{}, tags []awsOrgsTypes.Tag) {
+ for _, tag := range tags {
+ rv[awsSdk.ToString(tag.Key)] = awsSdk.ToString(tag.Value)
+ }
+}
+
+// fetchAccountTags reads the AWS Organizations tags attached to an account via
+// organizations:ListTagsForResource.
+func fetchAccountTags(ctx context.Context, orgClient orgsAPI, accountID string) (map[string]interface{}, error) {
+ paginator := awsOrgs.NewListTagsForResourcePaginator(
+ orgClient,
+ &awsOrgs.ListTagsForResourceInput{ResourceId: awsSdk.String(accountID)},
+ func(o *awsOrgs.ListTagsForResourcePaginatorOptions) {
+ o.StopOnDuplicateToken = true
+ },
+ )
+
+ rv := make(map[string]interface{})
+ for paginator.HasMorePages() {
+ resp, err := paginator.NextPage(ctx)
+ if err != nil {
+ return nil, wrapAWSError(fmt.Errorf(
+ "baton-aws: organizations.ListTagsForResource failed for account %q "+
+ "(sync-resource-tags requires organizations:ListTagsForResource): %w", accountID, err))
+ }
+ putOrgTags(rv, resp.Tags)
+ }
+ return rv, nil
+}
+
+// fetchIAMUserTags reads an IAM user's tags via iam:ListUserTags.
+func fetchIAMUserTags(ctx context.Context, iamClient iamTagsAPI, userName string) (map[string]interface{}, error) {
+ paginator := iam.NewListUserTagsPaginator(
+ iamClient,
+ &iam.ListUserTagsInput{
+ UserName: awsSdk.String(userName),
+ MaxItems: awsSdk.Int32(iamTagsMaxItems),
+ },
+ func(o *iam.ListUserTagsPaginatorOptions) {
+ o.StopOnDuplicateToken = true
+ },
+ )
+
+ rv := make(map[string]interface{})
+ for paginator.HasMorePages() {
+ resp, err := paginator.NextPage(ctx)
+ if err != nil {
+ return nil, wrapAWSError(fmt.Errorf(
+ "baton-aws: iam.ListUserTags failed for user %q "+
+ "(sync-resource-tags requires iam:ListUserTags): %w", userName, err))
+ }
+ putIAMTags(rv, resp.Tags)
+ }
+ return rv, nil
+}
+
+// fetchIAMRoleTags reads an IAM role's tags via iam:ListRoleTags.
+func fetchIAMRoleTags(ctx context.Context, iamClient iamTagsAPI, roleName string) (map[string]interface{}, error) {
+ paginator := iam.NewListRoleTagsPaginator(
+ iamClient,
+ &iam.ListRoleTagsInput{
+ RoleName: awsSdk.String(roleName),
+ MaxItems: awsSdk.Int32(iamTagsMaxItems),
+ },
+ func(o *iam.ListRoleTagsPaginatorOptions) {
+ o.StopOnDuplicateToken = true
+ },
+ )
+
+ rv := make(map[string]interface{})
+ for paginator.HasMorePages() {
+ resp, err := paginator.NextPage(ctx)
+ if err != nil {
+ return nil, wrapAWSError(fmt.Errorf(
+ "baton-aws: iam.ListRoleTags failed for role %q "+
+ "(sync-resource-tags requires iam:ListRoleTags): %w", roleName, err))
+ }
+ putIAMTags(rv, resp.Tags)
+ }
+ return rv, nil
+}
diff --git a/pkg/connector/tags_test.go b/pkg/connector/tags_test.go
new file mode 100644
index 00000000..121338a9
--- /dev/null
+++ b/pkg/connector/tags_test.go
@@ -0,0 +1,314 @@
+package connector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+
+ 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"
+ awsOrgs "github.com/aws/aws-sdk-go-v2/service/organizations"
+ awsOrgsTypes "github.com/aws/aws-sdk-go-v2/service/organizations/types"
+ awsSsoAdminTypes "github.com/aws/aws-sdk-go-v2/service/ssoadmin/types"
+ resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// fakeIAMTags is an iamTagsAPI whose responses are supplied per call, so pagination
+// and error handling can be exercised without a real IAM client.
+type fakeIAMTags struct {
+ userPages []*iam.ListUserTagsOutput
+ rolePages []*iam.ListRoleTagsOutput
+ err error
+
+ userCalls int
+ roleCalls int
+ lastUserInput *iam.ListUserTagsInput
+ lastRoleInput *iam.ListRoleTagsInput
+}
+
+func (f *fakeIAMTags) ListUserTags(_ context.Context, in *iam.ListUserTagsInput, _ ...func(*iam.Options)) (*iam.ListUserTagsOutput, error) {
+ f.lastUserInput = in
+ if f.err != nil {
+ return nil, f.err
+ }
+ out := f.userPages[f.userCalls]
+ f.userCalls++
+ return out, nil
+}
+
+func (f *fakeIAMTags) ListRoleTags(_ context.Context, in *iam.ListRoleTagsInput, _ ...func(*iam.Options)) (*iam.ListRoleTagsOutput, error) {
+ f.lastRoleInput = in
+ if f.err != nil {
+ return nil, f.err
+ }
+ out := f.rolePages[f.roleCalls]
+ f.roleCalls++
+ return out, nil
+}
+
+func iamTag(k, v string) iamTypes.Tag {
+ return iamTypes.Tag{Key: awsSdk.String(k), Value: awsSdk.String(v)}
+}
+
+func orgTag(k, v string) awsOrgsTypes.Tag {
+ return awsOrgsTypes.Tag{Key: awsSdk.String(k), Value: awsSdk.String(v)}
+}
+
+// newOrgAccountWithTags builds an accountResourceType with tag sync enabled.
+func newOrgAccountWithTags(orgs *fakeOrgs) *accountResourceType {
+ identityInstance := &awsSsoAdminTypes.InstanceMetadata{
+ InstanceArn: awsSdk.String(behaviorInstanceArn),
+ IdentityStoreId: awsSdk.String(behaviorIdentityStoreID),
+ }
+ return accountBuilder(orgs, "", &fakeSSOAdmin{}, identityInstance, behaviorRegion, nil,
+ HierarchySyncFlags{Organization: true, OrganizationalUnit: true}, true)
+}
+
+func oneActiveAccount(_ *awsOrgs.ListAccountsInput) (*awsOrgs.ListAccountsOutput, error) {
+ return &awsOrgs.ListAccountsOutput{Accounts: []awsOrgsTypes.Account{{
+ Id: awsSdk.String(testAccountID),
+ Name: awsSdk.String("prod"),
+ Status: awsOrgsTypes.AccountStatusActive,
+ }}}, nil
+}
+
+func accountTagsFromProfile(t *testing.T, acct *accountResourceType) (map[string]interface{}, bool) {
+ t.Helper()
+ resources, _, err := acct.List(context.Background(), nil, resourceSdk.SyncOpAttrs{})
+ require.NoError(t, err)
+ require.Len(t, resources, 1)
+
+ // WithResourceProfile sets the profile on the resource itself, which is what
+ // maps to AppResource.Profile in c1 (the field CEL reads).
+ profile := resources[0].GetProfile().AsMap()
+ raw, ok := profile[tagsProfileField]
+ if !ok {
+ return nil, false
+ }
+ tags, ok := raw.(map[string]interface{})
+ require.True(t, ok, "aws_tags must be a nested map so CEL can index it")
+ return tags, true
+}
+
+// The account profile carries tags as a nested map when sync-resource-tags is on.
+// c1 exposes this to CEL as resource.profile.aws_tags["Owner"].
+func TestAccountList_TagsOnProfileWhenEnabled(t *testing.T) {
+ orgs := &fakeOrgs{
+ listAccountsFn: oneActiveAccount,
+ listTagsFn: func(in *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ assert.Equal(t, testAccountID, awsSdk.ToString(in.ResourceId))
+ return &awsOrgs.ListTagsForResourceOutput{Tags: []awsOrgsTypes.Tag{
+ orgTag("Owner", "cloudinfrastructure"),
+ orgTag("Description", "Managed by terraform"),
+ }}, nil
+ },
+ }
+
+ tags, present := accountTagsFromProfile(t, newOrgAccountWithTags(orgs))
+ require.True(t, present, "aws_tags must be set when sync-resource-tags is enabled")
+ assert.Equal(t, map[string]interface{}{
+ "Owner": "cloudinfrastructure",
+ "Description": "Managed by terraform",
+ }, tags)
+ assert.Equal(t, 1, orgs.listTagsCalls)
+}
+
+// With the flag off the extra per-account call must not happen at all — this is the
+// whole point of gating it at ~1000-account scale.
+func TestAccountList_NoTagCallWhenDisabled(t *testing.T) {
+ orgs := &fakeOrgs{
+ listAccountsFn: oneActiveAccount,
+ listTagsFn: func(_ *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ t.Fatal("ListTagsForResource must not be called when sync-resource-tags is disabled")
+ return nil, nil
+ },
+ }
+
+ _, present := accountTagsFromProfile(t, newOrgAccount(orgs))
+ assert.False(t, present, "aws_tags must be absent when sync-resource-tags is disabled")
+ assert.Equal(t, 0, orgs.listTagsCalls)
+}
+
+// sync-resource-tags is opt-in, so a missing organizations:ListTagsForResource permission
+// fails the sync loudly instead of quietly emitting untagged accounts — c1 routing rules
+// would otherwise evaluate against tags that silently are not there.
+func TestAccountList_TagReadDeniedIsFatal(t *testing.T) {
+ orgs := &fakeOrgs{
+ listAccountsFn: oneActiveAccount,
+ listTagsFn: func(_ *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ return nil, &awsOrgsTypes.AccessDeniedException{Message: awsSdk.String("no perms")}
+ },
+ }
+
+ _, _, err := newOrgAccountWithTags(orgs).List(context.Background(), nil, resourceSdk.SyncOpAttrs{})
+ require.Error(t, err)
+ assert.Equal(t, codes.PermissionDenied, status.Code(err))
+ assert.Contains(t, err.Error(), "organizations:ListTagsForResource")
+}
+
+// Any non-permission error propagates instead of silently producing untagged accounts.
+func TestAccountList_TagReadErrorPropagates(t *testing.T) {
+ orgs := &fakeOrgs{
+ listAccountsFn: oneActiveAccount,
+ listTagsFn: func(_ *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ return nil, errors.New("boom")
+ },
+ }
+
+ _, _, err := newOrgAccountWithTags(orgs).List(context.Background(), nil, resourceSdk.SyncOpAttrs{})
+ require.Error(t, err)
+}
+
+// The 50-tag quota counts only user-created tags; system tags are additional, and the
+// IAM response array caps at 50. Tags must therefore be read across pages.
+func TestFetchAccountTags_PaginatesAcrossPages(t *testing.T) {
+ page := 0
+ orgs := &fakeOrgs{
+ listTagsFn: func(in *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ page++
+ if page == 1 {
+ assert.Nil(t, in.NextToken)
+ return &awsOrgs.ListTagsForResourceOutput{
+ Tags: []awsOrgsTypes.Tag{orgTag("Owner", "team-a")},
+ NextToken: awsSdk.String("page2"),
+ }, nil
+ }
+ assert.Equal(t, "page2", awsSdk.ToString(in.NextToken))
+ return &awsOrgs.ListTagsForResourceOutput{Tags: []awsOrgsTypes.Tag{orgTag("Env", "prod")}}, nil
+ },
+ }
+
+ tags, err := fetchAccountTags(context.Background(), orgs, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]interface{}{"Owner": "team-a", "Env": "prod"}, tags)
+ assert.Equal(t, 2, orgs.listTagsCalls)
+}
+
+// A single-page response must not cost a second call.
+func TestFetchAccountTags_SinglePageStopsImmediately(t *testing.T) {
+ orgs := &fakeOrgs{
+ listTagsFn: func(_ *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ return &awsOrgs.ListTagsForResourceOutput{Tags: []awsOrgsTypes.Tag{orgTag("Owner", "team-a")}}, nil
+ },
+ }
+
+ tags, err := fetchAccountTags(context.Background(), orgs, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]interface{}{"Owner": "team-a"}, tags)
+ assert.Equal(t, 1, orgs.listTagsCalls)
+}
+
+// A resource whose tags arrive in many small pages must sync completely. ListTagsForResource
+// documents no page size, so nothing here may assume pages are large.
+func TestFetchAccountTags_ManySmallPagesSucceed(t *testing.T) {
+ page := 0
+ orgs := &fakeOrgs{
+ listTagsFn: func(_ *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ page++
+ out := &awsOrgs.ListTagsForResourceOutput{
+ Tags: []awsOrgsTypes.Tag{orgTag(fmt.Sprintf("k%d", page), "v")},
+ }
+ if page < 60 {
+ out.NextToken = awsSdk.String(fmt.Sprintf("tok%d", page))
+ }
+ return out, nil
+ },
+ }
+
+ tags, err := fetchAccountTags(context.Background(), orgs, testAccountID)
+ require.NoError(t, err)
+ assert.Len(t, tags, 60)
+ assert.Equal(t, 60, orgs.listTagsCalls)
+}
+
+// An endpoint that echoes the same token back must terminate on the first repeat.
+func TestFetchAccountTags_StopsOnDuplicateToken(t *testing.T) {
+ orgs := &fakeOrgs{
+ listTagsFn: func(_ *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) {
+ return &awsOrgs.ListTagsForResourceOutput{
+ Tags: []awsOrgsTypes.Tag{orgTag("Owner", "team-a")},
+ NextToken: awsSdk.String("same"),
+ }, nil
+ },
+ }
+
+ tags, err := fetchAccountTags(context.Background(), orgs, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]interface{}{"Owner": "team-a"}, tags)
+ assert.Equal(t, 2, orgs.listTagsCalls, "must stop once the token repeats")
+}
+
+func TestFetchIAMUserTags_PaginatesAcrossPages(t *testing.T) {
+ fake := &fakeIAMTags{userPages: []*iam.ListUserTagsOutput{
+ {Tags: []iamTypes.Tag{iamTag("Owner", "team-a")}, IsTruncated: true, Marker: awsSdk.String("m1")},
+ {Tags: []iamTypes.Tag{iamTag("Env", "prod")}},
+ }}
+
+ tags, err := fetchIAMUserTags(context.Background(), fake, "alice")
+ require.NoError(t, err)
+ assert.Equal(t, map[string]interface{}{"Owner": "team-a", "Env": "prod"}, tags)
+ assert.Equal(t, 2, fake.userCalls)
+ assert.Equal(t, iamTagsMaxItems, awsSdk.ToInt32(fake.lastUserInput.MaxItems))
+}
+
+func TestFetchIAMRoleTags_PaginatesAcrossPages(t *testing.T) {
+ fake := &fakeIAMTags{rolePages: []*iam.ListRoleTagsOutput{
+ {Tags: []iamTypes.Tag{iamTag("Owner", "team-b")}, IsTruncated: true, Marker: awsSdk.String("m1")},
+ {Tags: []iamTypes.Tag{iamTag("Env", "staging")}},
+ }}
+
+ tags, err := fetchIAMRoleTags(context.Background(), fake, "admin")
+ require.NoError(t, err)
+ assert.Equal(t, map[string]interface{}{"Owner": "team-b", "Env": "staging"}, tags)
+ assert.Equal(t, 2, fake.roleCalls)
+ assert.Equal(t, iamTagsMaxItems, awsSdk.ToInt32(fake.lastRoleInput.MaxItems))
+}
+
+// An untruncated IAM response must not cost a second call.
+func TestFetchIAMTags_SinglePageStopsImmediately(t *testing.T) {
+ user := &fakeIAMTags{userPages: []*iam.ListUserTagsOutput{
+ {Tags: []iamTypes.Tag{iamTag("Owner", "team-a")}},
+ }}
+ _, err := fetchIAMUserTags(context.Background(), user, "alice")
+ require.NoError(t, err)
+ assert.Equal(t, 1, user.userCalls)
+
+ role := &fakeIAMTags{rolePages: []*iam.ListRoleTagsOutput{
+ {Tags: []iamTypes.Tag{iamTag("Owner", "team-b")}},
+ }}
+ _, err = fetchIAMRoleTags(context.Background(), role, "admin")
+ require.NoError(t, err)
+ assert.Equal(t, 1, role.roleCalls)
+}
+
+// A missing IAM tag permission surfaces as PermissionDenied naming the action to grant,
+// rather than silently producing untagged users and roles.
+func TestFetchIAMTags_AccessDeniedIsFatal(t *testing.T) {
+ fake := &fakeIAMTags{err: &awsOrgsTypes.AccessDeniedException{Message: awsSdk.String("no perms")}}
+
+ _, err := fetchIAMUserTags(context.Background(), fake, "alice")
+ require.Error(t, err)
+ assert.Equal(t, codes.PermissionDenied, status.Code(err))
+ assert.Contains(t, err.Error(), "iam:ListUserTags")
+
+ _, err = fetchIAMRoleTags(context.Background(), fake, "admin")
+ require.Error(t, err)
+ assert.Equal(t, codes.PermissionDenied, status.Code(err))
+ assert.Contains(t, err.Error(), "iam:ListRoleTags")
+}
+
+func TestFetchIAMTags_ErrorPropagates(t *testing.T) {
+ fake := &fakeIAMTags{err: errors.New("boom")}
+
+ _, err := fetchIAMUserTags(context.Background(), fake, "alice")
+ require.Error(t, err)
+
+ _, err = fetchIAMRoleTags(context.Background(), fake, "admin")
+ require.Error(t, err)
+}