🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: generate STS external ID for Bedrock role assumption - #26869

Merged
evgeniy-scherbina merged 6 commits into
mainfrom
feat/bedrock-external-id
Jul 1, 2026
Merged

feat: generate STS external ID for Bedrock role assumption#26869
evgeniy-scherbina merged 6 commits into
mainfrom
feat/bedrock-external-id

Conversation

@evgeniy-scherbina

@evgeniy-scherbina evgeniy-scherbina commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Implements: https://linear.app/codercom/issue/AIGOV-495/add-externalid-to-prevent-confused-deputy-problem

When a Bedrock provider assumes an IAM role via STS, the gateway now generates a unique external ID for it and sends that value on every AssumeRole call. The external ID guards against the confused deputy problem on cross-account role assumption. Per AWS's recommendation, the gateway generates and owns the value rather than accepting one from the operator; that ownership is what makes it effective, since a party who knows another's external ID can't induce the gateway to send it.

The external ID is server-owned and read-only over the API. It is generated once, when a provider first has a role_arn, and is stable thereafter. Clients cannot set it: create rejects any supplied external_id, and update rejects a value that differs from the stored one. An update may echo the stored value back unchanged, so the normal read-modify-write flow (GET the provider, change a field, PATCH the full settings object) keeps working. The value is not a secret and is returned on GET so operators can copy it into the target role's trust policy as an sts:ExternalId condition.

It is persisted in the existing JSON settings blob, so there is no migration or audit-table change.

@github-actions

Copy link
Copy Markdown

Docs preview

📖 View docs preview for docs/ai-coder/ai-gateway/providers.md

@evgeniy-scherbina evgeniy-scherbina changed the title feat(aibridge): generate STS external ID for Bedrock role assumptio feat: generate STS external ID for Bedrock role assumption Jun 30, 2026
@evgeniy-scherbina
evgeniy-scherbina force-pushed the feat/bedrock-external-id branch 4 times, most recently from aed9153 to 2dffd27 Compare June 30, 2026 19:00
@evgeniy-scherbina
evgeniy-scherbina force-pushed the feat/bedrock-external-id branch from 11e33ab to 2573afa Compare June 30, 2026 23:38
@evgeniy-scherbina
evgeniy-scherbina marked this pull request as ready for review June 30, 2026 23:38
@linear-code

linear-code Bot commented Jun 30, 2026

Copy link
Copy Markdown

AIGOV-495

@coder-tasks

coder-tasks Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

Updates Needed

  • docs/ai-coder/ai-gateway/providers.md - The Assuming an IAM role section (lines 141-164) needs to document the new server-generated external ID:
    • When a Role ARN is set, the server auto-generates an STS external ID and returns it in the API response.
    • Operators should add this external ID to the target role's trust policy as an sts:ExternalId condition to restrict who can assume the role.
    • Step 2 ("Configure the role's trust policy") should include an example sts:ExternalId condition block.
    • The external ID is read-only; it cannot be set or changed by clients.

Automated review via Coder Agents

@evgeniy-scherbina

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-06-30 23:43 UTC by @evgeniy-scherbina
Spend: $36.74 / $100.00

Review history
  • R1 (2026-07-01): 18 reviewers, 1 Nit, 3 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 89b0a66..2573afa

Last posted: Round 1, 4 findings (3 P3, 1 Nit), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open coderd/ai_providers_internal_test.go:39 Length assertion on crypto/rand.Text() hardcodes unstable implementation detail R1 Bisky P3, Hisoka P3, Knov Nit, Komugi P3, Gon P3, Razor Note Yes
CRF-2 P3 Open codersdk/aiproviders_bedrock.go:38 Go doc comment shorter than generated TS; next make gen will strip operator-facing guidance R1 Razor P2, Leorio P3, Zoro P3, Ryosuke Note Yes
CRF-3 P3 Open coderd/ai_providers.go:800 No test for role-removal-then-readd ExternalID stability R1 Ryosuke Yes
CRF-4 Nit Open coderd/ai_providers.go:805 Func doc and merge comment restate function names; could be tighter R1 Gon Yes
CRF-5 P4 Dropped by orchestrator (pre-existing update pattern, not actionable in this PR) coderd/ai_providers.go:341 Concurrent PATCHes can race on ExternalID generation R1 Hisoka No
CRF-6 P4 Dropped by orchestrator (mitigated by first PATCH triggering generation) coderd/ai_providers.go:828 No backfill for existing providers with RoleARN R1 Meruem No
CRF-7 P4 Dropped by orchestrator (scope creep, pre-existing duplication pattern) aibridge/provider/bedrock_internal_test.go:227 STS mock server boilerplate duplicated R1 Robin No

Round log

Round 1

Panel. 18 reviewers. 0 P0-P1, 3 P3, 1 Nit, 3 P4 dropped. Reviewed against 89b0a66..2573afa.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review 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.

Clean, well-scoped feature. The confused-deputy mitigation is implemented at the right level, with defense in depth across validation, merge, and generation. The 4:1 test-to-code ratio is solid, and the integration tests cover the full CRUD lifecycle. Three P3 findings and a Nit below; nothing blocking.

Severity count: 3 P3, 1 Nit.

"I tried to build a case against this and couldn't." (Pariston)

Two lower-severity observations worth noting in the body rather than inline:

  • Existing providers with a RoleARN created before this deploy will have an empty external_id until a PATCH (any PATCH, even to display_name) triggers generation. The first touch backfills it, but operators who upgrade and immediately GET the provider will see nothing. If this matters, a startup backfill analogous to BackfillBedrockProviderType would close the gap. (Meruem)

  • Concurrent PATCHes that both add a RoleARN can each generate a different ExternalID; last commit wins silently. This follows the pre-existing update pattern (no FOR UPDATE lock). The window is narrow but ExternalID is uniquely sensitive because it must be manually synced to an external trust policy. Not actionable in this PR. (Hisoka)

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/ai_providers_internal_test.go Outdated
ensureBedrockExternalID(&s)
require.NotEmpty(t, s.Bedrock.ExternalID)
// crypto/rand.Text returns a 26-character base32 string.
require.Len(t, s.Bedrock.ExternalID, 26)

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.

P3 [CRF-1] require.Len(t, s.Bedrock.ExternalID, 26) hardcodes the current output length of crypto/rand.Text(). The Go docs explicitly state: "A future version may return longer texts as needed to maintain those properties." A Go upgrade that increases the length breaks this test with no bug in the code. The require.NotEmpty on the preceding line already verifies generation works.

Replace with require.NotEmpty or, if a sanity floor is desired, require.GreaterOrEqual(t, len(s.Bedrock.ExternalID), 26). (Bisky P3, Hisoka P3, Komugi P3, Knov Nit, Gon P3, Razor Note)

🤖

@evgeniy-scherbina evgeniy-scherbina Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: f7178f5

Comment on lines +38 to +42
// ExternalID is the STS external ID sent on the AssumeRole call when
// RoleARN is set. The server generates and owns it: create and update
// reject any client-supplied value that differs from the stored one (an
// update may echo the stored value back).
ExternalID string `json:"external_id,omitempty"`

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.

P3 [CRF-2] The generated TypeScript at site/src/api/typesGenerated.ts:339-346 includes operator-facing guidance absent from this Go source:

Operators add it to the target role's trust policy as an sts:ExternalId condition to constrain the assumption to this deployment. It is not a secret and is returned on GET.

Since the apitypings generator produces comments from Go doc comments, the next make gen will strip this text from the TypeScript file. The missing content matters: an operator reading this field in the SDK needs to know what to do with it (copy it into the trust policy) and whether to treat it as a secret (no).

Fix: add the extra sentences to this Go doc comment and re-run make gen. (Razor P2, Leorio P3, Zoro P3, Ryosuke Note)

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed

Comment thread coderd/ai_providers.go
// The external ID is server-owned and stable: carry the stored value
// forward so a patch can't change it. A patch that sets a different
// value is rejected upstream.
merged.ExternalID = existing.Bedrock.ExternalID

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.

P3 [CRF-3] The ExternalID survives role removal (carried forward unconditionally here) and is reused when a different role is later added. The PR description says "stable thereafter," so this is by design. But there is no test for the remove-then-readd sequence: create with role A → PATCH to clear RoleARN → PATCH to add role B → assert same ExternalID.

Without that test, a future refactor could accidentally clear the ExternalID on role removal, silently breaking trust policies. (Ryosuke)

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: d2d0bce

Comment thread coderd/ai_providers.go
Comment on lines +805 to +808
// validateBedrockExternalIDUnchanged rejects a patch that sets a Bedrock
// external ID different from the stored one. A patch may echo the stored
// value (read-modify-write resends it) but not change it; the value is
// server-owned.

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.

Nit [CRF-4] The func doc restates the function name. The one trap worth preserving is the echo-vs-change asymmetry (read-modify-write sends the stored value back and must be accepted). Consider:

// validateBedrockExternalIDUnchanged allows echoing the stored value
// (read-modify-write) but rejects a changed one.

Similarly, the merge comment at line 797-799 could compress to // Server-owned: carry the stored value forward. (Gon)

🤖

@dannykopping dannykopping 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.

Couple minor nits, feel free to ignore.

Comment thread coderd/ai_providers_internal_test.go
Comment thread coderd/ai_providers_test.go Outdated
require.Equal(t, original, updated.Settings.Bedrock.ExternalID, "external ID must be stable across PATCH")
})

t.Run("StableAcrossRoleRemovalAndReadd", func(t *testing.T) {

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.

Suggested change
t.Run("StableAcrossRoleRemovalAndReadd", func(t *testing.T) {
t.Run("StableAcrossRoleRemovalAndRead", func(t *testing.T) {

@evgeniy-scherbina evgeniy-scherbina Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually it's supposed to mean ReAdd, but I renamed to Reassignment for better readability.

5deff01

@evgeniy-scherbina
evgeniy-scherbina enabled auto-merge (squash) July 1, 2026 20:13
@evgeniy-scherbina
evgeniy-scherbina enabled auto-merge (squash) July 1, 2026 20:31
@evgeniy-scherbina
evgeniy-scherbina merged commit db7f443 into main Jul 1, 2026
48 of 51 checks passed
@evgeniy-scherbina
evgeniy-scherbina deleted the feat/bedrock-external-id branch July 1, 2026 20:44
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants