feat: resolve upstream auth at runtime
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use crank_core::{AuthConfig, AuthProfile, SecretId};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{PreparedRequest, RuntimeError};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ResolvedAuth {
|
||||
Bearer { header_name: String, token: String },
|
||||
Basic { username: String, password: String },
|
||||
ApiKeyHeader { header_name: String, value: String },
|
||||
ApiKeyQuery { param_name: String, value: String },
|
||||
}
|
||||
|
||||
impl ResolvedAuth {
|
||||
pub fn from_profile(
|
||||
profile: &AuthProfile,
|
||||
secrets: &BTreeMap<SecretId, Value>,
|
||||
) -> Result<Self, RuntimeError> {
|
||||
match &profile.config {
|
||||
AuthConfig::Bearer(config) => Ok(Self::Bearer {
|
||||
header_name: config.header_name.clone(),
|
||||
token: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?,
|
||||
}),
|
||||
AuthConfig::Basic(config) => Ok(Self::Basic {
|
||||
username: extract_secret_string(
|
||||
secrets,
|
||||
&config.username_secret_id,
|
||||
&["username", "value"],
|
||||
)?,
|
||||
password: extract_secret_string(
|
||||
secrets,
|
||||
&config.password_secret_id,
|
||||
&["password", "value"],
|
||||
)?,
|
||||
}),
|
||||
AuthConfig::ApiKeyHeader(config) => Ok(Self::ApiKeyHeader {
|
||||
header_name: config.header_name.clone(),
|
||||
value: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?,
|
||||
}),
|
||||
AuthConfig::ApiKeyQuery(config) => Ok(Self::ApiKeyQuery {
|
||||
param_name: config.param_name.clone(),
|
||||
value: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&self, prepared_request: &mut PreparedRequest) {
|
||||
match self {
|
||||
Self::Bearer { header_name, token } => {
|
||||
prepared_request
|
||||
.headers
|
||||
.insert(header_name.clone(), format!("Bearer {token}"));
|
||||
}
|
||||
Self::Basic { username, password } => {
|
||||
let credentials = STANDARD.encode(format!("{username}:{password}"));
|
||||
prepared_request
|
||||
.headers
|
||||
.insert("Authorization".to_owned(), format!("Basic {credentials}"));
|
||||
}
|
||||
Self::ApiKeyHeader { header_name, value } => {
|
||||
prepared_request
|
||||
.headers
|
||||
.insert(header_name.clone(), value.clone());
|
||||
}
|
||||
Self::ApiKeyQuery { param_name, value } => {
|
||||
prepared_request
|
||||
.query_params
|
||||
.insert(param_name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_secret_string(
|
||||
secrets: &BTreeMap<SecretId, Value>,
|
||||
secret_id: &SecretId,
|
||||
preferred_fields: &[&str],
|
||||
) -> Result<String, RuntimeError> {
|
||||
let Some(value) = secrets.get(secret_id) else {
|
||||
return Err(RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
if let Some(text) = value.as_str() {
|
||||
return Ok(text.to_owned());
|
||||
}
|
||||
|
||||
let Some(object) = value.as_object() else {
|
||||
return Err(RuntimeError::InvalidAuthSecretValue {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
details: "secret payload must be a string or object".to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
for field in preferred_fields {
|
||||
if let Some(text) = object.get(*field).and_then(Value::as_str) {
|
||||
return Ok(text.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
Err(RuntimeError::InvalidAuthSecretValue {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
details: format!(
|
||||
"secret payload must contain one of: {}",
|
||||
preferred_fields.join(", ")
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthKind, AuthProfile,
|
||||
BasicAuthConfig, BearerAuthConfig, SecretId, WorkspaceId,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{PreparedRequest, auth::ResolvedAuth};
|
||||
|
||||
#[test]
|
||||
fn applies_bearer_auth_to_headers() {
|
||||
let auth = ResolvedAuth::from_profile(
|
||||
&AuthProfile {
|
||||
id: "auth_01".into(),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
name: "bearer".to_owned(),
|
||||
kind: AuthKind::Bearer,
|
||||
config: AuthConfig::Bearer(BearerAuthConfig {
|
||||
header_name: "Authorization".to_owned(),
|
||||
secret_id: SecretId::new("secret_token"),
|
||||
}),
|
||||
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
},
|
||||
&BTreeMap::from([(SecretId::new("secret_token"), json!({"token":"abc"}))]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut request = PreparedRequest::default();
|
||||
|
||||
auth.apply(&mut request);
|
||||
|
||||
assert_eq!(
|
||||
request.headers.get("Authorization").map(String::as_str),
|
||||
Some("Bearer abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_basic_auth_to_headers() {
|
||||
let auth = ResolvedAuth::from_profile(
|
||||
&AuthProfile {
|
||||
id: "auth_01".into(),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
name: "basic".to_owned(),
|
||||
kind: AuthKind::Basic,
|
||||
config: AuthConfig::Basic(BasicAuthConfig {
|
||||
username_secret_id: SecretId::new("secret_user"),
|
||||
password_secret_id: SecretId::new("secret_pass"),
|
||||
}),
|
||||
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
},
|
||||
&BTreeMap::from([
|
||||
(SecretId::new("secret_user"), json!({"username":"demo"})),
|
||||
(SecretId::new("secret_pass"), json!({"password":"s3cr3t"})),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut request = PreparedRequest::default();
|
||||
|
||||
auth.apply(&mut request);
|
||||
|
||||
assert_eq!(
|
||||
request.headers.get("Authorization").map(String::as_str),
|
||||
Some("Basic ZGVtbzpzM2NyM3Q=")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_api_key_header_auth() {
|
||||
let auth = ResolvedAuth::from_profile(
|
||||
&AuthProfile {
|
||||
id: "auth_01".into(),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
name: "api-header".to_owned(),
|
||||
kind: AuthKind::ApiKeyHeader,
|
||||
config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
|
||||
header_name: "X-Api-Key".to_owned(),
|
||||
secret_id: SecretId::new("secret_api_key"),
|
||||
}),
|
||||
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
},
|
||||
&BTreeMap::from([(SecretId::new("secret_api_key"), json!("key-123"))]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut request = PreparedRequest::default();
|
||||
|
||||
auth.apply(&mut request);
|
||||
|
||||
assert_eq!(
|
||||
request.headers.get("X-Api-Key").map(String::as_str),
|
||||
Some("key-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_api_key_query_auth() {
|
||||
let auth = ResolvedAuth::from_profile(
|
||||
&AuthProfile {
|
||||
id: "auth_01".into(),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
name: "api-query".to_owned(),
|
||||
kind: AuthKind::ApiKeyQuery,
|
||||
config: AuthConfig::ApiKeyQuery(ApiKeyQueryAuthConfig {
|
||||
param_name: "api_key".to_owned(),
|
||||
secret_id: SecretId::new("secret_api_key"),
|
||||
}),
|
||||
created_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-04-07T00:00:00Z".to_owned(),
|
||||
},
|
||||
&BTreeMap::from([(SecretId::new("secret_api_key"), json!({"value":"key-123"}))]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut request = PreparedRequest::default();
|
||||
|
||||
auth.apply(&mut request);
|
||||
|
||||
assert_eq!(
|
||||
request.query_params.get("api_key").map(String::as_str),
|
||||
Some("key-123")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,14 @@ pub enum RuntimeError {
|
||||
InvalidPreparedRequest { details: String },
|
||||
#[error("invalid streaming payload: {details}")]
|
||||
InvalidStreamingPayload { details: String },
|
||||
#[error("auth profile {auth_profile_id} was not found")]
|
||||
MissingAuthProfile { auth_profile_id: String },
|
||||
#[error("secret {secret_id} was not found")]
|
||||
MissingSecret { secret_id: String },
|
||||
#[error("secret {secret_id} does not have current version {version}")]
|
||||
MissingSecretVersion { secret_id: String, version: u32 },
|
||||
#[error("invalid secret payload for {secret_id}: {details}")]
|
||||
InvalidAuthSecretValue { secret_id: String, details: String },
|
||||
#[error("secret crypto error: {details}")]
|
||||
SecretCrypto { details: String },
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ use crank_core::{ExecutionMode, Target, TransportBehavior};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation, WindowExecutionResult,
|
||||
AdapterResponse, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeOperation,
|
||||
WindowExecutionResult,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -42,8 +43,18 @@ impl RuntimeExecutor {
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
input: &Value,
|
||||
) -> Result<Value, RuntimeError> {
|
||||
self.execute_with_auth(operation, input, None).await
|
||||
}
|
||||
|
||||
pub async fn execute_with_auth(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
input: &Value,
|
||||
resolved_auth: Option<&ResolvedAuth>,
|
||||
) -> Result<Value, RuntimeError> {
|
||||
let prepared_request = self.prepare_request(operation, input)?;
|
||||
let prepared_request = apply_resolved_auth(prepared_request, resolved_auth);
|
||||
self.execute_prepared(operation, prepared_request).await
|
||||
}
|
||||
|
||||
@@ -51,6 +62,15 @@ impl RuntimeExecutor {
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
input: &Value,
|
||||
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||
self.execute_window_with_auth(operation, input, None).await
|
||||
}
|
||||
|
||||
pub async fn execute_window_with_auth(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
input: &Value,
|
||||
resolved_auth: Option<&ResolvedAuth>,
|
||||
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
||||
return Err(RuntimeError::MissingStreamingConfig {
|
||||
@@ -66,6 +86,7 @@ impl RuntimeExecutor {
|
||||
}
|
||||
|
||||
let prepared_request = self.prepare_request(operation, input)?;
|
||||
let prepared_request = apply_resolved_auth(prepared_request, resolved_auth);
|
||||
let adapter_response = if matches!(
|
||||
streaming.transport_behavior,
|
||||
TransportBehavior::ServerStream
|
||||
@@ -86,6 +107,16 @@ impl RuntimeExecutor {
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
input: &Value,
|
||||
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||
self.execute_session_seed_with_auth(operation, input, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn execute_session_seed_with_auth(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
input: &Value,
|
||||
resolved_auth: Option<&ResolvedAuth>,
|
||||
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
||||
return Err(RuntimeError::MissingStreamingConfig {
|
||||
@@ -113,7 +144,8 @@ impl RuntimeExecutor {
|
||||
config.max_items = Some(seed_limit);
|
||||
}
|
||||
|
||||
self.execute_window(&seeded_operation, input).await
|
||||
self.execute_window_with_auth(&seeded_operation, input, resolved_auth)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn prepare_request(
|
||||
@@ -396,6 +428,17 @@ fn finalize_output(
|
||||
.unwrap_or_else(|| Value::Object(Map::new())))
|
||||
}
|
||||
|
||||
fn apply_resolved_auth(
|
||||
mut prepared_request: PreparedRequest,
|
||||
resolved_auth: Option<&ResolvedAuth>,
|
||||
) -> PreparedRequest {
|
||||
if let Some(resolved_auth) = resolved_auth {
|
||||
resolved_auth.apply(&mut prepared_request);
|
||||
}
|
||||
|
||||
prepared_request
|
||||
}
|
||||
|
||||
fn merge_headers(
|
||||
static_headers: &BTreeMap<String, String>,
|
||||
execution_headers: &BTreeMap<String, String>,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
mod aggregation;
|
||||
mod auth;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod model;
|
||||
mod redaction;
|
||||
mod secret_crypto;
|
||||
mod streaming;
|
||||
|
||||
pub use auth::ResolvedAuth;
|
||||
pub use error::RuntimeError;
|
||||
pub use executor::RuntimeExecutor;
|
||||
pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation};
|
||||
pub use secret_crypto::SecretCrypto;
|
||||
pub use streaming::WindowExecutionResult;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
aead::{Aead, OsRng, rand_core::RngCore},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::RuntimeError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretCrypto {
|
||||
cipher: Aes256Gcm,
|
||||
key_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CipherEnvelope {
|
||||
nonce_b64: String,
|
||||
ciphertext_b64: String,
|
||||
}
|
||||
|
||||
impl SecretCrypto {
|
||||
pub fn new(master_key: &str) -> Result<Self, RuntimeError> {
|
||||
let trimmed = master_key.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(RuntimeError::SecretCrypto {
|
||||
details: "CRANK_MASTER_KEY must not be empty".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let digest = Sha256::digest(trimmed.as_bytes());
|
||||
let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| {
|
||||
RuntimeError::SecretCrypto {
|
||||
details: format!("failed to initialize secret crypto: {error}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
cipher,
|
||||
key_version: "v1".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn key_version(&self) -> &str {
|
||||
&self.key_version
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, value: &Value) -> Result<String, RuntimeError> {
|
||||
let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: format!("failed to serialize secret value: {error}"),
|
||||
})?;
|
||||
let mut nonce_bytes = [0_u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = self
|
||||
.cipher
|
||||
.encrypt(nonce, plaintext.as_ref())
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: format!("failed to encrypt secret value: {error}"),
|
||||
})?;
|
||||
let envelope = CipherEnvelope {
|
||||
nonce_b64: STANDARD.encode(nonce_bytes),
|
||||
ciphertext_b64: STANDARD.encode(ciphertext),
|
||||
};
|
||||
|
||||
serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: format!("failed to encode secret ciphertext: {error}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decrypt(&self, ciphertext: &str) -> Result<Value, RuntimeError> {
|
||||
let envelope: CipherEnvelope =
|
||||
serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: format!("failed to decode secret envelope: {error}"),
|
||||
})?;
|
||||
let nonce_bytes =
|
||||
STANDARD
|
||||
.decode(envelope.nonce_b64)
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: format!("failed to decode secret nonce: {error}"),
|
||||
})?;
|
||||
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
|
||||
RuntimeError::SecretCrypto {
|
||||
details: format!("failed to decode secret payload: {error}"),
|
||||
}
|
||||
})?;
|
||||
let plaintext = self
|
||||
.cipher
|
||||
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: format!("failed to decrypt secret value: {error}"),
|
||||
})?;
|
||||
|
||||
serde_json::from_slice(&plaintext).map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: format!("failed to deserialize secret value: {error}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::SecretCrypto;
|
||||
|
||||
#[test]
|
||||
fn roundtrips_secret_payload() {
|
||||
let crypto = SecretCrypto::new("test-master-key").unwrap();
|
||||
let plaintext = json!({
|
||||
"token": "top-secret",
|
||||
"username": "demo"
|
||||
});
|
||||
|
||||
let ciphertext = crypto.encrypt(&plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&ciphertext).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user