diff --git a/Cargo.lock b/Cargo.lock index 9c47867cf..b19e48ebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2114,6 +2114,7 @@ dependencies = [ "async-trait", "bumpalo", "bytes", + "chrono", "criterion", "dashmap", "futures", @@ -2132,6 +2133,10 @@ dependencies = [ "ntex-http", "ordered-float", "regex-automata", + "reqsign-aws-v4", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-http-send-reqwest", "ryu", "serde", "sonic-rs", @@ -2604,6 +2609,47 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -3974,6 +4020,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4130,6 +4185,16 @@ dependencies = [ "tracing-tree", ] +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4395,6 +4460,79 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +[[package]] +name = "reqsign-aws-v4" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4510c2a3e42b653cf788d560a3d54b0ae4cc315a62aaba773554f18319c0db0b" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "form_urlencoded", + "http", + "log", + "percent-encoding", + "quick-xml", + "reqsign-core", + "rust-ini", + "serde", + "serde_json", + "serde_urlencoded", + "sha1", +] + +[[package]] +name = "reqsign-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39da118ccf3bdb067ac6cc40136fec99bc5ba418cbd388dc88e4ce0e5d0b1423" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http", + "jiff", + "log", + "percent-encoding", + "sha1", + "sha2", + "windows-sys 0.61.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ea66036266a9ac371d2e63cc7d345e69994da0168b4e6f3487fe21e126f76" +dependencies = [ + "anyhow", + "async-trait", + "reqsign-core", + "tokio", +] + +[[package]] +name = "reqsign-http-send-reqwest" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46186bce769674f9200ad01af6f2ca42de3e819ddc002fff1edae135bfb6cd9c" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "futures-channel", + "http", + "http-body-util", + "reqsign-core", + "reqwest", + "wasm-bindgen-futures", +] + [[package]] name = "reqwest" version = "0.12.24" diff --git a/docs/README.md b/docs/README.md index fb474b9d2..6cf190532 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ |Name|Type|Description|Required| |----|----|-----------|--------| +|[**aws\_sig\_v4**](#aws_sig_v4)|`object`|Configuration for AWS SigV4 signing of requests to subgraphs.
|yes| |[**cors**](#cors)|`object`|Configuration for CORS (Cross-Origin Resource Sharing).
Default: `{"allow_any_origin":false,"allow_credentials":false,"enabled":false,"policies":[]}`
|yes| |[**csrf**](#csrf)|`object`|Configuration for CSRF prevention.
Default: `{"enabled":false,"required_headers":[]}`
|| |[**graphiql**](#graphiql)|`object`|Configuration for the GraphiQL interface.
Default: `{"enabled":true}`
|| @@ -21,6 +22,8 @@ **Example** ```yaml +aws_sig_v4: + subgraphs: {} cors: allow_any_origin: false allow_credentials: false @@ -113,6 +116,36 @@ traffic_shaping: ``` + +## aws\_sig\_v4: object + +Configuration for AWS SigV4 signing of requests to subgraphs. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**all**|||yes| +|[**subgraphs**](#aws_sig_v4subgraphs)|`object`||no| + +**Additional Properties:** not allowed +**Example** + +```yaml +subgraphs: {} + +``` + + +### aws\_sig\_v4\.subgraphs: object + +**Additional Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**Additional Properties**|||| + ## cors: object diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 27f7af1bf..55c689f95 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -49,12 +49,17 @@ itoa = "1.0.15" ryu = "1.0.20" indexmap = "2.10.0" bumpalo = "3.19.0" +reqsign-aws-v4 = "2.0.1" +reqsign-core = "2.0.1" +reqsign-file-read-tokio = "2.0.1" +reqsign-http-send-reqwest = "2.0.1" [dev-dependencies] subgraphs = { path = "../../bench/subgraphs" } criterion = { workspace = true } tokio = { workspace = true } insta = { workspace = true } +chrono = "0.4.42" [[bench]] name = "executor_benches" diff --git a/lib/executor/src/execution/awssigv4.rs b/lib/executor/src/execution/awssigv4.rs new file mode 100644 index 000000000..ad07711e5 --- /dev/null +++ b/lib/executor/src/execution/awssigv4.rs @@ -0,0 +1,145 @@ +use hive_router_config::aws_sig_v4::AwsSigV4SubgraphConfig; +use reqsign_aws_v4::{ + Credential, DefaultCredentialProvider, DefaultCredentialProviderBuilder, RequestSigner, + StaticCredentialProvider, +}; +use reqsign_core::{Context, OsEnv, ProvideCredentialChain, Signer}; +use reqsign_file_read_tokio::TokioFileRead; +use reqsign_http_send_reqwest::ReqwestHttpSend; + +pub fn create_awssigv4_signer(config: &AwsSigV4SubgraphConfig) -> Option> { + let ctx = Context::new() + .with_file_read(TokioFileRead) + .with_http_send(ReqwestHttpSend::default()) + .with_env(OsEnv); + let mut loader = ProvideCredentialChain::new(); + match config { + AwsSigV4SubgraphConfig::Disabled => { + return None; + } + AwsSigV4SubgraphConfig::DefaultChain { + default_chain: default_chain_config, + } => { + loader = loader.push(DefaultCredentialProvider::new()); + let mut default_chain_builder = DefaultCredentialProviderBuilder::new(); + if let Some(profile_name) = &default_chain_config.profile_name { + default_chain_builder = default_chain_builder + .configure_profile(|p| p.with_credentials_file(profile_name)); + } + if let Some(assume_role_config) = &default_chain_config.assume_role { + default_chain_builder = + default_chain_builder.configure_assume_role(|mut assume_role| { + assume_role = assume_role + .with_role_arn(&assume_role_config.role_arn) + .with_region(default_chain_config.region.to_string()); + if let Some(session_name) = &assume_role_config.session_name { + assume_role = + assume_role.with_role_session_name(session_name.to_string()); + } + assume_role + }); + let default_chain = default_chain_builder.build(); + loader = loader.push(default_chain); + } + } + AwsSigV4SubgraphConfig::HardCoded { hardcoded } => { + let mut provider = StaticCredentialProvider::new( + &hardcoded.access_key_id, + &hardcoded.secret_access_key, + ); + if let Some(session_token) = &hardcoded.session_token { + provider = provider.with_session_token(session_token); + } + loader = loader.push(provider); + } + } + let service: &str = match config { + AwsSigV4SubgraphConfig::DefaultChain { default_chain } => &default_chain.service, + AwsSigV4SubgraphConfig::HardCoded { hardcoded } => &hardcoded.service_name, + AwsSigV4SubgraphConfig::Disabled => unreachable!(), + }; + let region: &str = match config { + AwsSigV4SubgraphConfig::DefaultChain { default_chain } => &default_chain.region, + AwsSigV4SubgraphConfig::HardCoded { hardcoded } => &hardcoded.region, + AwsSigV4SubgraphConfig::Disabled => unreachable!(), + }; + let builder = RequestSigner::new(service, region); + + Some(Signer::new(ctx, loader, builder)) +} + +#[cfg(test)] +mod tests { + use crate::execution::awssigv4::create_awssigv4_signer; + use bytes::Bytes; + use chrono::Utc; + use hive_router_config::aws_sig_v4::{AwsSigV4SubgraphConfig, HardCodedConfig}; + use http_body_util::Full; + use hyper::body::Body; + + #[tokio::test] + async fn signs_the_request_correctly() { + let access_key_id = "AKIAIOSFODNN7EXAMPLE"; + let secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let region = "eu-central-1"; + let service_name = "s3"; + let config = AwsSigV4SubgraphConfig::HardCoded { + hardcoded: HardCodedConfig { + access_key_id: access_key_id.to_string(), + secret_access_key: secret_access_key.to_string(), + region: region.to_string(), + service_name: service_name.to_string(), + session_token: None, + }, + }; + let signer = create_awssigv4_signer(&config).expect("Expected to return a signer"); + let body = Full::new(Bytes::from("query { hello }")); + let content_length = body.size_hint().exact().unwrap(); + let endpoint = format!( + "http://sigv4examplegraphqlbucket.{}-{}.amazonaws.com", + service_name, region + ); + let req: http::Request> = http::Request::builder() + .method("POST") + .uri(endpoint) + .header("Accept", "application/json") + .header("Content-Length", content_length) + .header("Content-Type", "application/json") + .body(body) + .unwrap(); + + let (mut parts, body) = req.into_parts(); + + signer + .sign(&mut parts, None) + .await + .expect("Expected to sign correctly"); + + let req = http::Request::from_parts(parts, body); + + let authorization_header = req + .headers() + .get("Authorization") + .expect("Expected to have Authorization header") + .to_str() + .expect("Expected to convert to str"); + + let date_stamp = Utc::now().format("%Y%m%d"); + + let mut expected_auth_header_prefix = "AWS4-HMAC-SHA256 ".to_string(); + expected_auth_header_prefix.push_str(&format!( + "Credential={}/{}/{}/{}/aws4_request, ", + access_key_id, date_stamp, region, service_name + )); + expected_auth_header_prefix.push_str( + "SignedHeaders=accept;content-length;content-type;host;x-amz-content-sha256;x-amz-date, Signature=", + ); + + assert!( + authorization_header.starts_with(&expected_auth_header_prefix), + "Expected authorization header to start with '{}', but got '{}'", + expected_auth_header_prefix, + authorization_header + ); + } +} diff --git a/lib/executor/src/execution/mod.rs b/lib/executor/src/execution/mod.rs index 52dc59506..26a28c572 100644 --- a/lib/executor/src/execution/mod.rs +++ b/lib/executor/src/execution/mod.rs @@ -1,3 +1,4 @@ +pub mod awssigv4; pub mod client_request_details; pub mod error; pub mod jwt_forward; diff --git a/lib/executor/src/executors/error.rs b/lib/executor/src/executors/error.rs index 2234f524c..ecc6d5df0 100644 --- a/lib/executor/src/executors/error.rs +++ b/lib/executor/src/executors/error.rs @@ -20,6 +20,8 @@ pub enum SubgraphExecutorError { RequestFailure(String, String), #[error("Failed to serialize variable \"{0}\": {1}")] VariablesSerializationFailure(String, String), + #[error("Failed to sign request with AWSSigV4 for subgraph \"{0}\": {1}")] + AwsSigV4SigningFailure(String, String), } impl From for GraphQLError { @@ -76,6 +78,9 @@ impl SubgraphExecutorError { SubgraphExecutorError::VariablesSerializationFailure(_, _) => { "SUBGRAPH_VARIABLES_SERIALIZATION_FAILURE" } + SubgraphExecutorError::AwsSigV4SigningFailure(_, _) => { + "SUBGRAPH_AWS_SIGV4_SIGNING_FAILURE" + } } } } diff --git a/lib/executor/src/executors/http.rs b/lib/executor/src/executors/http.rs index 29b392567..2d45929f4 100644 --- a/lib/executor/src/executors/http.rs +++ b/lib/executor/src/executors/http.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use crate::executors::common::HttpExecutionResponse; use crate::executors::dedupe::{request_fingerprint, ABuildHasher, SharedResponse}; +use async_trait::async_trait; use dashmap::DashMap; use hive_router_config::HiveRouterConfig; +use reqsign_aws_v4::Credential; +use reqsign_core::Signer; use tokio::sync::OnceCell; -use async_trait::async_trait; - use bytes::{BufMut, Bytes, BytesMut}; use http::HeaderMap; use http::HeaderValue; @@ -37,6 +38,7 @@ pub struct HTTPSubgraphExecutor { pub semaphore: Arc, pub config: Arc, pub in_flight_requests: Arc>, ABuildHasher>>, + pub aws_sigv4_signer: Option>, } const FIRST_VARIABLE_STR: &[u8] = b",\"variables\":{"; @@ -52,6 +54,7 @@ impl HTTPSubgraphExecutor { semaphore: Arc, config: Arc, in_flight_requests: Arc>, ABuildHasher>>, + aws_sigv4_signer: Option>, ) -> Self { let mut header_map = HeaderMap::new(); header_map.insert( @@ -71,6 +74,7 @@ impl HTTPSubgraphExecutor { semaphore, config, in_flight_requests, + aws_sigv4_signer, } } @@ -138,7 +142,7 @@ impl HTTPSubgraphExecutor { body: Vec, headers: HeaderMap, ) -> Result { - let mut req = hyper::Request::builder() + let mut req: http::Request> = hyper::Request::builder() .method(http::Method::POST) .uri(&self.endpoint) .version(Version::HTTP_11) @@ -151,6 +155,17 @@ impl HTTPSubgraphExecutor { debug!("making http request to {}", self.endpoint.to_string()); + if let Some(aws_sigv4_signer) = &self.aws_sigv4_signer { + let (mut parts, body) = req.into_parts(); + aws_sigv4_signer.sign(&mut parts, None).await.map_err(|e| { + SubgraphExecutorError::AwsSigV4SigningFailure( + self.endpoint.to_string(), + e.to_string(), + ) + })?; + req = http::Request::from_parts(parts, body); + } + let res = self.http_client.request(req).await.map_err(|e| { SubgraphExecutorError::RequestFailure(self.endpoint.to_string(), e.to_string()) })?; diff --git a/lib/executor/src/executors/map.rs b/lib/executor/src/executors/map.rs index a3c297ad1..9fc1746b3 100644 --- a/lib/executor/src/executors/map.rs +++ b/lib/executor/src/executors/map.rs @@ -27,7 +27,7 @@ use vrl::{ }; use crate::{ - execution::client_request_details::ClientRequestDetails, + execution::{awssigv4::create_awssigv4_signer, client_request_details::ClientRequestDetails}, executors::{ common::{ HttpExecutionRequest, HttpExecutionResponse, SubgraphExecutor, SubgraphExecutorBoxedArc, @@ -317,6 +317,15 @@ impl SubgraphExecutorMap { .or_insert_with(|| Arc::new(Semaphore::new(self.max_connections_per_host))) .clone(); + let aws_sigv4_subgraph_config = self + .config + .aws_sig_v4 + .subgraphs + .get(subgraph_name) + .unwrap_or(&self.config.aws_sig_v4.all); + + let aws_sigv4_signer = create_awssigv4_signer(aws_sigv4_subgraph_config); + let executor = HTTPSubgraphExecutor::new( subgraph_name.to_string(), endpoint_uri, @@ -324,6 +333,7 @@ impl SubgraphExecutorMap { semaphore, self.config.clone(), self.in_flight_requests.clone(), + aws_sigv4_signer, ); self.executors_by_subgraph diff --git a/lib/router-config/src/aws_sig_v4.rs b/lib/router-config/src/aws_sig_v4.rs new file mode 100644 index 000000000..a1d095f18 --- /dev/null +++ b/lib/router-config/src/aws_sig_v4.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AwsSigV4Config { + // configuration that will apply to all subgraphs + pub all: AwsSigV4SubgraphConfig, + + // per-subgraph configuration overrides + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub subgraphs: HashMap, +} + +impl Default for AwsSigV4Config { + fn default() -> Self { + Self { + all: default_all_config(), + subgraphs: HashMap::new(), + } + } +} + +fn default_all_config() -> AwsSigV4SubgraphConfig { + AwsSigV4SubgraphConfig::Disabled +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum AwsSigV4SubgraphConfig { + Disabled, + DefaultChain { default_chain: DefaultChainConfig }, + // Not recommended, prefer using default_chain as shown above + HardCoded { hardcoded: HardCodedConfig }, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct DefaultChainConfig { + // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile + pub profile_name: Option, + + // https://docs.aws.amazon.com/general/latest/gr/rande.html + pub region: String, + + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html + pub service: String, + + pub assume_role: Option, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct HardCodedConfig { + pub access_key_id: String, + pub secret_access_key: String, + pub region: String, + pub service_name: String, + pub session_token: Option, +} + +impl AwsSigV4Config { + pub fn is_disabled(&self) -> bool { + matches!(self.all, AwsSigV4SubgraphConfig::Disabled) + && (self.subgraphs.is_empty() + || self + .subgraphs + .values() + .all(|cfg| matches!(cfg, AwsSigV4SubgraphConfig::Disabled))) + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct AssumeRoleConfig { + pub role_arn: String, + pub session_name: Option, +} diff --git a/lib/router-config/src/lib.rs b/lib/router-config/src/lib.rs index 537244c9e..0476f7e9d 100644 --- a/lib/router-config/src/lib.rs +++ b/lib/router-config/src/lib.rs @@ -1,3 +1,4 @@ +pub mod aws_sig_v4; pub mod cors; pub mod csrf; mod env_overrides; @@ -92,6 +93,13 @@ pub struct HiveRouterConfig { /// Configuration for overriding labels. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub override_labels: OverrideLabelsConfig, + + /// Configuration for AWS SigV4 signing of requests to subgraphs. + #[serde( + default, + skip_serializing_if = "aws_sig_v4::AwsSigV4Config::is_disabled" + )] + pub aws_sig_v4: aws_sig_v4::AwsSigV4Config, } #[derive(Debug, thiserror::Error)]