mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
feat(bedrock): add OIDC role assumption as a fourth auth mode (#10936)
* feat(bedrock): add OIDC role assumption as a fourth auth mode Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): gate the OIDC cache correctly and assume the role once per job Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * refactor(bedrock): check the OIDC region before minting a token Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): keep OIDC session names collision-resistant, gate the copy on EE Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): check the OIDC region before reusing cached credentials Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * fix(bedrock): clear assumed-role sessions when AI settings change invalidate_ai_request_cache_for_workspace cleared AI_REQUEST_CACHE only, so a workspace's AI settings edit reset one cache and left the assumed-role sessions keyed on the old config in place until STS expired them. Also name the region requirement in the credentials-check hint, so following it does not land on the OIDC path's region guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVb91fZp3dqM14KRPzoEn * chore: update ee-repo-ref to de73db2bacfdc3eaa2e63b1827178bc198d54e5c This commit updates the EE repository reference after PR #770 was merged in windmill-ee-private. Previous ee-repo-ref: c43dab1e69b1cb3f685e6df07bff634dc2a0b734 New ee-repo-ref: de73db2bacfdc3eaa2e63b1827178bc198d54e5c Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
windmill-internal-app[bot]
parent
94e4fb1c84
commit
e14da5c6bc
@@ -1 +1 @@
|
||||
c099af43bad922fd57ed0449ab717ffa047b5546
|
||||
de73db2bacfdc3eaa2e63b1827178bc198d54e5c
|
||||
|
||||
@@ -6,7 +6,7 @@ edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
|
||||
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config", "windmill-common/aws_auth"]
|
||||
mcp = ["dep:windmill-mcp"]
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - BedrockClient: SDK wrapper with bearer token and IAM credentials auth
|
||||
//! - OIDC role assumption: exchange a Windmill OIDC token for temporary IAM keys
|
||||
//! - Message/tool conversion: OpenAI format <-> Bedrock Converse API format
|
||||
//! - Stream event parsing: Extract text/tool deltas from Bedrock stream events
|
||||
//!
|
||||
@@ -291,6 +292,187 @@ impl BedrockClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Role Assumption
|
||||
// ============================================================================
|
||||
|
||||
pub use windmill_common::auth::aws::AWS_OIDC_AUDIENCE;
|
||||
|
||||
/// Bedrock credential precedence: a bearer API key wins, then a complete pair of
|
||||
/// explicit IAM keys, then OIDC role assumption, then the ambient environment.
|
||||
/// Returns the role to assume only when both higher-priority modes are unset, so
|
||||
/// a resource can carry a role ARN without it ever overriding keys set on it.
|
||||
pub fn bedrock_oidc_role_to_assume<'a>(
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
oidc_role_arn: Option<&'a str>,
|
||||
) -> Option<&'a str> {
|
||||
let has_bearer_token = api_key.is_some_and(|key| !key.is_empty());
|
||||
let has_iam_keys = aws_access_key_id.is_some_and(|key| !key.is_empty())
|
||||
&& aws_secret_access_key.is_some_and(|key| !key.is_empty());
|
||||
|
||||
if has_bearer_token || has_iam_keys {
|
||||
return None;
|
||||
}
|
||||
|
||||
oidc_role_arn.filter(|arn| !arn.is_empty())
|
||||
}
|
||||
|
||||
/// Re-assume the role this long before AWS expires the credentials, so a request
|
||||
/// that starts on a reused set cannot finish holding dead ones.
|
||||
pub const ASSUMED_ROLE_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
/// Temporary IAM credentials minted by STS `AssumeRoleWithWebIdentity`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AssumedRoleCredentials {
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub session_token: String,
|
||||
pub expires_at: std::time::SystemTime,
|
||||
}
|
||||
|
||||
impl AssumedRoleCredentials {
|
||||
/// Whether these have enough life left to sign another request.
|
||||
pub fn is_fresh(&self) -> bool {
|
||||
self.expires_at > std::time::SystemTime::now() + ASSUMED_ROLE_REFRESH_MARGIN
|
||||
}
|
||||
}
|
||||
|
||||
/// Assume the resource's OIDC role for a job, reusing `cached` while it is still
|
||||
/// fresh, and hand back the temporary keys to sign Bedrock requests with.
|
||||
///
|
||||
/// The agent loop calls this once per iteration, so without the reuse a
|
||||
/// tool-heavy run would mint an OIDC token and call STS on every model turn.
|
||||
/// Returns `None` when a higher-priority credential is set, so the caller can
|
||||
/// call it unconditionally.
|
||||
pub async fn refresh_bedrock_oidc_credentials<'a>(
|
||||
credentials: &crate::credentials::ProviderCredentials,
|
||||
cached: &'a mut Option<AssumedRoleCredentials>,
|
||||
client: &windmill_common::client::AuthedClient,
|
||||
job_id: &uuid::Uuid,
|
||||
) -> Result<Option<&'a AssumedRoleCredentials>, Error> {
|
||||
let Some(role_arn) = bedrock_oidc_role_to_assume(
|
||||
credentials.api_key.as_deref(),
|
||||
credentials.aws_access_key_id.as_deref(),
|
||||
credentials.aws_secret_access_key.as_deref(),
|
||||
credentials.oidc_role_arn.as_deref(),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Ahead of the reuse check, so a request never signs against the empty region
|
||||
// just because an earlier one had already assumed the role.
|
||||
let region = bedrock_oidc_region(credentials.region.as_deref())?;
|
||||
|
||||
if !cached
|
||||
.as_ref()
|
||||
.is_some_and(AssumedRoleCredentials::is_fresh)
|
||||
{
|
||||
// A worker holds no OIDC signing key, so it asks the API to mint the
|
||||
// token for this job; the session name carries the job into CloudTrail.
|
||||
let id_token = client
|
||||
.get_id_token(AWS_OIDC_AUDIENCE)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to get OIDC token: {}", e)))?;
|
||||
// The whole UUID: "windmill-ai-job-" plus 32 hex is 48 characters, well
|
||||
// inside AWS's limit, and a truncated one would make two runs
|
||||
// indistinguishable in CloudTrail.
|
||||
let session_name = aws_role_session_name("windmill-ai-job", &job_id.simple().to_string());
|
||||
*cached =
|
||||
Some(assume_role_with_oidc_token(role_arn, region, id_token, &session_name).await?);
|
||||
}
|
||||
|
||||
Ok(cached.as_ref())
|
||||
}
|
||||
|
||||
/// Distinguishing digest appended to a session name that had to be truncated.
|
||||
const SESSION_NAME_DIGEST_LEN: usize = 8;
|
||||
|
||||
/// Build a role session name AWS accepts: it constrains them to
|
||||
/// `[\w+=,.@-]{2,64}`, and rejects the whole request otherwise. Disallowed
|
||||
/// characters are replaced rather than dropped.
|
||||
///
|
||||
/// The name is the only thing carrying the caller into the assumed-role ARN and
|
||||
/// CloudTrail, so an identity too long to fit keeps a prefix plus a digest of the
|
||||
/// whole thing: two identities sharing a prefix would otherwise be
|
||||
/// indistinguishable in exactly the audit trail this feature exists to produce.
|
||||
pub fn aws_role_session_name(prefix: &str, identity: &str) -> String {
|
||||
let sanitize = |s: &str| -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-')
|
||||
{
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let name = sanitize(&format!("{}-{}", prefix, identity));
|
||||
if name.len() <= 64 {
|
||||
return name;
|
||||
}
|
||||
|
||||
let digest = &windmill_common::utils::calculate_hash(identity)[..SESSION_NAME_DIGEST_LEN];
|
||||
let kept = 64 - SESSION_NAME_DIGEST_LEN - 1;
|
||||
format!("{}-{}", &name[..kept], digest)
|
||||
}
|
||||
|
||||
/// The region an OIDC role assumption runs in.
|
||||
///
|
||||
/// The assumption resolves to explicit IAM keys, and the SDK client built from
|
||||
/// those has no ambient-region fallback the way [`BedrockClient::from_env`] does,
|
||||
/// so a resource that assumes a role has to name its region. Callers check this
|
||||
/// before minting the OIDC token, so a misconfigured resource never issues an
|
||||
/// identity token it is only going to throw away.
|
||||
pub fn bedrock_oidc_region(region: Option<&str>) -> Result<&str, Error> {
|
||||
region.filter(|r| !r.is_empty()).ok_or_else(|| {
|
||||
Error::BadRequest(
|
||||
"AWS Bedrock resources that assume a role through OIDC must set a region".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Exchange a Windmill OIDC token for temporary IAM credentials on `role_arn`.
|
||||
pub async fn assume_role_with_oidc_token(
|
||||
role_arn: &str,
|
||||
region: &str,
|
||||
id_token: String,
|
||||
session_name: &str,
|
||||
) -> Result<AssumedRoleCredentials, Error> {
|
||||
use windmill_common::auth::aws::{
|
||||
get_assume_role_with_web_identity_fluent_builder, GetAuthenticationOutput, OidcAuth,
|
||||
};
|
||||
|
||||
let oidc_auth = OidcAuth { region: Some(region.to_string()), role_arn: role_arn.to_string() };
|
||||
|
||||
let output =
|
||||
get_assume_role_with_web_identity_fluent_builder(&oidc_auth, id_token, Some(session_name))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to assume AWS role {} with Windmill's OIDC token: {}",
|
||||
role_arn,
|
||||
format_bedrock_error(&e)
|
||||
))
|
||||
})?;
|
||||
|
||||
let credentials = output.get_credentials()?;
|
||||
|
||||
Ok(AssumedRoleCredentials {
|
||||
access_key_id: credentials.access_key_id.clone(),
|
||||
secret_access_key: credentials.secret_access_key.clone(),
|
||||
session_token: credentials.session_token.clone(),
|
||||
expires_at: std::time::UNIX_EPOCH
|
||||
+ std::time::Duration::from_secs(credentials.expiration.secs().max(0) as u64),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Formatting
|
||||
// ============================================================================
|
||||
@@ -1000,6 +1182,82 @@ mod tests {
|
||||
use aws_sdk_bedrockruntime::types::ReasoningContentBlock;
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
#[test]
|
||||
fn oidc_role_yields_only_to_bearer_and_complete_iam_keys() {
|
||||
let arn = Some("arn:aws:iam::1:role/bedrock");
|
||||
|
||||
assert_eq!(
|
||||
bedrock_oidc_role_to_assume(None, None, None, arn),
|
||||
arn,
|
||||
"nothing else set: assume the role rather than fall through to the environment"
|
||||
);
|
||||
assert_eq!(
|
||||
bedrock_oidc_role_to_assume(Some("key"), None, None, arn),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
bedrock_oidc_role_to_assume(None, Some("id"), Some("secret"), arn),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
bedrock_oidc_role_to_assume(None, Some("id"), None, arn),
|
||||
arn,
|
||||
"half a key pair is not usable IAM credentials, so the role still wins"
|
||||
);
|
||||
// Cleared resource fields arrive as empty strings, not as absent ones.
|
||||
assert_eq!(
|
||||
bedrock_oidc_role_to_assume(Some(""), Some(""), Some(""), arn),
|
||||
arn
|
||||
);
|
||||
assert_eq!(
|
||||
bedrock_oidc_role_to_assume(None, None, None, Some("")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_stop_being_fresh_before_aws_expires_them() {
|
||||
let at = |d: std::time::Duration| AssumedRoleCredentials {
|
||||
access_key_id: String::new(),
|
||||
secret_access_key: String::new(),
|
||||
session_token: String::new(),
|
||||
expires_at: std::time::SystemTime::now() + d,
|
||||
};
|
||||
// Reused only while there is still margin left to finish a request.
|
||||
assert!(at(ASSUMED_ROLE_REFRESH_MARGIN * 2).is_fresh());
|
||||
assert!(!at(ASSUMED_ROLE_REFRESH_MARGIN / 2).is_fresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_session_name_stays_within_what_aws_accepts() {
|
||||
// A whole job UUID fits, so CloudTrail names the exact run.
|
||||
assert_eq!(
|
||||
aws_role_session_name("windmill-ai-job", "0a1b2c3d4e5f60718293a4b5c6d7e8f9"),
|
||||
"windmill-ai-job-0a1b2c3d4e5f60718293a4b5c6d7e8f9"
|
||||
);
|
||||
// Usernames and emails routinely carry characters AWS rejects.
|
||||
assert_eq!(
|
||||
aws_role_session_name("windmill-ai-copilot", "ada/lovelace (admin)"),
|
||||
"windmill-ai-copilot-ada-lovelace--admin-"
|
||||
);
|
||||
|
||||
// Identities too long to fit keep a digest, so a shared prefix does not
|
||||
// collapse two callers into one CloudTrail identity.
|
||||
let a = aws_role_session_name("windmill-ai-copilot", &format!("{}a", "x".repeat(60)));
|
||||
let b = aws_role_session_name("windmill-ai-copilot", &format!("{}b", "x".repeat(60)));
|
||||
assert_ne!(a, b);
|
||||
for name in [&a, &b] {
|
||||
assert_eq!(name.len(), 64);
|
||||
assert!(name.chars().all(|c| c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-')));
|
||||
}
|
||||
|
||||
// Sanitising before truncating keeps every char one byte, so the cap is
|
||||
// never applied mid-character.
|
||||
let unicode = aws_role_session_name("windmill-ai-copilot", &"é".repeat(100));
|
||||
assert_eq!(unicode.len(), 64);
|
||||
}
|
||||
|
||||
fn text_message(role: &str, content: &str) -> OpenAIMessage {
|
||||
OpenAIMessage {
|
||||
role: role.to_string(),
|
||||
|
||||
@@ -19,6 +19,19 @@ pub struct ProviderCredentials {
|
||||
pub aws_access_key_id: Option<String>,
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
pub aws_session_token: Option<String>,
|
||||
/// AWS IAM role to assume through Windmill's OIDC provider. Only consulted
|
||||
/// when no bearer key and no explicit IAM keys are set — see
|
||||
/// [`crate::ai_bedrock::bedrock_oidc_role_to_assume`]. The API proxy
|
||||
/// exchanges it for temporary keys before it builds the request; the worker
|
||||
/// exchanges it at the Bedrock call, because only the API can sign an OIDC
|
||||
/// token.
|
||||
///
|
||||
/// The two paths therefore present different tokens to AWS: the proxy a
|
||||
/// workspace claim with `sub = "<email>::<workspace>"`, the worker the job
|
||||
/// claim `/oidc/token` issues, `sub = "<email>::<path>::<flow_path>::<workspace>"`.
|
||||
/// A role trust policy that conditions on `sub` has to admit both, or only
|
||||
/// one of the copilot and the AI agent step will be able to assume the role.
|
||||
pub oidc_role_arn: Option<String>,
|
||||
pub platform: AIPlatform,
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -858,6 +858,7 @@ mod tests {
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
oidc_role_arn: None,
|
||||
platform,
|
||||
custom_headers: HashMap::new(),
|
||||
}
|
||||
|
||||
@@ -780,6 +780,7 @@ mod tests {
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
oidc_role_arn: None,
|
||||
platform,
|
||||
custom_headers: HashMap::new(),
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ mod parity_tests {
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
oidc_role_arn: None,
|
||||
platform: kase.platform.clone(),
|
||||
custom_headers: HashMap::new(),
|
||||
}
|
||||
|
||||
@@ -263,6 +263,7 @@ mod tests {
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
oidc_role_arn: None,
|
||||
platform: AIPlatform::Standard,
|
||||
custom_headers: HashMap::new(),
|
||||
}
|
||||
|
||||
@@ -245,6 +245,12 @@ pub struct ProviderResource {
|
||||
deserialize_with = "empty_string_as_none"
|
||||
)]
|
||||
pub aws_session_token: Option<String>,
|
||||
#[serde(
|
||||
alias = "oidcRoleArn",
|
||||
default,
|
||||
deserialize_with = "empty_string_as_none"
|
||||
)]
|
||||
pub oidc_role_arn: Option<String>,
|
||||
/// Platform (standard or google_vertex_ai)
|
||||
#[serde(default)]
|
||||
pub platform: AIPlatform,
|
||||
@@ -311,6 +317,7 @@ impl ProviderWithResource {
|
||||
aws_access_key_id: self.resource.aws_access_key_id.clone(),
|
||||
aws_secret_access_key: self.resource.aws_secret_access_key.clone(),
|
||||
aws_session_token: self.resource.aws_session_token.clone(),
|
||||
oidc_role_arn: self.resource.oidc_role_arn.clone(),
|
||||
platform: self.resource.platform.clone(),
|
||||
custom_headers: self.resource.headers.clone(),
|
||||
})
|
||||
|
||||
@@ -120,8 +120,23 @@ lazy_static::lazy_static! {
|
||||
|
||||
}
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
lazy_static::lazy_static! {
|
||||
/// Temporary IAM credentials from a Bedrock OIDC role assumption, keyed by
|
||||
/// (workspace, role ARN, user email). The email is the identity the OIDC
|
||||
/// token is minted for, so unlike AI_REQUEST_CACHE an entry is never handed
|
||||
/// to a different user — that is what keeps AWS-side attribution per user.
|
||||
static ref BEDROCK_ASSUMED_ROLE_CACHE: Cache<(String, String, String), windmill_ai::ai_bedrock::AssumedRoleCredentials> = Cache::new(500);
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
|
||||
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
|
||||
// Dropped alongside the credentials that named the role, so that changing a
|
||||
// workspace's AI settings does not leave a session assumed under the old ones
|
||||
// in play until STS expires it.
|
||||
#[cfg(feature = "bedrock")]
|
||||
BEDROCK_ASSUMED_ROLE_CACHE
|
||||
.retain(|(cached_workspace_id, _, _), _| cached_workspace_id != workspace_id);
|
||||
}
|
||||
|
||||
/// Shared configuration for every outbound AI HTTP client (the pooled
|
||||
@@ -219,6 +234,12 @@ struct AIStandardResource {
|
||||
deserialize_with = "empty_string_as_none"
|
||||
)]
|
||||
aws_session_token: Option<String>,
|
||||
#[serde(
|
||||
alias = "oidcRoleArn",
|
||||
default,
|
||||
deserialize_with = "empty_string_as_none"
|
||||
)]
|
||||
oidc_role_arn: Option<String>,
|
||||
/// Platform (standard or google_vertex_ai)
|
||||
#[serde(default)]
|
||||
platform: AIPlatform,
|
||||
@@ -301,6 +322,11 @@ async fn resolve_provider_credentials(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let oidc_role_arn = if let Some(role_arn) = resource.oidc_role_arn {
|
||||
Some(resolve_var(role_arn, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ProviderCredentials {
|
||||
provider: provider.clone(),
|
||||
@@ -313,6 +339,7 @@ async fn resolve_provider_credentials(
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
oidc_role_arn,
|
||||
platform: resource.platform,
|
||||
custom_headers: resource.headers,
|
||||
})
|
||||
@@ -337,6 +364,7 @@ async fn resolve_provider_credentials(
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
oidc_role_arn: None,
|
||||
platform: AIPlatform::Standard,
|
||||
custom_headers: HashMap::new(),
|
||||
})
|
||||
@@ -385,6 +413,109 @@ async fn get_token_using_oauth(
|
||||
Ok(response.access_token)
|
||||
}
|
||||
|
||||
/// Exchange the resource's OIDC role ARN for temporary IAM credentials, in place.
|
||||
///
|
||||
/// Only the API holds the OIDC signing key, so the exchange happens here rather
|
||||
/// than in the Bedrock handler, which then sees ordinary IAM keys. It runs after
|
||||
/// the workspace credential cache on purpose: the STS session name names the user
|
||||
/// making the request, and a cache shared by the whole workspace would hand one
|
||||
/// user's session to another.
|
||||
#[cfg(feature = "bedrock")]
|
||||
async fn resolve_bedrock_oidc_credentials(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
authed: &ApiAuthed,
|
||||
credentials: &mut ProviderCredentials,
|
||||
) -> Result<()> {
|
||||
use windmill_ai::ai_bedrock::{aws_role_session_name, bedrock_oidc_role_to_assume};
|
||||
|
||||
// Only the Bedrock resource type carries a role ARN; leaving the field set on
|
||||
// any other resource inert keeps a hand-edited one from reaching STS.
|
||||
if credentials.provider != AIProvider::AWSBedrock {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(role_arn) = bedrock_oidc_role_to_assume(
|
||||
credentials.api_key.as_deref(),
|
||||
credentials.aws_access_key_id.as_deref(),
|
||||
credentials.aws_secret_access_key.as_deref(),
|
||||
credentials.oidc_role_arn.as_deref(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Ahead of the cache: STS credentials are region-independent, so a resource
|
||||
// with the same role but no region would otherwise reuse an entry and sign a
|
||||
// request against the empty region the guard exists to reject.
|
||||
let region = windmill_ai::ai_bedrock::bedrock_oidc_region(credentials.region.as_deref())?;
|
||||
|
||||
let session_name = aws_role_session_name("windmill-ai-copilot", &authed.username);
|
||||
// Keyed on the email, the identity the OIDC token is minted for, rather than
|
||||
// on the session name derived from it: that name is sanitised and length
|
||||
// capped, so it is a lossy stand-in for the caller.
|
||||
let cache_key = (w_id.to_string(), role_arn.to_string(), authed.email.clone());
|
||||
|
||||
let assumed = match BEDROCK_ASSUMED_ROLE_CACHE
|
||||
.get(&cache_key)
|
||||
.filter(windmill_ai::ai_bedrock::AssumedRoleCredentials::is_fresh)
|
||||
{
|
||||
Some(cached) => cached,
|
||||
None => {
|
||||
let assumed =
|
||||
assume_bedrock_role(db, w_id, authed, role_arn, region, &session_name).await?;
|
||||
BEDROCK_ASSUMED_ROLE_CACHE.insert(cache_key, assumed.clone());
|
||||
assumed
|
||||
}
|
||||
};
|
||||
|
||||
credentials.aws_access_key_id = Some(assumed.access_key_id);
|
||||
credentials.aws_secret_access_key = Some(assumed.secret_access_key);
|
||||
credentials.aws_session_token = Some(assumed.session_token);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
async fn assume_bedrock_role(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
authed: &ApiAuthed,
|
||||
role_arn: &str,
|
||||
region: &str,
|
||||
session_name: &str,
|
||||
) -> Result<windmill_ai::ai_bedrock::AssumedRoleCredentials> {
|
||||
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
|
||||
{
|
||||
use windmill_common::oidc_oss::{generate_id_token, WorkspaceClaim};
|
||||
|
||||
let id_token = generate_id_token(
|
||||
Some(db),
|
||||
WorkspaceClaim { workspace: w_id.to_string() },
|
||||
windmill_ai::ai_bedrock::AWS_OIDC_AUDIENCE,
|
||||
format!("{}::{}", authed.email, w_id),
|
||||
Some(authed.email.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
windmill_ai::ai_bedrock::assume_role_with_oidc_token(
|
||||
role_arn,
|
||||
region,
|
||||
id_token.to_string(),
|
||||
session_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(all(feature = "enterprise", feature = "openidconnect", feature = "private")))]
|
||||
{
|
||||
let _ = (db, w_id, authed, role_arn, region, session_name);
|
||||
Err(Error::BadRequest(
|
||||
"Assuming an AWS role through Windmill's OIDC provider requires an enterprise license"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExpiringProviderCredentials {
|
||||
credentials: ProviderCredentials,
|
||||
@@ -976,6 +1107,7 @@ async fn global_proxy(
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
oidc_role_arn: None,
|
||||
platform: AIPlatform::Standard,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
@@ -1253,6 +1385,9 @@ async fn proxy(
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
resolve_bedrock_oidc_credentials(&db, &w_id, &authed, &mut credentials).await?;
|
||||
|
||||
// Free tier: pin the model and clamp max_tokens server-side before forwarding,
|
||||
// since the request body is otherwise client-controlled.
|
||||
if free_lease.is_some() {
|
||||
@@ -1463,6 +1598,7 @@ mod tests {
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
oidc_role_arn: None,
|
||||
platform: AIPlatform::Standard,
|
||||
custom_headers: HashMap::new(),
|
||||
}
|
||||
@@ -1504,6 +1640,24 @@ mod tests {
|
||||
("workspace-b".to_string(), AIProvider::OpenAI),
|
||||
ExpiringProviderCredentials::new(sample_provider_credentials(), None),
|
||||
);
|
||||
#[cfg(feature = "bedrock")]
|
||||
let assumed_role_key = {
|
||||
let key = (
|
||||
"workspace-a".to_string(),
|
||||
"arn:aws:iam::123456789012:role/bedrock".to_string(),
|
||||
"someone@windmill.dev".to_string(),
|
||||
);
|
||||
BEDROCK_ASSUMED_ROLE_CACHE.insert(
|
||||
key.clone(),
|
||||
windmill_ai::ai_bedrock::AssumedRoleCredentials {
|
||||
access_key_id: "AKIA".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
session_token: "session".to_string(),
|
||||
expires_at: std::time::SystemTime::now() + std::time::Duration::from_secs(3600),
|
||||
},
|
||||
);
|
||||
key
|
||||
};
|
||||
|
||||
invalidate_ai_request_cache_for_workspace("workspace-a");
|
||||
|
||||
@@ -1516,6 +1670,8 @@ mod tests {
|
||||
assert!(AI_REQUEST_CACHE
|
||||
.get(&("workspace-b".to_string(), AIProvider::OpenAI))
|
||||
.is_some());
|
||||
#[cfg(feature = "bedrock")]
|
||||
assert!(BEDROCK_ASSUMED_ROLE_CACHE.get(&assumed_role_key).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1419,6 +1419,11 @@ pub async fn run_agent(
|
||||
.map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS))
|
||||
.unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS);
|
||||
|
||||
// Held across iterations so an OIDC role is assumed once for the job and
|
||||
// re-assumed only near expiry, rather than on every model turn.
|
||||
#[cfg(feature = "bedrock")]
|
||||
let mut bedrock_assumed_role: Option<windmill_ai::ai_bedrock::AssumedRoleCredentials> = None;
|
||||
|
||||
// Main agent loop
|
||||
for i in 0..max_iterations {
|
||||
// Check if parent was canceled — stop iterating but let current tool calls finish
|
||||
@@ -1438,6 +1443,27 @@ pub async fn run_agent(
|
||||
.region
|
||||
.as_deref()
|
||||
.unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION);
|
||||
// An OIDC role resolves to ordinary IAM keys, so from here the
|
||||
// call is identical to the explicit-keys mode.
|
||||
let assumed = windmill_ai::ai_bedrock::refresh_bedrock_oidc_credentials(
|
||||
&credentials,
|
||||
&mut bedrock_assumed_role,
|
||||
client,
|
||||
&job.id,
|
||||
)
|
||||
.await?;
|
||||
let (access_key_id, secret_access_key, session_token) = match assumed {
|
||||
Some(assumed) => (
|
||||
Some(assumed.access_key_id.as_str()),
|
||||
Some(assumed.secret_access_key.as_str()),
|
||||
Some(assumed.session_token.as_str()),
|
||||
),
|
||||
None => (
|
||||
credentials.aws_access_key_id.as_deref(),
|
||||
credentials.aws_secret_access_key.as_deref(),
|
||||
credentials.aws_session_token.as_deref(),
|
||||
),
|
||||
};
|
||||
// Use Bedrock SDK via dedicated query builder
|
||||
windmill_ai::providers::bedrock::BedrockQueryBuilder::default()
|
||||
.execute_request(
|
||||
@@ -1453,9 +1479,9 @@ pub async fn run_agent(
|
||||
client,
|
||||
&job.workspace_id,
|
||||
structured_output_tool_name.as_deref(),
|
||||
credentials.aws_access_key_id.as_deref(),
|
||||
credentials.aws_secret_access_key.as_deref(),
|
||||
credentials.aws_session_token.as_deref(),
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { JobService, type FlowValue } from '$lib/gen'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { tryEvery } from '$lib/utils'
|
||||
import { Check, LoaderCircle, Server, X, Cpu } from 'lucide-svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
@@ -179,6 +180,11 @@
|
||||
<p class="text-xs text-secondary">
|
||||
Check if AWS credentials are available from the environment. If available, you do not need to
|
||||
configure credentials manually.
|
||||
{#if $enterpriseLicense}
|
||||
To attribute usage to the job or user that made the request instead, set
|
||||
<span class="font-mono">oidcRoleArn</span> to an IAM role Windmill assumes through its OIDC provider,
|
||||
and set a region alongside it.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
|
||||
Reference in New Issue
Block a user