fix(connector): refresh cross-account STS credentials instead of caching them forever - #144
Conversation
Connector PR Review: fix(connector): refresh cross-account STS credentials instead of caching them foreverBlocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0 Review SummaryFull PR diff scanned for security and correctness. The change replaces the one-shot Security IssuesNone found. Correctness IssuesNone found. Suggestions
Prompt for AI agents |
…ing them forever AWSClientFactory built per-child-account IAM clients from a single sts:AssumeRole call, wrapped the result in credentials.NewStaticCredentialsProvider, and cached the client in iamClientMap for the lifetime of the process with no TTL or expiry check. Assumed-role sessions last at most 1 hour (role chaining caps there, and an AssumeRole call with no DurationSeconds defaults there anyway), while clients are created eagerly at account-listing time and used later during the child IAM crawl. Any sync where more than an hour elapsed between creation and use died with 403 ExpiredToken, and the cached client then failed every subsequent sync until the process restarted. getConfig now builds aws.NewCredentialsCache(stscreds.NewAssumeRoleProvider(...)), matching the pattern already used for the connector's own credentials in (*AWS).getCallingConfig. Caching the client is safe once its provider re-assumes on expiry. Three details worth noting: - stscreds providers are lazy, so constructing one issues no API call. getConfig retrieves once up front because accountIAMResourceType.parseAssumeRole relies on an assume-role failure to skip inaccessible accounts; without the probe those accounts would be registered and fail later mid-crawl instead. - LoadDefaultConfig is retained (via the new GetAwsConfigOptionsForCredentials, replacing GetAwsConfigOptionsForAssumeRole) so child-account clients keep resolving ambient AWS settings. Hand-building an aws.Config drops RetryMode, RetryMaxAttempts, AppID, AccountIDEndpointMode and ConfigSources — the last of which is where service clients read FIPS/dualstack and endpoint settings, so losing it would silently disable AWS_USE_FIPS_ENDPOINT for cross-account clients only. - Once credentials refresh lazily, an STS AccessDenied on re-assume surfaces at the IAM call site through the %w-wrapped credentials chain, where the fail-soft guards would mistake it for a resource-level denial and skip. That turns a credentials outage into a sync reporting success with grants silently missing, which reads downstream as revoked access. isCredentialsRetrievalError excludes those errors so credential problems stay loud. Also fixes isAccessDeniedError, which only matched "AccessDenied". SSO Admin returns a typed AccessDeniedException whose ErrorCode() is "AccessDeniedException", so the fail-soft skips at permission_set.go and inline_policy.go were dead code and failed the sync instead of degrading. Verified against a live tenant: before the change the sync exits 1 on ssoadmin.GetInlinePolicyForPermissionSet; after, it completes with 8 skips logged as warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to 7c7c621, addressing three findings from review of that change. **Session duration.** stscreds does not inherit the STS API's 1-hour default. AssumeRoleProvider.Retrieve back-fills an unset Duration with stscreds.DefaultDuration (15 minutes) and then always sends DurationSeconds, so switching to a refreshing provider silently quartered cross-account session length and quadrupled AssumeRole volume against every child account — on exactly the large-Organization deployment CXP-801 was filed from. The replaced code sent no DurationSeconds and so received 3600s. aro.Duration now says 3600s explicitly, restoring parity. One hour is also the ceiling: role chaining (which both assume-role modes use) rejects only DurationSeconds above 3600, and where chaining does not apply the target role's MaxSessionDuration governs, whose minimum permitted value is itself 3600 — so it is accepted in every auth mode. **Refresh window.** The cache was built with the SDK default ExpiryWindow of 0, which serves credentials until the exact expiry instant. A request issued in the final round-trip is signed with credentials that expire in flight and comes back 403 ExpiredToken, which is absent from the SDK's retryable error codes (only RequestTimeout*, the throttle codes, and three clock-skew codes are retried), so it fails the sync task rather than being retried. A 5-minute window removes the race; at 1-hour sessions the cost is a refresh every ~55 minutes instead of 60. This is not a regression from 7c7c621 — the cross-account path only acquires expiry boundaries by refreshing at all, where before it failed permanently — but it is cheap to close while the path is being fixed. **Test hermeticity.** getConfig goes through awsConfig.LoadDefaultConfig, which reads the ambient environment and shared config files, so the tests added in 7c7c621 inherited whatever AWS configuration the host had. Six of seven failed with "failed to get shared config profile" on any machine with AWS_PROFILE set. isolateAWSEnv neutralizes profile and credential-file resolution and is wired into newTestFactory, so every getConfig test is isolated without per-test setup. Both new guards are mutation-tested: dropping aro.Duration fails TestGetConfigRequestsOneHourSessions with 900 != 3600, and dropping the ExpiryWindow option fails TestGetConfigRefreshesBeforeExpiry. The duration assertion deliberately checks the value on the wire rather than the constant, since asserting the constant would still pass if aro.Duration were removed. Verified against a live 2-account Organization: sync completes with exit 0, no ExpiredToken, no DurationSeconds or MaxSessionDuration rejection, and the same one denied account and eight SSO Admin fail-soft skips as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
31f2ee9 to
3a8c591
Compare
Fixes CXP-801. Go-live blocker for The Trade Desk (Pylon #12224).
Problem
AWSClientFactorybuilt per-child-account IAM clients from a singlests:AssumeRolecall, wrapped the result incredentials.NewStaticCredentialsProvider(never refreshes), and cached the client iniamClientMapfor the lifetime of the process — no TTL, no expiry check, no invalidation.Assumed-role sessions last at most 1 hour: role chaining caps there, and an
AssumeRolecall with noDurationSecondsdefaults there anyway, so the 1-hour ceiling applied in every deployment mode. Clients are created eagerly at account-listing time (account_iam.go) but used later during the child IAM crawl, so:403 ExpiredToken, andFix
getConfignow buildsaws.NewCredentialsCache(stscreds.NewAssumeRoleProvider(...)), matching the pattern already used for the connector's own credentials in(*AWS).getCallingConfig. Caching the client is safe once its provider re-assumes on expiry.Three details that aren't obvious from the diff:
The eager
Retrieveis load-bearing, not a sanity check.stscredsproviders are lazy — constructing one issues no API call.accountIAMResourceType.parseAssumeRolerelies on an assume-role failure to skip inaccessible accounts, so without the probe those accounts would be silently registered and fail later mid-crawl. This isn't hypothetical: the verification run below hit an account that exercises it.LoadDefaultConfigis retained deliberately. I initially hand-built anaws.Configand measured what that drops:RetryMode,RetryMaxAttempts,AppID,RequestMinCompressSizeBytes,DisableRequestCompression,AccountIDEndpointMode, andConfigSources. That last one is where service clients read FIPS/dualstack and endpoint settings at construction, so an empty one would silently disableAWS_USE_FIPS_ENDPOINTfor cross-account clients only.GetAwsConfigOptionsForAssumeRoleis replaced byGetAwsConfigOptionsForCredentials, which takes aCredentialsProviderinstead of an*sts.AssumeRoleOutput.config.wrapWithCredentialsCachereturns an existing*aws.CredentialsCacheuntouched rather than re-wrapping, so refresh behavior survives the round trip — pinned by a test.Refreshing credentials introduce a new silent-data-loss path, closed here. Once credentials re-assume lazily, an STS
AccessDeniedon re-assume surfaces at the IAM call site through the%w-wrapped chain:errors.Astraverses all of it, so the existing fail-soft guards would have mistaken a credentials outage for a resource-level denial and skipped — a sync reporting success with grants silently missing, which reads downstream as revoked access. Strictly worse than the loudExpiredTokenwe're fixing.isCredentialsRetrievalErrorexcludes those. Note it can't use a plainerrors.As: that matches the outermostOperationError(IAM) and never sees the STS one beneath, so it steps past each non-STS match and resumes underneath.Secondary fix (same error family, per the ticket)
isAccessDeniedErroronly matched"AccessDenied". IAM models noAccessDenied*error type and returns an unmodeledGenericAPIErrorwith that code, but SSO Admin returns a typedAccessDeniedExceptionwhoseErrorCode()is"AccessDeniedException"— so the fail-soft skips atpermission_set.go:136andinline_policy.go:162were dead code and failed the sync instead of degrading. Both spellings now match. (sts_actions.go:188already had this shape.)Verification
Live tenant, 2-account org,
--global-aws-cross-account-iam-enabled.Credential refresh — with a temporary always-expired
ExpiryWindowplus per-callAssumeRolelogging, one child account logged 42 successiveAssumeRolecalls (numbered 1→42) through the single cached IAM client. On the old code that number is exactly 1, forever.Eager probe — account
852922979753returned403 AccessDeniedonAssumeRole; the probe caught it and the account was skipped viaSkipping account in Organizations, preserving pre-fix semantics.A/B on the
AccessDeniedhalf — reverting only the code-matching and re-running the same sync:exit=1—cancelling context due to error in actiononssoadmin.GetInlinePolicyForPermissionSetexit=0— 8 fail-soft skips logged as warnings,Sync complete.The tenant's
test-userlackssso:ListManagedPoliciesInPermissionSetandsso:GetInlinePolicyForPermissionSet, so it reproduces the dead-guard bug directly.Final clean run on the exact committed code (no instrumentation):
exit=0,Sync complete., zeroExpiredToken.go build,go vet, fullgo test, and-raceall pass.golangci-lint: 12 issues vs. 14 onmain— zero new, two pre-existinggoconstfindings cleared.Tests
11 tests in
pkg/connector/aws_client_factory_test.go, using an injectablestsClientFnseam. Each guard was mutation-tested to confirm it actually bites:TestCredentialsFailureIsNotTreatedAsResourceDenialfailsAccessDenied-only → the SSO Admin subtest failsaws.Config→TestGetConfigResolvesAmbientAWSSettingsfails onAWS_RETRY_MODEAcceptance criteria
ExpiredToken— refresh demonstrated via the 42-call sequence rather than a >1h waitgo run(one sync per process); sameCredentialsCachemechanism as aboveisAccessDeniedErrormatches SSO AdminAccessDeniedException; skip paths covered by unit testsReviewer notes
GetAwsConfigOptionsForCredentialsreplaces an exported symbol (GetAwsConfigOptionsForAssumeRole). Only caller wasaws_client_factory.go, but worth confirming nothing outside this repo referenced it.isCredentialsRetrievalErroris covered by unit tests only. Hitting it live needs a role that assumes successfully at probe time and then loses permission mid-sync — hard to stage deliberately.AssumeRoleper denied account);f.mutexis a single global lock held across the STS call;orgClientMapis declared and never read — note the ticket's criterion 1 says "IAM/org clients" but no cross-account org path exists. Plus the ticket's own README least-privilege follow-up.🤖 Generated with Claude Code