Skip to content

fix(connector): refresh cross-account STS credentials instead of caching them forever - #144

Merged
agustin-conductor merged 2 commits into
mainfrom
bugfix/cross-account-client-cache-expired-sts
Jul 31, 2026
Merged

fix(connector): refresh cross-account STS credentials instead of caching them forever#144
agustin-conductor merged 2 commits into
mainfrom
bugfix/cross-account-client-cache-expired-sts

Conversation

@agustin-conductor

Copy link
Copy Markdown
Contributor

Fixes CXP-801. Go-live blocker for The Trade Desk (Pylon #12224).

Problem

AWSClientFactory built per-child-account IAM clients from a single sts:AssumeRole call, wrapped the result in credentials.NewStaticCredentialsProvider (never refreshes), and cached the client in iamClientMap for 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 AssumeRole call with no DurationSeconds defaults 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:

  • any sync where >1h elapsed between creation and use died mid-sync with 403 ExpiredToken, and
  • the cached client then failed every subsequent sync until the process restarted.

Fix

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 that aren't obvious from the diff:

The eager Retrieve is load-bearing, not a sanity check. stscreds providers are lazy — constructing one issues no API call. accountIAMResourceType.parseAssumeRole relies 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.

LoadDefaultConfig is retained deliberately. I initially hand-built an aws.Config and measured what that drops: RetryMode, RetryMaxAttempts, AppID, RequestMinCompressSizeBytes, DisableRequestCompression, AccountIDEndpointMode, and ConfigSources. That last one is where service clients read FIPS/dualstack and endpoint settings at construction, so an empty one would silently disable AWS_USE_FIPS_ENDPOINT for cross-account clients only. GetAwsConfigOptionsForAssumeRole is replaced by GetAwsConfigOptionsForCredentials, which takes a CredentialsProvider instead of an *sts.AssumeRoleOutput. config.wrapWithCredentialsCache returns an existing *aws.CredentialsCache untouched 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 AccessDenied on re-assume surfaces at the IAM call site through the %w-wrapped chain:

iam.<Op> err → smithy.OperationError{ServiceID:"IAM"} → "get identity: %w"
  → "get credentials: %w" → CredentialsCache → AssumeRoleProvider
  → smithy.OperationError{ServiceID:"STS"} → GenericAPIError{Code:"AccessDenied"}

errors.As traverses 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 loud ExpiredToken we're fixing. isCredentialsRetrievalError excludes those. Note it can't use a plain errors.As: that matches the outermost OperationError (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)

isAccessDeniedError only matched "AccessDenied". IAM models no AccessDenied* error type and returns an unmodeled GenericAPIError with that code, but SSO Admin returns a typed AccessDeniedException whose ErrorCode() is "AccessDeniedException" — so the fail-soft skips at permission_set.go:136 and inline_policy.go:162 were dead code and failed the sync instead of degrading. Both spellings now match. (sts_actions.go:188 already had this shape.)

Verification

Live tenant, 2-account org, --global-aws-cross-account-iam-enabled.

Credential refresh — with a temporary always-expired ExpiryWindow plus per-call AssumeRole logging, one child account logged 42 successive AssumeRole calls (numbered 1→42) through the single cached IAM client. On the old code that number is exactly 1, forever.

Eager probe — account 852922979753 returned 403 AccessDenied on AssumeRole; the probe caught it and the account was skipped via Skipping account in Organizations, preserving pre-fix semantics.

A/B on the AccessDenied half — reverting only the code-matching and re-running the same sync:

result
pre-fix matching exit=1cancelling context due to error in action on ssoadmin.GetInlinePolicyForPermissionSet
this PR exit=0 — 8 fail-soft skips logged as warnings, Sync complete.

The tenant's test-user lacks sso:ListManagedPoliciesInPermissionSet and sso:GetInlinePolicyForPermissionSet, so it reproduces the dead-guard bug directly.

Final clean run on the exact committed code (no instrumentation): exit=0, Sync complete., zero ExpiredToken.

go build, go vet, full go test, and -race all pass. golangci-lint: 12 issues vs. 14 on main — zero new, two pre-existing goconst findings cleared.

Tests

11 tests in pkg/connector/aws_client_factory_test.go, using an injectable stsClientFn seam. Each guard was mutation-tested to confirm it actually bites:

  • drop the credentials guard → TestCredentialsFailureIsNotTreatedAsResourceDenial fails
  • revert to AccessDenied-only → the SSO Admin subtest fails
  • hand-build the aws.ConfigTestGetConfigResolvesAmbientAWSSettings fails on AWS_RETRY_MODE

Acceptance criteria

  • Cross-account IAM clients use auto-refreshing assumed-role credentials
  • A sync against child accounts completes without ExpiredToken — refresh demonstrated via the 42-call sequence rather than a >1h wait
  • [~] Subsequent syncs in a long-lived process succeed without restart — not directly observable via go run (one sync per process); same CredentialsCache mechanism as above
  • isAccessDeniedError matches SSO Admin AccessDeniedException; skip paths covered by unit tests

Reviewer notes

  • GetAwsConfigOptionsForCredentials replaces an exported symbol (GetAwsConfigOptionsForAssumeRole). Only caller was aws_client_factory.go, but worth confirming nothing outside this repo referenced it.
  • isCredentialsRetrievalError is 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.
  • Deliberately out of scope, all pre-existing: no negative caching for unassumable accounts (13 call sites re-attempt AssumeRole per denied account); f.mutex is a single global lock held across the STS call; orgClientMap is 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

@agustin-conductor
agustin-conductor requested a review from a team July 30, 2026 15:52
@linear-code

linear-code Bot commented Jul 30, 2026

Copy link
Copy Markdown

CXP-801

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: fix(connector): refresh cross-account STS credentials instead of caching them forever

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 46cfb82d9408.
Review mode: full
View review run

Review Summary

Full PR diff scanned for security and correctness. The change replaces the one-shot AssumeRole + StaticCredentialsProvider with aws.NewCredentialsCache(stscreds.NewAssumeRoleProvider(...)) so cross-account clients re-assume on expiry, retains LoadDefaultConfig to preserve ambient AWS settings, and adds isCredentialsRetrievalError so a mid-sync STS AccessDenied on re-assume is no longer swallowed by the fail-soft skips. The refresh/probe/credentials-guard logic is correct and thoroughly covered by the new tests; all new imports resolve to already-required modules (no go.mod/go.sum change). No security or correctness issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/connector.go:625 — the exported GetAwsConfigOptionsForAssumeRole is removed and replaced by GetAwsConfigOptionsForCredentials (different signature). Verified the only in-repo caller is aws_client_factory.go; this is an exported-symbol break for any external importer. Already acknowledged in the PR's reviewer notes, so no action needed unless an out-of-repo consumer references it.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/connector.go`:
- Around line 625: The exported helper `GetAwsConfigOptionsForAssumeRole` was removed and
  replaced by `GetAwsConfigOptionsForCredentials`, which takes an `awsSdk.CredentialsProvider`
  instead of an `*sts.AssumeRoleOutput`. The only in-repo caller is
  pkg/connector/aws_client_factory.go. If any code outside this repository imported the old
  exported symbol, it will fail to compile. Confirm there are no external consumers; if there
  are, keep a thin deprecated wrapper or coordinate the rename with them.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@btipling btipling removed their assignment Jul 31, 2026
agustin-conductor and others added 2 commits July 31, 2026 15:16
…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>
@agustin-conductor
agustin-conductor force-pushed the bugfix/cross-account-client-cache-expired-sts branch from 31f2ee9 to 3a8c591 Compare July 31, 2026 18:34

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@agustin-conductor
agustin-conductor merged commit 05a7c6e into main Jul 31, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants