From d453885b14064f9c4c34cc8d6af9eb63351c94fd Mon Sep 17 00:00:00 2001 From: agustin-conductor Date: Wed, 16 Sep 2026 13:27:48 -0300 Subject: [PATCH 1/3] add sync resource tags feature --- README.md | 16 + baton_capabilities.json | 24 ++ config_schema.json | 6 + docs/connector.mdx | 20 +- pkg/config/conf.gen.go | 1 + pkg/config/config.go | 10 + pkg/connector/account.go | 15 + pkg/connector/account_iam.go | 9 + pkg/connector/connector.go | 14 +- pkg/connector/iam_policy.go | 8 +- pkg/connector/iam_user.go | 11 + pkg/connector/inline_policy.go | 10 +- pkg/connector/organization_test.go | 2 +- pkg/connector/partition.go | 10 +- ...permission_set_assignment_behavior_test.go | 16 +- pkg/connector/resource_types.go | 20 +- pkg/connector/role.go | 18 +- pkg/connector/ssoadmin_api.go | 5 + pkg/connector/sts_actions.go | 10 +- pkg/connector/tags.go | 174 ++++++++++ pkg/connector/tags_test.go | 311 ++++++++++++++++++ 21 files changed, 684 insertions(+), 26 deletions(-) create mode 100644 pkg/connector/tags.go create mode 100644 pkg/connector/tags_test.go 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..59b830b1 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", @@ -991,6 +1004,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..af98ecbd 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 { 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..a83b7305 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, } } 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..c45a0ddf --- /dev/null +++ b/pkg/connector/tags.go @@ -0,0 +1,174 @@ +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 every resource type publishes its AWS tags under. +// It is a nested map of tag key -> tag value, which c1 exposes to CEL as +// resource.profile.aws_tags["Owner"]. Rule authors must guard lookups with +// `"Owner" in resource.profile.aws_tags` — a missing key is an eval error, not null. +const tagsProfileField = "aws_tags" + +// iamTagsMaxItems is the page size requested from iam:ListUserTags / iam:ListRoleTags. +// It is set explicitly so the request does not depend on the API default changing, but it +// buys nothing: MaxItems accepts up to 1000 while the response schema caps Tags at 50 +// items ("Array Members: Maximum number of 50 items"), so no page size makes a resource +// with more than 50 tags arrive in one response. organizations:ListTagsForResource has no +// page-size parameter at all. Pagination is therefore not avoidable on either API. +const iamTagsMaxItems int32 = 100 + +// maxTagPages bounds every tag paginator below. The documented user-tag quota is 50 per +// resource and system tags are a small fixed set per resource, so five pages is already +// far past anything real — the bound exists so a misbehaving endpoint cannot stall a sync. +const maxTagPages = 5 + +// None of the List* calls this connector uses return tags: organizations.Account has no +// Tags field at all, and iam.ListUsers / iam.ListRoles return an empty Tags slice. Tags +// are only reachable through a separate per-resource call, so syncing them costs at least +// one extra API call per resource. That is why every fetch below is gated on the +// sync-resource-tags config field (default false) — at org scale the added Organizations +// traffic is a deliberate trade, not a free enrichment. organizations:ListTagsForResource +// is throttled at 10 req/s (burst 15) per account, so ~1000 accounts is ~100s of tag reads. +// +// These fetchers paginate, and must: the documented 50-tag quota counts only user-created +// tags. AWS states for Organizations that "system tags don't count against your tags per +// resource limit" (INVALID_SYSTEM_TAGS_PARAMETER, ListTagsForResource API reference), and +// aws:-prefixed system tags are reserved and invisible to that quota on IAM resources too. +// A resource can therefore hold more than 50 tags in total, while iam:ListUserTags and +// iam:ListRoleTags cap their response array at 50 items ("Array Members: Maximum number of +// 50 items"). Reading only the first response would silently drop tags, and IAM returns +// tags sorted by key, so the dropped ones are not a random sample. +// +// Pagination is driven by the AWS SDK's own paginators rather than a hand-rolled token +// loop. A tag cursor cannot be hoisted into the caller's page token: these are per-resource +// sub-fetches inside a List that already owns a single pagination.Bag for its own page, and +// a resource's profile has to be complete before the resource is emitted. +// +// Every failure here is fatal, including a missing tag permission. sync-resource-tags is +// opt-in: a tenant that turns it on has asked for tags, and the tags feed access-routing +// decisions in c1. Degrading to untagged resources would leave routing rules silently +// evaluating against absent tags, and nobody reads warnings on a sync that reported +// success. Failing loudly with a PermissionDenied naming the missing action is recoverable; +// a quietly wrong approval route is not. + +// iamTagsAPI is the subset of the IAM client used for per-resource 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) +} + +// errTagPageCap reports a tag listing that ran past maxTagPages. Truncated tags are as +// unusable as absent ones for routing, so this fails rather than returning a partial set. +func errTagPageCap(kind string, name string) error { + return fmt.Errorf( + "baton-aws: %s %q returned more than %d pages of tags; refusing to sync a truncated aws_tags set. "+ + "Disable sync-resource-tags if this resource's tags are not needed", + kind, name, maxTagPages, + ) +} + +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 pages := 0; paginator.HasMorePages(); pages++ { + if pages == maxTagPages { + return nil, errTagPageCap("account", accountID) + } + 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 pages := 0; paginator.HasMorePages(); pages++ { + if pages == maxTagPages { + return nil, errTagPageCap("iam user", userName) + } + 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 pages := 0; paginator.HasMorePages(); pages++ { + if pages == maxTagPages { + return nil, errTagPageCap("role", roleName) + } + 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..acd6e82c --- /dev/null +++ b/pkg/connector/tags_test.go @@ -0,0 +1,311 @@ +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, orgs *fakeOrgs) (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), 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), 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) +} + +// An endpoint that never stops handing back tokens must be bounded — and a truncated tag +// set is as unusable for routing as a missing one, so the bound is an error, not a partial +// result. +func TestFetchAccountTags_PageCapIsFatal(t *testing.T) { + page := 0 + orgs := &fakeOrgs{ + listTagsFn: func(_ *awsOrgs.ListTagsForResourceInput) (*awsOrgs.ListTagsForResourceOutput, error) { + page++ + return &awsOrgs.ListTagsForResourceOutput{ + Tags: []awsOrgsTypes.Tag{orgTag(fmt.Sprintf("k%d", page), "v")}, + NextToken: awsSdk.String(fmt.Sprintf("tok%d", page)), + }, nil + }, + } + + _, err := fetchAccountTags(context.Background(), orgs, testAccountID) + require.Error(t, err) + assert.Equal(t, maxTagPages, orgs.listTagsCalls, "must stop at the page cap") +} + +// 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) +} From 75beaa8eafa4ce852c71612f3e7c30dd7d21282a Mon Sep 17 00:00:00 2001 From: agustin-conductor Date: Wed, 16 Sep 2026 15:08:50 -0300 Subject: [PATCH 2/3] address pr comments and remove tags page limit logic --- docs/connector.mdx | 8 ++++++++ pkg/connector/iam_user.go | 2 +- pkg/connector/role.go | 2 +- pkg/connector/tags.go | 36 +++++------------------------------- pkg/connector/tags_test.go | 31 +++++++++++++++++-------------- 5 files changed, 32 insertions(+), 47 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index 59b830b1..f76ff1f8 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -651,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**. @@ -707,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`. @@ -810,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 diff --git a/pkg/connector/iam_user.go b/pkg/connector/iam_user.go index af98ecbd..cfbb10c7 100644 --- a/pkg/connector/iam_user.go +++ b/pkg/connector/iam_user.go @@ -250,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/role.go b/pkg/connector/role.go index a83b7305..c89407dd 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -296,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/tags.go b/pkg/connector/tags.go index c45a0ddf..959d66b4 100644 --- a/pkg/connector/tags.go +++ b/pkg/connector/tags.go @@ -11,10 +11,8 @@ import ( awsOrgsTypes "github.com/aws/aws-sdk-go-v2/service/organizations/types" ) -// tagsProfileField is the profile key every resource type publishes its AWS tags under. -// It is a nested map of tag key -> tag value, which c1 exposes to CEL as -// resource.profile.aws_tags["Owner"]. Rule authors must guard lookups with -// `"Owner" in resource.profile.aws_tags` — a missing key is an eval error, not null. +// tagsProfileField is the profile key carrying a resource's AWS tags, a nested map of tag +// key -> tag value. c1 exposes it to CEL as resource.profile.aws_tags["Owner"]. const tagsProfileField = "aws_tags" // iamTagsMaxItems is the page size requested from iam:ListUserTags / iam:ListRoleTags. @@ -25,11 +23,6 @@ const tagsProfileField = "aws_tags" // page-size parameter at all. Pagination is therefore not avoidable on either API. const iamTagsMaxItems int32 = 100 -// maxTagPages bounds every tag paginator below. The documented user-tag quota is 50 per -// resource and system tags are a small fixed set per resource, so five pages is already -// far past anything real — the bound exists so a misbehaving endpoint cannot stall a sync. -const maxTagPages = 5 - // None of the List* calls this connector uses return tags: organizations.Account has no // Tags field at all, and iam.ListUsers / iam.ListRoles return an empty Tags slice. Tags // are only reachable through a separate per-resource call, so syncing them costs at least @@ -66,16 +59,6 @@ type iamTagsAPI interface { ListRoleTags(ctx context.Context, params *iam.ListRoleTagsInput, optFns ...func(*iam.Options)) (*iam.ListRoleTagsOutput, error) } -// errTagPageCap reports a tag listing that ran past maxTagPages. Truncated tags are as -// unusable as absent ones for routing, so this fails rather than returning a partial set. -func errTagPageCap(kind string, name string) error { - return fmt.Errorf( - "baton-aws: %s %q returned more than %d pages of tags; refusing to sync a truncated aws_tags set. "+ - "Disable sync-resource-tags if this resource's tags are not needed", - kind, name, maxTagPages, - ) -} - func putIAMTags(rv map[string]interface{}, tags []iamTypes.Tag) { for _, tag := range tags { rv[awsSdk.ToString(tag.Key)] = awsSdk.ToString(tag.Value) @@ -100,10 +83,7 @@ func fetchAccountTags(ctx context.Context, orgClient orgsAPI, accountID string) ) rv := make(map[string]interface{}) - for pages := 0; paginator.HasMorePages(); pages++ { - if pages == maxTagPages { - return nil, errTagPageCap("account", accountID) - } + for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { return nil, wrapAWSError(fmt.Errorf( @@ -129,10 +109,7 @@ func fetchIAMUserTags(ctx context.Context, iamClient iamTagsAPI, userName string ) rv := make(map[string]interface{}) - for pages := 0; paginator.HasMorePages(); pages++ { - if pages == maxTagPages { - return nil, errTagPageCap("iam user", userName) - } + for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { return nil, wrapAWSError(fmt.Errorf( @@ -158,10 +135,7 @@ func fetchIAMRoleTags(ctx context.Context, iamClient iamTagsAPI, roleName string ) rv := make(map[string]interface{}) - for pages := 0; paginator.HasMorePages(); pages++ { - if pages == maxTagPages { - return nil, errTagPageCap("role", roleName) - } + for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { return nil, wrapAWSError(fmt.Errorf( diff --git a/pkg/connector/tags_test.go b/pkg/connector/tags_test.go index acd6e82c..121338a9 100644 --- a/pkg/connector/tags_test.go +++ b/pkg/connector/tags_test.go @@ -78,7 +78,7 @@ func oneActiveAccount(_ *awsOrgs.ListAccountsInput) (*awsOrgs.ListAccountsOutput }}}, nil } -func accountTagsFromProfile(t *testing.T, acct *accountResourceType, orgs *fakeOrgs) (map[string]interface{}, bool) { +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) @@ -110,7 +110,7 @@ func TestAccountList_TagsOnProfileWhenEnabled(t *testing.T) { }, } - tags, present := accountTagsFromProfile(t, newOrgAccountWithTags(orgs), orgs) + 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", @@ -130,7 +130,7 @@ func TestAccountList_NoTagCallWhenDisabled(t *testing.T) { }, } - _, present := accountTagsFromProfile(t, newOrgAccount(orgs), orgs) + _, 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) } @@ -204,24 +204,27 @@ func TestFetchAccountTags_SinglePageStopsImmediately(t *testing.T) { assert.Equal(t, 1, orgs.listTagsCalls) } -// An endpoint that never stops handing back tokens must be bounded — and a truncated tag -// set is as unusable for routing as a missing one, so the bound is an error, not a partial -// result. -func TestFetchAccountTags_PageCapIsFatal(t *testing.T) { +// 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++ - return &awsOrgs.ListTagsForResourceOutput{ - Tags: []awsOrgsTypes.Tag{orgTag(fmt.Sprintf("k%d", page), "v")}, - NextToken: awsSdk.String(fmt.Sprintf("tok%d", page)), - }, nil + 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 }, } - _, err := fetchAccountTags(context.Background(), orgs, testAccountID) - require.Error(t, err) - assert.Equal(t, maxTagPages, orgs.listTagsCalls, "must stop at the page cap") + 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. From 953892e7ecbeb6f7ffa47f858efd858802f3db15 Mon Sep 17 00:00:00 2001 From: agustin-conductor Date: Thu, 17 Sep 2026 14:34:18 -0300 Subject: [PATCH 3/3] slim code comments --- pkg/connector/tags.go | 47 +++++++++---------------------------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/pkg/connector/tags.go b/pkg/connector/tags.go index 959d66b4..0940f9eb 100644 --- a/pkg/connector/tags.go +++ b/pkg/connector/tags.go @@ -12,48 +12,21 @@ import ( ) // tagsProfileField is the profile key carrying a resource's AWS tags, a nested map of tag -// key -> tag value. c1 exposes it to CEL as resource.profile.aws_tags["Owner"]. +// key -> tag value. const tagsProfileField = "aws_tags" -// iamTagsMaxItems is the page size requested from iam:ListUserTags / iam:ListRoleTags. -// It is set explicitly so the request does not depend on the API default changing, but it -// buys nothing: MaxItems accepts up to 1000 while the response schema caps Tags at 50 -// items ("Array Members: Maximum number of 50 items"), so no page size makes a resource -// with more than 50 tags arrive in one response. organizations:ListTagsForResource has no -// page-size parameter at all. Pagination is therefore not avoidable on either API. +// iamTagsMaxItems is the API default, set explicitly so the request does not depend on it. const iamTagsMaxItems int32 = 100 -// None of the List* calls this connector uses return tags: organizations.Account has no -// Tags field at all, and iam.ListUsers / iam.ListRoles return an empty Tags slice. Tags -// are only reachable through a separate per-resource call, so syncing them costs at least -// one extra API call per resource. That is why every fetch below is gated on the -// sync-resource-tags config field (default false) — at org scale the added Organizations -// traffic is a deliberate trade, not a free enrichment. organizations:ListTagsForResource -// is throttled at 10 req/s (burst 15) per account, so ~1000 accounts is ~100s of tag reads. -// -// These fetchers paginate, and must: the documented 50-tag quota counts only user-created -// tags. AWS states for Organizations that "system tags don't count against your tags per -// resource limit" (INVALID_SYSTEM_TAGS_PARAMETER, ListTagsForResource API reference), and -// aws:-prefixed system tags are reserved and invisible to that quota on IAM resources too. -// A resource can therefore hold more than 50 tags in total, while iam:ListUserTags and -// iam:ListRoleTags cap their response array at 50 items ("Array Members: Maximum number of -// 50 items"). Reading only the first response would silently drop tags, and IAM returns -// tags sorted by key, so the dropped ones are not a random sample. -// -// Pagination is driven by the AWS SDK's own paginators rather than a hand-rolled token -// loop. A tag cursor cannot be hoisted into the caller's page token: these are per-resource -// sub-fetches inside a List that already owns a single pagination.Bag for its own page, and -// a resource's profile has to be complete before the resource is emitted. -// -// Every failure here is fatal, including a missing tag permission. sync-resource-tags is -// opt-in: a tenant that turns it on has asked for tags, and the tags feed access-routing -// decisions in c1. Degrading to untagged resources would leave routing rules silently -// evaluating against absent tags, and nobody reads warnings on a sync that reported -// success. Failing loudly with a PermissionDenied naming the missing action is recoverable; -// a quietly wrong approval route is not. +// 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 per-resource tag reads. It satisfies -// the SDK's ListUserTagsAPIClient and ListRoleTagsAPIClient paginator interfaces. +// 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)