Skip to content

Commit f42ad50

Browse files
committed
feat(router): HMAC based Subgraph Auth
1 parent 0438acf commit f42ad50

File tree

9 files changed

+189
-22
lines changed

9 files changed

+189
-22
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/executor/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ itoa = "1.0.15"
4949
ryu = "1.0.20"
5050
indexmap = "2.10.0"
5151
bumpalo = "3.19.0"
52+
hmac = "0.12.1"
53+
sha2 = "0.10.9"
54+
hex = "0.4.3"
5255

5356
[dev-dependencies]
5457
subgraphs = { path = "../../bench/subgraphs" }

lib/executor/src/execution/plan.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,7 @@ impl<'exec, 'req> Executor<'exec, 'req> {
708708
representations,
709709
headers: headers_map,
710710
extensions: None,
711+
client_request: self.client_request,
711712
};
712713

713714
if let Some(jwt_forwarding_plan) = &self.jwt_forwarding_plan {
@@ -722,7 +723,7 @@ impl<'exec, 'req> Executor<'exec, 'req> {
722723
subgraph_name: node.service_name.clone(),
723724
response: self
724725
.executors
725-
.execute(&node.service_name, subgraph_request, self.client_request)
726+
.execute(&node.service_name, subgraph_request)
726727
.await
727728
.into(),
728729
}))

lib/executor/src/executors/common.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ use bytes::Bytes;
55
use http::HeaderMap;
66
use sonic_rs::Value;
77

8+
use crate::execution::client_request_details::ClientRequestDetails;
9+
810
#[async_trait]
911
pub trait SubgraphExecutor {
10-
async fn execute<'a>(
12+
async fn execute<'exec, 'req>(
1113
&self,
12-
execution_request: HttpExecutionRequest<'a>,
14+
execution_request: HttpExecutionRequest<'exec, 'req>,
1315
) -> HttpExecutionResponse;
1416

1517
fn to_boxed_arc<'a>(self) -> Arc<Box<dyn SubgraphExecutor + Send + Sync + 'a>>
@@ -26,18 +28,19 @@ pub type SubgraphExecutorBoxedArc = Arc<Box<SubgraphExecutorType>>;
2628

2729
pub type SubgraphRequestExtensions = HashMap<String, Value>;
2830

29-
pub struct HttpExecutionRequest<'a> {
30-
pub query: &'a str,
31+
pub struct HttpExecutionRequest<'exec, 'req> {
32+
pub query: &'exec str,
3133
pub dedupe: bool,
32-
pub operation_name: Option<&'a str>,
34+
pub operation_name: Option<&'exec str>,
3335
// TODO: variables could be stringified before even executing the request
34-
pub variables: Option<HashMap<&'a str, &'a sonic_rs::Value>>,
36+
pub variables: Option<HashMap<&'exec str, &'exec sonic_rs::Value>>,
3537
pub headers: HeaderMap,
3638
pub representations: Option<Vec<u8>>,
3739
pub extensions: Option<SubgraphRequestExtensions>,
40+
pub client_request: &'exec ClientRequestDetails<'exec, 'req>,
3841
}
3942

40-
impl HttpExecutionRequest<'_> {
43+
impl HttpExecutionRequest<'_, '_> {
4144
pub fn add_request_extensions_field(&mut self, key: String, value: Value) {
4245
self.extensions
4346
.get_or_insert_with(HashMap::new)

lib/executor/src/executors/error.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ pub enum SubgraphExecutorError {
2020
RequestFailure(String, String),
2121
#[error("Failed to serialize variable \"{0}\": {1}")]
2222
VariablesSerializationFailure(String, String),
23+
#[error("HMAC signature error: {0}")]
24+
HMACSignatureError(String),
25+
#[error("Failed to serialize extension \"{0}\": {1}")]
26+
ExtensionSerializationFailure(String, String),
2327
}
2428

2529
impl From<SubgraphExecutorError> for GraphQLError {
@@ -61,6 +65,10 @@ impl SubgraphExecutorError {
6165
SubgraphExecutorError::VariablesSerializationFailure(_, _) => {
6266
"SUBGRAPH_VARIABLES_SERIALIZATION_FAILURE"
6367
}
68+
SubgraphExecutorError::ExtensionSerializationFailure(_, _) => {
69+
"SUBGRAPH_EXTENSION_SERIALIZATION_FAILURE"
70+
}
71+
SubgraphExecutorError::HMACSignatureError(_) => "SUBGRAPH_HMAC_SIGNATURE_ERROR",
6472
}
6573
}
6674
}

lib/executor/src/executors/http.rs

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
1+
use std::collections::BTreeMap;
12
use std::sync::Arc;
23

34
use crate::executors::common::HttpExecutionResponse;
45
use crate::executors::dedupe::{request_fingerprint, ABuildHasher, SharedResponse};
56
use dashmap::DashMap;
7+
use hive_router_config::hmac_signature::BooleanOrExpression;
68
use hive_router_config::HiveRouterConfig;
79
use tokio::sync::OnceCell;
810

911
use async_trait::async_trait;
1012

1113
use bytes::{BufMut, Bytes, BytesMut};
14+
use hmac::{Hmac, Mac};
1215
use http::HeaderMap;
1316
use http::HeaderValue;
1417
use http_body_util::BodyExt;
1518
use http_body_util::Full;
1619
use hyper::Version;
1720
use hyper_tls::HttpsConnector;
1821
use hyper_util::client::legacy::{connect::HttpConnector, Client};
22+
use sha2::Sha256;
1923
use tokio::sync::Semaphore;
2024
use tracing::debug;
2125

@@ -27,6 +31,7 @@ use crate::utils::consts::COLON;
2731
use crate::utils::consts::COMMA;
2832
use crate::utils::consts::QUOTE;
2933
use crate::{executors::common::SubgraphExecutor, json_writer::write_and_escape_string};
34+
use vrl::core::Value as VrlValue;
3035

3136
#[derive(Debug)]
3237
pub struct HTTPSubgraphExecutor {
@@ -41,9 +46,12 @@ pub struct HTTPSubgraphExecutor {
4146

4247
const FIRST_VARIABLE_STR: &[u8] = b",\"variables\":{";
4348
const FIRST_QUOTE_STR: &[u8] = b"{\"query\":";
49+
const FIRST_EXTENSION_STR: &[u8] = b",\"extensions\":{";
4450

4551
pub type HttpClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
4652

53+
type HmacSha256 = Hmac<Sha256>;
54+
4755
impl HTTPSubgraphExecutor {
4856
pub fn new(
4957
subgraph_name: String,
@@ -74,9 +82,9 @@ impl HTTPSubgraphExecutor {
7482
}
7583
}
7684

77-
fn build_request_body<'a>(
85+
fn build_request_body<'exec, 'req>(
7886
&self,
79-
execution_request: &HttpExecutionRequest<'a>,
87+
execution_request: &HttpExecutionRequest<'exec, 'req>,
8088
) -> Result<Vec<u8>, SubgraphExecutorError> {
8189
let mut body = Vec::with_capacity(4096);
8290
body.put(FIRST_QUOTE_STR);
@@ -118,13 +126,90 @@ impl HTTPSubgraphExecutor {
118126
body.put(CLOSE_BRACE);
119127
}
120128

121-
if let Some(extensions) = &execution_request.extensions {
122-
if !extensions.is_empty() {
123-
let as_value = sonic_rs::to_value(extensions).unwrap();
129+
let should_sign_hmac = match &self.config.hmac_signature.enabled {
130+
BooleanOrExpression::Boolean(b) => *b,
131+
BooleanOrExpression::Expression(expr) => {
132+
// .subgraph
133+
let subgraph_value = VrlValue::Object(BTreeMap::from([(
134+
"name".into(),
135+
VrlValue::Bytes(Bytes::from(self.subgraph_name.to_owned())),
136+
)]));
137+
// .request
138+
let request_value: VrlValue = execution_request.client_request.into();
139+
let target_value = VrlValue::Object(BTreeMap::from([
140+
("subgraph".into(), subgraph_value),
141+
("request".into(), request_value),
142+
]));
143+
let result = expr.execute_with_value(target_value);
144+
match result {
145+
Ok(VrlValue::Boolean(b)) => b,
146+
Ok(_) => {
147+
return Err(SubgraphExecutorError::HMACSignatureError(
148+
"HMAC signature expression did not evaluate to a boolean".to_string(),
149+
));
150+
}
151+
Err(e) => {
152+
return Err(SubgraphExecutorError::HMACSignatureError(format!(
153+
"HMAC signature expression evaluation error: {}",
154+
e
155+
)));
156+
}
157+
}
158+
}
159+
};
124160

125-
body.put(COMMA);
126-
body.put("\"extensions\":".as_bytes());
127-
body.extend_from_slice(as_value.to_string().as_bytes());
161+
let hmac_signature_ext = if should_sign_hmac {
162+
let mut mac = HmacSha256::new_from_slice(self.config.hmac_signature.secret.as_bytes())
163+
.map_err(|e| {
164+
SubgraphExecutorError::HMACSignatureError(format!(
165+
"Failed to create HMAC instance: {}",
166+
e
167+
))
168+
})?;
169+
let mut body_without_extensions = body.clone();
170+
body_without_extensions.put(CLOSE_BRACE);
171+
mac.update(&body_without_extensions);
172+
let result = mac.finalize();
173+
let result_bytes = result.into_bytes();
174+
Some(result_bytes)
175+
} else {
176+
None
177+
};
178+
179+
if let Some(extensions) = &execution_request.extensions {
180+
let mut first = true;
181+
for (extension_name, extension_value) in extensions {
182+
if first {
183+
body.put(COMMA);
184+
body.put(FIRST_EXTENSION_STR);
185+
first = false;
186+
} else {
187+
body.put(COMMA);
188+
}
189+
body.put(QUOTE);
190+
body.put(extension_name.as_bytes());
191+
body.put(QUOTE);
192+
body.put(COLON);
193+
let value_str = sonic_rs::to_string(extension_value).map_err(|err| {
194+
SubgraphExecutorError::ExtensionSerializationFailure(
195+
extension_name.to_string(),
196+
err.to_string(),
197+
)
198+
})?;
199+
body.put(value_str.as_bytes());
200+
}
201+
if let Some(hmac_bytes) = hmac_signature_ext {
202+
if first {
203+
body.put(COMMA);
204+
body.put(FIRST_EXTENSION_STR);
205+
} else {
206+
body.put(COMMA);
207+
}
208+
body.put(self.config.hmac_signature.extension_name.as_bytes());
209+
let hmac_hex = hex::encode(hmac_bytes);
210+
body.put(QUOTE);
211+
body.put(hmac_hex.as_bytes());
212+
body.put(QUOTE);
128213
}
129214
}
130215

@@ -210,9 +295,9 @@ impl HTTPSubgraphExecutor {
210295
#[async_trait]
211296
impl SubgraphExecutor for HTTPSubgraphExecutor {
212297
#[tracing::instrument(skip_all, fields(subgraph_name = self.subgraph_name))]
213-
async fn execute<'a>(
298+
async fn execute<'exec, 'req>(
214299
&self,
215-
execution_request: HttpExecutionRequest<'a>,
300+
execution_request: HttpExecutionRequest<'exec, 'req>,
216301
) -> HttpExecutionResponse {
217302
let body = match self.build_request_body(&execution_request) {
218303
Ok(body) => body,

lib/executor/src/executors/map.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,12 @@ impl SubgraphExecutorMap {
105105
Ok(subgraph_executor_map)
106106
}
107107

108-
pub async fn execute<'a, 'req>(
108+
pub async fn execute<'exec, 'req>(
109109
&self,
110110
subgraph_name: &str,
111-
execution_request: HttpExecutionRequest<'a>,
112-
client_request: &ClientRequestDetails<'a, 'req>,
111+
execution_request: HttpExecutionRequest<'exec, 'req>,
113112
) -> HttpExecutionResponse {
114-
match self.get_or_create_executor(subgraph_name, client_request) {
113+
match self.get_or_create_executor(subgraph_name, execution_request.client_request) {
115114
Ok(Some(executor)) => executor.execute(execution_request).await,
116115
Err(err) => {
117116
error!(
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use schemars::JsonSchema;
2+
use serde::{Deserialize, Serialize};
3+
4+
use crate::primitives::expression::Expression;
5+
6+
#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema)]
7+
pub struct HMACSignatureConfig {
8+
// Whether to sign outgoing requests with HMAC signatures.
9+
// Can be a boolean or a VRL expression that evaluates to a boolean.
10+
// Example:
11+
// hmac_signature:
12+
// enabled: true
13+
// or enable it conditionally based on the subgraph name:
14+
// hmac_signature:
15+
// enabled: |
16+
// if .subgraph.name == "users" {
17+
// true
18+
// } else {
19+
// false
20+
// }
21+
pub enabled: BooleanOrExpression,
22+
23+
// The secret key used for HMAC signing and verification.
24+
// It should be a random, opaque string shared between the Hive Router and the subgraph services.
25+
pub secret: String,
26+
27+
// The key name used in the extensions field of the outgoing requests to store the HMAC signature.
28+
#[serde(default = "default_extension_name")]
29+
pub extension_name: String,
30+
}
31+
32+
fn default_extension_name() -> String {
33+
"hmac_signature".to_string()
34+
}
35+
36+
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
37+
pub enum BooleanOrExpression {
38+
Boolean(bool),
39+
Expression(Expression),
40+
}
41+
42+
impl Default for BooleanOrExpression {
43+
fn default() -> Self {
44+
BooleanOrExpression::Boolean(false)
45+
}
46+
}
47+
48+
impl HMACSignatureConfig {
49+
pub fn is_disabled(&self) -> bool {
50+
match &self.enabled {
51+
BooleanOrExpression::Boolean(b) => !*b,
52+
BooleanOrExpression::Expression(_) => {
53+
// If it's an expression, we consider it enabled for the purpose of this check.
54+
false
55+
}
56+
}
57+
}
58+
}

lib/router-config/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod csrf;
33
mod env_overrides;
44
pub mod graphiql;
55
pub mod headers;
6+
pub mod hmac_signature;
67
pub mod http_server;
78
pub mod jwt_auth;
89
pub mod log;
@@ -92,6 +93,12 @@ pub struct HiveRouterConfig {
9293
/// Configuration for overriding labels.
9394
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
9495
pub override_labels: OverrideLabelsConfig,
96+
97+
#[serde(
98+
default,
99+
skip_serializing_if = "hmac_signature::HMACSignatureConfig::is_disabled"
100+
)]
101+
pub hmac_signature: hmac_signature::HMACSignatureConfig,
95102
}
96103

97104
#[derive(Debug, thiserror::Error)]

0 commit comments

Comments
 (0)